Skip to content
Operate And Ship

Webhooks And Async Workflows

One inbound Stripe webhook with signature verification, two-layer idempotency, and stale-claim recovery in Postgres. No background queue ships; after(), OutboundEffect, and common providers to plug in are below.

Last updated on

9 min read

On This Page

What Ships Today

One inbound endpoint. No GitHub, Slack, or Discord webhook. To add another, follow Adding A Different Webhook Provider.

SurfaceEndpointPurpose
Stripe webhookapps/web/app/api/webhooks/stripe/route.tsSignature-verified Stripe handler with two-layer idempotency and structured logs to PostHog Logs

If billing is off (isBillingEnabled() is false), the route returns 503 and does not process events.

The Stripe Webhook

The route verifies the signature, then calls processWebhookEvent in packages/payments/src/stripe/webhook.ts.

Endpoint And Signature Verification

It reads the raw body, verifies the Stripe signature against STRIPE_WEBHOOK_SECRET, and rejects a missing signature with 400 before any handler runs:

const signature = req.headers.get("stripe-signature");

if (!signature) {
  log.warn("Stripe webhook missing signature header", {
    attributes: { endpoint: "/api/webhooks/stripe" },
  });
  return new Response("Missing stripe-signature header", { status: 400 });
}

processWebhookEvent calls stripe.webhooks.constructEvent. A bad signature throws StripeWebhookSignatureError, which the route maps to 400. Other thrown errors become 500 with a structured log.

Two-Layer Idempotency

Idempotency means running the same delivery twice has the same effect as running it once. Stripe re-delivers until it gets a 2xx, so the kit guards double-processing at two levels.

LayerTableKeyWhat it protects against
EventStripeWebhookEventeventId (unique)Stripe retries the same event id
Side effectOutboundEffect(kind, key) semantic keyDifferent events drive the same business action; emails and analytics still fire only once

The event claim is an atomic prisma.stripeWebhookEvent.create({ status: "processing" }). The unique constraint on eventId turns a duplicate delivery into a P2002 error. claimEvent inspects that and returns one of four outcomes:

Claim outcomeWhat it meansRoute response
claimedFirst successful claim, or a failed/stale reclaim under the reclaim cap. Run the handler.200 after the handler completes
processedAnother worker already finished this event. Mapped to outcome duplicate.200 (duplicate suppression)
in_progressAnother worker still owns the claim and is inside the stale window.503 + Retry-After: 60
failed_terminalReclaim budget exhausted (MAX_RECLAIM_COUNT is 5). Mapped to outcome failed.200 so Stripe stops retrying; logged at error level for ops

The side-effect layer adds a second guard inside handlers. Example: claimEffect("email", "subscription_created:sub_xxx") records the dispatch in OutboundEffect before the email sends. If a later Stripe event resolves to the same business action, the second dispatch sees the existing claim and skips. The key is always semantic, never the Stripe eventId, so dedupe still works when Stripe sends the same business change under a different id.

Stale Processing Recovery

If a worker crashes mid-handler (timeout, OOM, deploy kill), the StripeWebhookEvent row can stay at status: processing with no one to finish it. Before returning in_progress, claimEvent tries two reclaim paths. Both increment reclaimCount and stop after 5 reclaims:

  1. Failed reclaim. A row at status: failed with reclaimCount < 5 is re-claimable so Stripe retries can recover from transient errors.
  2. Stale reclaim. A row still at processing whose createdAt is older than 5 minutes (STALE_PROCESSING_WINDOW_MS) is treated as dead and re-claimed, also only while under the cap.
const STALE_PROCESSING_WINDOW_MS = 5 * 60 * 1000;
const MAX_RECLAIM_COUNT = 5;

const staleBefore = new Date(reclaimedAt.getTime() - STALE_PROCESSING_WINDOW_MS);

const staleReclaim = await prisma.stripeWebhookEvent.updateMany({
  where: {
    eventId,
    status: "processing",
    createdAt: { lt: staleBefore },
    reclaimCount: { lt: MAX_RECLAIM_COUNT },
  },
  data: {
    status: "processing",
    failureReason: null,
    createdAt: reclaimedAt,
    reclaimCount: { increment: 1 },
  },
});

Combined with Stripe's retry schedule (immediate, then minutes, then hours, up to 3 days), a stuck event usually recovers after the 5-minute window. After five reclaims the row goes terminal (failed_terminal), the route returns 200, and Stripe stops retrying. That is the escape hatch for a recurring crash loop.

