Server Integration — Payment Widget
For hosts that need to do work server-side after a payment succeeds — credit a user account, deliver a license key, send a custom email, mark a row paid in your own database. Connect your backend to Widgetfied via HMAC-signed webhooks.
🎯 Why You Need This
The browser-side payment:complete bus event is for UX only — it can be forged from devtools. For anything that costs money or grants entitlements (token grants, license keys, account upgrades), you must verify the payment server-side via signed webhooks.
- Stripe → Widgetfied → your backend via signed event
- HMAC-SHA256 verification — cryptographically tamper-proof
- Automatic retries (Stripe retries up to 3 days on failure)
- Idempotency by sessionId — safe to receive duplicate deliveries
- NOT this: trusting the browser bus event for fulfillment
- NOT this: polling Stripe directly from your frontend
✅ Before You Start
Confirm these are in place before integrating:
Stripe Connect account is onboarded
Your Connect account has charges_enabled and details_submitted. Check at /tenant/payments.
Set up Stripe Connect →Payment widget is embedded and working
The widget on your customer-facing page successfully opens Stripe Checkout. Test with a $0.50 charge.
Embed the widget →You have a public webhook endpoint URL
Your backend is deployed (Vercel, Render, AWS, etc.) and can receive POST requests at a public URL. localhost won't work — use ngrok or cloudflared for local testing.
You have your tenant API key
Available in the Widgetfied dashboard top-right dropdown (click your avatar). Format: sk_live_…
🔄 How It Works
The full execution path from customer click to credited account:
Customer pays via embedded widget
Stripe Checkout completes; card is charged via your Stripe Connect account.
Stripe fires checkout.session.completed
Sent to Widgetfied's Stripe webhook endpoint (server-to-server).
Widgetfied dispatches a normalized, signed event
POSTs payment.completed to your registered webhook URL with X-Widgetfied-Signature header.
Your receiver verifies HMAC + acts
Verify signature, identify the user via metadata, perform fulfillment (credit tokens, send license key, etc.).
Your receiver returns 2xx
Widgetfied logs success. If you return 5xx, Stripe retries the upstream event with exponential backoff.
Step 1 — Generate your HMAC secret
You generate the secret on YOUR side. Widgetfied stores a copy at registration time and uses it to sign every outbound delivery to you. Use openssl or Node's crypto module:
# Bash / macOS / Linux
openssl rand -hex 32
# Outputs a 64-character hex string. Store it as
# WIDGETFIED_WEBHOOK_SECRET in your production env.// Node.js
const crypto = require('crypto');
const secret = crypto.randomBytes(32).toString('hex');
console.log(secret);
// 64-char hex — store as WIDGETFIED_WEBHOOK_SECRET in your env.The secret must be at least 32 characters. Don't commit it to git. Use your platform's secret store (Vercel Environment Variables, Railway secrets, etc.).
Step 2 — Set up custom data on your embed
When you render the payment widget, attach data-meta-* attributes to identify your end user. These get round-tripped through Stripe metadata and arrive on your webhook payload — your only way to correlate the payment back to a user account.
<div
data-widget="payment"
data-tenant="YOUR_TENANT_ID"
data-type="custom"
data-amount="25.00"
data-product-name="500 Tokens"
data-customer-email="user@example.com"
data-meta-host-tenant-id="lga_my_tenant_uuid"
data-meta-clerk-user-id="user_abc123"
data-meta-pack-amount="500"
data-meta-nonce="optional-server-generated-uuid">
</div>
<script src="https://cdn.widgetfied.com/portal.js"></script>Step 3 — Build your webhook receiver
Your endpoint must verify the HMAC signature before doing any work. Reject anything that fails verification — never trust the payload contents until you've confirmed it came from Widgetfied.
// Example: Next.js App Router
// app/api/widgetfied/webhook/route.ts
import crypto from 'crypto';
import { NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs'; // crypto.timingSafeEqual requires Node
function verifySignature(rawBody: string, sigHeader: string | null, secret: string): boolean {
if (!sigHeader) return false;
const match = sigHeader.match(/^t=(\d+),v1=([a-f0-9]+)$/);
if (!match) return false;
const [, , signatureHex] = match;
const expectedHex = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
if (signatureHex.length !== expectedHex.length) return false;
// Constant-time comparison to prevent timing attacks
return crypto.timingSafeEqual(
Buffer.from(signatureHex, 'hex'),
Buffer.from(expectedHex, 'hex')
);
}
export async function POST(req: NextRequest): Promise<NextResponse> {
const secret = process.env.WIDGETFIED_WEBHOOK_SECRET;
if (!secret) {
return NextResponse.json({ error: 'server not configured' }, { status: 500 });
}
// CRITICAL: read raw body BEFORE parsing — HMAC must match the
// exact bytes Widgetfied signed. Don't use req.json() first.
const rawBody = await req.text();
const sigHeader = req.headers.get('x-widgetfied-signature');
if (!verifySignature(rawBody, sigHeader, secret)) {
return NextResponse.json({ error: 'invalid signature' }, { status: 401 });
}
const event = JSON.parse(rawBody);
// Acknowledge unknown event types fast — we may add more later
if (event.type !== 'payment.completed' && event.type !== 'payment.failed') {
return NextResponse.json({ received: true, ignored: true });
}
if (event.type === 'payment.completed') {
const { sessionId, amount, currency, customerEmail, metadata } = event.data;
const { hostTenantId, clerkUserId, packAmount } = metadata;
// Validate required metadata
if (!hostTenantId || !clerkUserId) {
return NextResponse.json({ error: 'missing metadata' }, { status: 400 });
}
// Compute fulfillment value SERVER-SIDE — never trust client packAmount
// as the credit amount. Use it only as a sanity check.
const tokensCredited = computeTokensForUsd(amount / 100);
// Idempotent insert — sessionId is your natural dedup key.
// Use a UNIQUE constraint on session_id + INSERT … ON CONFLICT
// DO NOTHING (Postgres) so duplicate deliveries are no-ops.
try {
await db.insert(tokenPurchases).values({
sessionId, // UNIQUE constraint here
hostTenantId,
clerkUserId,
tokensCredited,
amountCents: amount,
receiptEmail: customerEmail,
}).onConflictDoNothing();
} catch (err) {
// Return 5xx so Widgetfied retries (don't ack a real failure)
return NextResponse.json({ error: 'credit failed' }, { status: 500 });
}
}
if (event.type === 'payment.failed') {
// Optional — surface the failure in your UI / send a notification
const { sessionId, failureMessage } = event.data;
await logPaymentFailure({ sessionId, failureMessage });
}
return NextResponse.json({ received: true });
}Step 4 — Register your webhook with Widgetfied
Once your endpoint is live, register it via the platform API. The platform stores your URL, secret, and event subscriptions. From this point on, every payment event for your tenant fires a signed delivery to your URL.
# Set the values you need (use a leading space on the export
# line to keep them out of shell history on macOS/zsh)
export WIDGETFIED_TENANT_API_KEY='sk_live_…' # from dashboard
export WIDGETFIED_WEBHOOK_SECRET='<your generated 64-char hex>'
curl -X POST https://widgetfied.com/api/tenants/YOUR_TENANT_ID/webhooks \
-H "x-api-key: $WIDGETFIED_TENANT_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"url\": \"https://yourapp.com/api/widgetfied/webhook\",
\"events\": [\"payment.completed\", \"payment.failed\"],
\"secret\": \"$WIDGETFIED_WEBHOOK_SECRET\"
}"First-time response (HTTP 201):
{
"ok": true,
"webhook": {
"id": "cmowd…",
"url": "https://yourapp.com/api/widgetfied/webhook",
"events": ["payment.completed", "payment.failed"],
"isActive": true,
"createdAt": "2026-05-08T03:56:52.525Z"
}
}
// HTTP 201 CreatedRe-registration response (HTTP 200, upserted):
{
"ok": true,
"webhook": { ... },
"updated": true
}
// HTTP 200 OK — re-registering the same URL upserts (rotates secret + reactivates)Step 5 — Test end-to-end
Run a real $0.50 charge through your widget and watch the chain:
- Customer-side: Stripe Checkout opens, payment completes, popup closes.
- Your webhook receiver: logs POST /api/widgetfied/webhook → 200 within 2-4 seconds.
- Your database: a new fulfillment row exists, scoped by sessionId.
- Stripe: receipt email arrives in the customer's inbox (if Connect account has receipts enabled).
- Idempotency: re-trigger the same signed event — your DB doesn't double-credit.
If anything fails, check the troubleshooting section below — the most common failures are auth middleware blocking your route and tunnel/deploy URL mismatches.
📨 Webhook Payload Reference
Every outbound delivery uses the same envelope. The data field varies by event type.
HTTP headers
POST https://yourapp.com/api/widgetfied/webhook
Content-Type: application/json
X-Widgetfied-Event: payment.completed
X-Widgetfied-Signature: t=1714855923,v1=<hex-hmac-sha256>Common envelope (all event types)
{
"id": "evt_<24 hex chars>", // unique per delivery
"type": "payment.completed" | "payment.failed",
"created": 1714855923, // unix seconds
"livemode": true, // false in non-production
"data": { ...event-specific... }
}data shape — payment.completed
{
"sessionId": "cs_xxx", // YOUR idempotency key
"paymentIntentId": "pi_xxx" | null,
"amount": 2500, // cents
"currency": "usd",
"customerEmail": "buyer@example.com" | null,
"metadata": {
// Platform-set fields (always present):
"tenantId": "...",
"tenantName": "...",
"bookingReference": "",
"platformFeeAmount": 33,
"platformFeePercent": 0.013,
"passProcessingFees": "false",
"tipAmount": 0,
// YOUR data-meta-* keys (kebab → camelCase):
"hostTenantId": "lga_my_tenant_uuid",
"clerkUserId": "user_abc123",
"packAmount": "500"
},
"paidAt": "2026-05-08T03:56:52.525Z"
}data shape — payment.failed
{
"sessionId": "cs_xxx", // SAME idempotency key as success path
"paymentIntentId": "pi_xxx",
"amount": 2500, // cents (the attempted amount)
"currency": "usd",
"customerEmail": "buyer@example.com" | null,
"failureCode": "card_declined" | null,
"failureMessage": "Your card was declined." | null,
"declineCode": "insufficient_funds" | null,
"metadata": { ...same as payment.completed... },
"failedAt": "2026-05-08T03:56:52.525Z"
}IMPORTANT: the same sessionId can fire payment.failed AND THEN payment.completed if a customer's first attempt declines and they retry on the same Stripe session. Do NOT dedupe failure notifications by sessionId alone — only dedupe credit operations.
✨ Best Practices
DB-enforced idempotency, not application-checked
Add a UNIQUE constraint on session_id at the schema level. Use INSERT … ON CONFLICT DO NOTHING (Postgres) or equivalent. SELECT-then-INSERT has a race window where two concurrent webhook deliveries can both pass the SELECT and both INSERT.
Compute fulfillment values server-side
Never trust client-supplied amounts as the source of truth. Read event.data.amount (Stripe-confirmed) and compute the fulfillment value yourself. Treat metadata.packAmount as a sanity check — if it disagrees with the actual amount, log and reject.
Drop the timestamp/replay window check
Stripe retries failed webhooks for up to 3 days; Widgetfied may queue retries for hours. Rejecting late deliveries orphans legitimate payments. The HMAC alone is the forgery defense — that's sufficient.
Return 5xx on real failures, never 200
A 200 acknowledges the event and Widgetfied stops retrying. If your fulfillment failed (DB outage, downstream service unreachable), return 500 so the event is re-fired automatically.
Lock down direct credit endpoints
After moving fulfillment to webhook-only, delete or gate any public endpoint that grants entitlements. The webhook receiver should be the ONLY path that mints tokens / unlocks features. Anything else is an exploit surface.
Use the bus event for UX only
window.qtPortalPayment "payment:complete" is forge-able from devtools. Use it to show "Crediting…" spinners and poll your own backend for the real fulfillment confirmation. NEVER call your credit API from the bus listener.
🐛 Troubleshooting
WebhookDelivery row shows succeeded=true but my handler never ran
Cause: Your auth middleware (Clerk, NextAuth, etc.) is intercepting the route and returning a 307 redirect to a sign-in page. The platform's fetch follows redirects by default and sees the final 200 as success.
Fix: Add /api/widgetfied/webhook to your middleware's public-route allowlist. The route authenticates via HMAC server-to-server, NOT via user sessions.
WebhookDelivery shows httpStatus=405 with HTML body
Cause: Your URL points to a static host (GitHub Pages, S3, etc.) that doesn't accept POST. The endpoint isn't backed by your Next.js app at all.
Fix: Verify DNS — your webhook URL's hostname must route to your Next.js deployment. Use a subdomain (api.yourapp.com) if your root domain serves a static landing page.
WebhookDelivery shows httpStatus=401 "invalid signature"
Cause: Secret mismatch — the value you sent in the registration body doesn't match what your receiver is using to verify.
Fix: Re-register the webhook (POST to the same URL upserts) with the exact value of WIDGETFIED_WEBHOOK_SECRET in your production env. Check for trailing whitespace, quotes, or env-var interpolation issues.
Customer paid but no receipt email arrived
Cause: Either data-customer-email wasn't set on the embed, or your Stripe Connect account has receipt emails disabled.
Fix: Verify customer_email is on the Checkout Session (use Stripe CLI: stripe checkout sessions list --stripe-account=acct_xxx). If yes, log into your Stripe Connect Express dashboard → Settings → Emails → confirm "Successful payments" toggle is ON.
Webhook is auto-disabled after several failures
Cause: After 10 consecutive failed deliveries, Widgetfied automatically disables the subscription to prevent retry storms.
Fix: Fix the underlying issue (check WebhookDelivery rows for the failure reason), then re-register the same URL with POST. Re-registration reactivates the subscription and resets failureCount.
📚 API Reference — Webhook Subscription Management
Manage your webhook subscriptions programmatically:
/api/tenants/{tenantId}/webhooksRegister a new subscription, or upsert if (tenantId, url) already exists.
/api/tenants/{tenantId}/webhooksList all subscriptions for the tenant. Never returns the secret.
/api/tenants/{tenantId}/webhooks/{webhookId}Remove a subscription permanently. Cascade-deletes its delivery history.
All endpoints accept either the platform DASHBOARD_API_KEY (admin) OR your tenant's own apiKey (Customer.apiKey, scoped to your tenant only). Use your tenant key for self-service — never share the dashboard key.
Need Help?
Stuck on signature verification, webhook deliveries, or fulfillment logic? Reach out — most issues are tunnel/deployment misconfigurations and easy to diagnose.
Contact Support