widgetfied

© 2026 Widgetfied

Server-to-Server

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:

1

Customer pays via embedded widget

Stripe Checkout completes; card is charged via your Stripe Connect account.

2

Stripe fires checkout.session.completed

Sent to Widgetfied's Stripe webhook endpoint (server-to-server).

3

Widgetfied dispatches a normalized, signed event

POSTs payment.completed to your registered webhook URL with X-Widgetfied-Signature header.

4

Your receiver verifies HMAC + acts

Verify signature, identify the user via metadata, perform fulfillment (credit tokens, send license key, etc.).

5

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
# Bash / macOS / Linux
openssl rand -hex 32
# Outputs a 64-character hex string. Store it as
# WIDGETFIED_WEBHOOK_SECRET in your production env.
javascript
// 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.

html
<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>
Each data-meta-<kebab> attribute becomes metadata.<camelCase> on the webhook payload.
Reserved keys you must NOT use: tenant-id, tenant-name, booking-reference, platform-fee-amount, platform-fee-percent, pass-processing-fees, tip-amount. Use namespaced names like host-tenant-id.
Stripe limits metadata to 50 keys, 40 chars per key, 500 chars per value. Platform uses ~7 — you have ~43 slots.
data-customer-email is critical: Stripe uses it to send the receipt email automatically.

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.

typescript
// 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 });
}
Read req.text() BEFORE parsing JSON — HMAC is computed over the exact raw bytes.
Use crypto.timingSafeEqual, not === — string equality is vulnerable to timing attacks.
NEVER skip signature verification, even in development — you'll forget to add it back.
Return 5xx (not 200) on real failures so Widgetfied retries — Stripe re-fires the event.
Make your fulfillment idempotent on sessionId — duplicate deliveries are normal.

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.

bash
# 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):

json
{
  "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 Created

Re-registration response (HTTP 200, upserted):

json
{
  "ok": true,
  "webhook": { ... },
  "updated": true
}
// HTTP 200 OK — re-registering the same URL upserts (rotates secret + reactivates)
The (tenantId, url) pair is unique. Re-POSTing with the same URL updates the secret + reactivates the subscription. This is how you rotate your secret.
Save the webhook id from the response — you'll need it to delete the subscription later.
Use your tenant API key (sk_live_...), NOT the dashboard admin key. Per-tenant keys limit blast radius.

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

http
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)

json
{
  "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

json
{
  "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

json
{
  "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:

POST/api/tenants/{tenantId}/webhooks

Register a new subscription, or upsert if (tenantId, url) already exists.

body: { url, events[], secret }returns: 201 Created (new) or 200 OK with updated:true (upsert)
GET/api/tenants/{tenantId}/webhooks

List all subscriptions for the tenant. Never returns the secret.

body: nonereturns: { ok, webhooks: [{id, url, events, isActive, lastTriggeredAt, failureCount, createdAt}] }
DELETE/api/tenants/{tenantId}/webhooks/{webhookId}

Remove a subscription permanently. Cascade-deletes its delivery history.

body: nonereturns: { ok: true, deleted: <id> }

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
⚡ Quick setup
🚀 Get Started
DOCS