This design fits stateless serverless. Each invocation reads the row, claims atomically, dispatches, writes back, and returns. The 5-minute window and the 60-second Retry-After coordinate independent invocations through Postgres. They do not keep a single lambda alive for that long.

How It Holds Up Under Serverless Failure Modes

ScenarioWhat happens
Two Stripe deliveries arrive milliseconds apartOne invocation wins the unique-constraint race and runs the handler. The other sees in_progress and returns 503 + Retry-After: 60. By the next Stripe retry, the row is usually processed, so the retry becomes a duplicate 200.
Lambda dies mid-handler (timeout, OOM)The processing row stays stuck. Early retries return 503. After 5 minutes, stale reclaim re-runs the handler. Side effects do not double-fire: successful OutboundEffect claims stay recorded; failed best-effort claims are released.
Lambda dies after markEventProcessed wrote processedStripe's retry sees processed and gets the duplicate-suppression 200. No re-run.
Lambda dies after the handler ran but before markEventProcessedStale reclaim re-runs the handler. Domain writes are upsert-shaped (subscription upsert, org update), so they are safe to repeat. Email and analytics see existing OutboundEffect claims and skip.
Informational email/analytics provider is downBest-effort helpers release the OutboundEffect claim and log a warning. The webhook still returns 200. The next Stripe retry can try the side effect again.
Critical dunning email fails (payment_failed, payment_action_required)Those use mustSucceed: true. Failure rethrows, the event is marked failed, and the route returns 500 so Stripe retries. Orphaned claims for those effects can be reclaimed after 10 minutes if the worker crashed between claim and a confirmed send.

Vercel Hobby caps function execution at 10 seconds. Kit handlers usually finish well under a second, but a slow Postgres or Stripe call can push past the limit. Stale reclaim picks the event up within about 5 to 6 minutes on the next retry. Use Pro (60-second timeout) or higher in production for headroom.

Best-Effort Side Effects

Email and analytics go through sendBestEffortWebhookEmail and sendBestEffortAnalyticsEvent. Both use withDedupedEffect: claim, dispatch, release the claim on failure (and optionally escalate).

await sendBestEffortWebhookEmail({
  effectKey: "subscription_created:sub_xxx",
  to: owner.email,
  subject,
  body: html,
  // mustSucceed: true  // only for payment_failed / payment_action_required
});
  • Default (mustSucceed unset or false). Welcome, receipt, trial, and cancellation emails, plus analytics. A Plunk outage becomes a log.warn and a released claim. The webhook still returns 200. A successful send keeps its claim, so a later retry does not send twice.
  • mustSucceed: true. Used for invoice.payment_failed and invoice.payment_action_required. Failure fails the webhook (500) so Stripe retries. Confirmed sends stamp sentAt so retries do not re-send. Claims left with sentAt = null past 10 minutes can be reclaimed.

Events Handled Today

Nine event types. Billing owns the subscription lifecycle; this page owns delivery and idempotency. See Billing: The Webhook Path for the product-facing dispatch table.

EventWhat it does
checkout.session.completedPersist stripeCustomerId on the org when missing
customer.subscription.createdUpsert subscription, sync entitlement, welcome email, subscription_started analytics
customer.subscription.updatedUpsert subscription, sync entitlement
customer.subscription.deletedMark subscription canceled, sync entitlement, cancellation email
customer.subscription.trial_will_endTrial-ending reminder email
invoice.finalizedStructured log only
invoice.payment_action_required3DS / SCA confirmation email (mustSucceed)
invoice.payment_failedPayment-failed email (mustSucceed)
invoice.payment_succeededReceipt email

Adding A Stripe Event

Register the event in the dispatch table

Each handler module owns a Record<string, (event: Stripe.Event) => Promise<void>>: subscriptionEventHandlers in packages/payments/src/stripe/webhook/handlers/subscription.ts, invoiceEventHandlers in ./handlers/invoice.ts. webhook.ts merges them into one lookup. Adding an event is one entry; you do not edit a switch. Use ensurePayload<T> to narrow event.data.object:

export const subscriptionEventHandlers: Record<
  string,
  (event: Stripe.Event) => Promise<void>
> = {
  // ...
  "customer.subscription.paused": (event) =>
    handleSubscriptionPaused(
      ensurePayload<Stripe.Subscription>(event, "subscription")
    ),
};

Implement the handler

Mirror handleSubscriptionCreated: resolve the org, upsert the subscription, then dispatch email/analytics if needed. Use resolveOrganizationId(subscription) for customer-id vs metadata reconciliation.

Wrap side effects with stable effect keys

Emails go through sendBestEffortWebhookEmail({ effectKey, to, subject, body }). Analytics go through sendBestEffortAnalyticsEvent({ effectKey, distinctId, event, properties }). Key on the business action, not the Stripe event id (subscription_paused:sub_xxx, not …:evt_xxx). That is what keeps dedupe stable across re-deliveries with different event ids. Set mustSucceed: true only when a failed send must fail the webhook (dunning).

Subscribe in Stripe

Add the event type on your webhook endpoint in the Stripe dashboard. Repeat for live mode and any test-mode endpoints you use locally.

Adding A Different Webhook Provider

Create the route

Add apps/web/app/api/webhooks/<provider>/route.ts. Read the body as text first. Signature verification needs the raw bytes, not a parsed JSON object.

Verify the signature

Use the provider's signature header and your webhook secret. Return 400 on a missing or invalid signature before any handler runs. Throw a typed Error subclass for signature failures so the route can map it to 400 cleanly, same as Stripe.

Add idempotency

Two options:

  1. Reuse claimEffect("<provider>_event", eventId) and releaseEffectClaim against OutboundEffect. Cheap, no new table, but shares the row pattern with emails and analytics.
  2. Add a provider-specific table mirroring StripeWebhookEvent (id, status, processedAt, failureReason, reclaim fields). More code, clearer separation from outbound-effect bookkeeping.

For low volume, option 1 is fine. For a dedicated handler, option 2 reads cleaner.

Mirror the logging and after-flush pattern

Use log.{info,warn,error} from @syntaxkit/analytics/logger for structured logs to PostHog Logs. Schedule after(() => loggerProvider?.forceFlush()) before you return so the response is not blocked on the OTLP batch flush. The Stripe route is the reference.

Async Workflows

SyntaxKit ships no scheduled jobs and no background queues. The only work that runs after a response is after()-deferred work in route handlers, plus the reusable OutboundEffect dedupe pattern. If you need cron, durable execution, or long-running tasks, bring a provider. The kit does not pick one.

CI is separate from app workflows. .github/workflows/stripe-live.yml runs the live billing suite on manual dispatch (and a lightweight price-drift check on push to main). That is not an in-app job queue.

after() for fire-and-forget work in a request

Next's after() runs a callback after the response is sent. On Vercel the lambda stays alive long enough to drain it (billed duration, not client latency). The Stripe route uses it to flush the OTLP log batch:

after(async () => {
  await loggerProvider?.forceFlush();
});

return new Response("OK", { status: 200 });

Use it for bounded fire-and-forget work that must not delay the response: log flushes, analytics flushes, secondary notifications, cache warm-ups. after() is not a queue. A stuck callback eventually times out with the lambda.

Reuse OutboundEffect for a scheduler or queue

If you wire in a scheduler later (cron tick, durable queue, Inngest run), reuse OutboundEffect for at-least-once delivery. claimEffect(kind, key) plus releaseEffectClaim work the same whether the job came from Stripe, a cron tick, or a queued message.

The helpers live in packages/payments/src/stripe/webhook/idempotency.ts and are intra-package only today (the @syntaxkit/payments/server barrel does not re-export them; they are marked @internal). Before reusing them from non-webhook code, promote the two functions in packages/payments/src/server.ts:

// packages/payments/src/server.ts
export { claimEffect, releaseEffectClaim } from "./stripe/webhook/idempotency";

Then a scheduled job can reuse the pattern:

import { claimEffect, releaseEffectClaim } from "@syntaxkit/payments/server";

const claimed = await claimEffect("daily_digest", `digest:${userId}:${date}`);
if (!claimed) return;
try {
  await sendDailyDigest(userId);
} catch (error) {
  await releaseEffectClaim("daily_digest", `digest:${userId}:${date}`);
  throw error;
}

If auth, AI, and other packages all need the helpers, move them to @syntaxkit/shared so the API stays package-neutral.

Where To Go Next

Also useful: Monitoring, Email, and Security.

Was this page helpful?

On this page