Webhooks And Async Workflows
External events and background processing.
Last updated on
9 min readSyntaxKit ships exactly one inbound webhook: Stripe. It's signature-verified, deduped at two levels (event id and semantic side-effect key), and recovers crashed workers through a stale-claim window. Every coordination decision lives in Postgres, never in lambda memory, so it survives timeouts, OOMs, mid-flight deploys, and concurrent deliveries.
Async workflows (cron, durable queues, long-running tasks) don't ship today. The Async Workflows section covers Next's after(), the reusable OutboundEffect dedupe pattern, and the providers buyers most often plug in.
The Stripe webhook
Endpoint, signature verification, and how it delegates to the handler.
Two-layer idempotency
Event-id and semantic side-effect keys keep retries from double-firing.
Add a Stripe event
Register a handler, wrap side effects, and subscribe in Stripe.
Async workflows
after() deferred work, OutboundEffect reuse, and providers to plug in.
What Ships Today
One inbound endpoint, nothing else: no GitHub, no Slack, no Discord. Adding another follows Adding A Different Webhook Provider.
| Surface | Endpoint | Purpose |
|---|---|---|
| Stripe webhook | apps/web/app/api/webhooks/stripe/route.ts | Signature-verified Stripe handler, two-layer idempotent, structured logs to PostHog Logs |
The Stripe Webhook
The route at apps/web/app/api/webhooks/stripe/route.ts verifies the signature, then delegates to 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; everything else falls through to a 500 with a structured log.
Two-Layer Idempotency
Stripe re-delivers events until it gets a 2xx. The kit guards against double-processing at two levels.
| Layer | Table | Key | What it protects against |
|---|---|---|---|
| Event | StripeWebhookEvent | eventId (unique) | Stripe retries the same event id |
| Side effect | OutboundEffect | (kind, key) semantic key | Different 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, which claimEvent inspects to pick one of three outcomes:
| Claim outcome | What it means | Route response |
|---|---|---|
claimed | First successful claim, or stale/failed reclaim. Run the handler. | 200 after the handler completes |
processed | Another worker already finished this event. | 200 (duplicate suppression) |
in_progress | Another worker still owns the claim and isn't past the stale window. | 503 + Retry-After: 60 |
The side-effect layer adds a second guard inside the handlers: claimEffect("email", "subscription_created:sub_xxx") records the dispatch in OutboundEffect before the email sends. If a different Stripe event later resolves to the same business action (plan changes, trial conversions), the second dispatch sees the existing claim and skips. The key is always semantic, never the Stripe eventId, so the dedupe stays meaningful even when Stripe sends the "right" event under a different id.
Stale Processing Recovery
If a worker crashes mid-handler (lambda timeout, OOM, deploy interrupting the invocation), the StripeWebhookEvent row stays at status: processing with no one to clear it. claimEvent has two reclaim branches before it returns in_progress:
- Failed reclaim. A row at
status: failedis re-claimable. Failed events get another shot every Stripe retry. - Stale reclaim. A row whose
createdAtis older than 5 minutes (STALE_PROCESSING_WINDOW_MS) is assumed dead and re-claimed.
const STALE_PROCESSING_WINDOW_MS = 5 * 60 * 1000;
const staleBefore = new Date(reclaimedAt.getTime() - STALE_PROCESSING_WINDOW_MS);
const staleReclaim = await prisma.stripeWebhookEvent.updateMany({
where: {
eventId,
status: "processing",
createdAt: { lt: staleBefore },
},
data: { status: "processing", failureReason: null, createdAt: reclaimedAt },
});Combined with Stripe's exponential retry schedule (immediate, then minutes, then hours, up to 3 days), a stuck event recovers on its own within a retry or two of the 5-minute window. No manual intervention.
This design is built for stateless serverless. Each lambda invocation reads the row, claims atomically, dispatches, writes back, returns. The 5-minute window and the 60-second Retry-After are coordination hints between independent invocations talking through Postgres. They don't ask any single lambda to stay alive for that duration.
How It Holds Up Under Serverless Failure Modes
The cases that bite naive webhook handlers, and what the kit does instead.
| Scenario | What the kit does |
|---|---|
| Two Stripe deliveries arrive milliseconds apart | One lambda wins the unique-constraint race and runs the handler; the other sees in_progress, returns 503 + Retry-After: 60. By the time Stripe redelivers, the row is processed and the redelivery falls into duplicate suppression. |
| Lambda is killed by Vercel's function-timeout limit mid-handler | The processing row stays stuck. The next retry sees in_progress (503); the retry after the 5-minute window hits stale-reclaim and re-runs the handler. Side effects don't double-fire because OutboundEffect claims released on failure or are already recorded. |
Lambda dies after markEventProcessed already wrote processed | Stripe's retry sees status: processed and returns the duplicate-suppression 200. No re-run. |
Lambda dies after the handler ran but before markEventProcessed | Stale reclaim re-runs the handler. Domain writes are upsert-shaped (subscription upsert, org update) so they're idempotent. Email and analytics see the existing OutboundEffect claim and skip. |
| Email provider is down when the handler runs | sendBestEffortWebhookEmail releases the OutboundEffect claim on failure, so the next retry re-attempts the email. The webhook still returns 200: a side-effect failure doesn't fail the event. |
Vercel's free tier caps function execution at 10 seconds. Handlers in the kit usually complete in well under a second, but pathological cases (very slow Postgres or Stripe calls) could push past the limit. If that happens, stale-reclaim catches the event within 5 to 6 minutes via the next retry. Run production on Pro (60-second timeout) or higher to give the handler comfortable headroom.
Best-Effort Side Effects
Email and analytics don't run inline: they go through sendBestEffortWebhookEmail and sendBestEffortAnalyticsEvent, which claim, dispatch, then release the claim on failure:
async function sendBestEffortWebhookEmail(input) {
const emailClaimed = await claimEffect("email", input.effectKey);
if (!emailClaimed) return;
try {
const success = await sendEmail({
to: input.to,
subject: input.subject,
body: input.body,
});
if (!success) throw new Error("Email provider reported unsuccessful delivery");
} catch (error) {
await releaseEffectClaim("email", input.effectKey);
log.warn("Stripe webhook email delivery failed", {
attributes: { effectKey: input.effectKey, error: String(error) },
});
}
}- An email or analytics outage never fails the webhook. A 5xx from Plunk becomes a
log.warnand a releasedOutboundEffectclaim; the next Stripe retry attempts the email again. - The dedupe survives retries. A successful dispatch keeps its claim, so a later retry that picks up where the first left off fires the email only once.
Events Handled Today
Nine event types, mapped to the business outcomes documented on the Billing page.
| Event | Drives |
|---|---|
checkout.session.completed | Persist stripeCustomerId on the org |
customer.subscription.created | Upsert subscription, sync entitlement, send welcome email, capture subscription_started analytics |
customer.subscription.updated | Upsert subscription, sync entitlement |
customer.subscription.deleted | Mark subscription canceled, sync entitlement, send cancellation email |
customer.subscription.trial_will_end | Send trial-ending reminder email |
invoice.finalized | Structured log only (no side effects) |
invoice.payment_action_required | Send 3DS / SCA confirmation email |
invoice.payment_failed | Send payment-failed email |
invoice.payment_succeeded | Send receipt email |
Adding A Stripe Event
Register the event in the dispatch table
Each handler module owns its own Record<string, (event: Stripe.Event) => Promise<void>> (subscriptionEventHandlers in packages/payments/src/stripe/webhook/handlers/subscription.ts, invoiceEventHandlers in ./handlers/invoice.ts). webhook.ts composes them into one lookup table: adding an event is one entry, no switch edits. Each entry uses the local ensurePayload<T> helper to narrow event.data.object to the concrete Stripe type:
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 for the canonical shape: resolve the org id, upsert the subscription record, then dispatch best-effort email and analytics if appropriate. The resolveOrganizationId(subscription) helper already does the customer-id-vs-metadata reconciliation for you.
Wrap side effects with stable effect keys
Any email goes through sendBestEffortWebhookEmail({ effectKey, to, subject, body }); any analytics dispatch through sendBestEffortAnalyticsEvent({ effectKey, distinctId, event, properties }). The effectKey is a semantic string keyed on the business action, never the Stripe event id (e.g. subscription_paused:sub_xxx, not subscription_paused:evt_xxx). That's what makes the dedupe survive re-deliveries with different event ids.
Subscribe in Stripe
Add the new event type to your webhook endpoint configuration 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 raw bytes, not a parsed JSON object.
Verify the signature
Use the provider's signature header and your provider-specific webhook secret. Return 400 on a missing or invalid signature before any handler runs. Mirror the kit's pattern of throwing a typed Error subclass for signature failures so the route maps it to 400 cleanly.
Add idempotency
Two reasonable options:
- Reuse
claimEffect("<provider>_event", eventId)andreleaseEffectClaimfromOutboundEffect. Cheap, no new table, but couples your provider's dedupe to the row pattern emails and analytics use. - Add a provider-specific table mirroring
StripeWebhookEvent(id, status, processedAt, failureReason). More code, but keeps provider bookkeeping separate from generic outbound-effect bookkeeping.
For low-volume providers, option 1 is fine. For anything that justifies 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, and after(() => loggerProvider?.forceFlush()) so the webhook returns 200 without waiting on the OTLP batch flush. The Stripe handler is the canonical reference.
Async Workflows
SyntaxKit ships zero scheduled jobs and zero background queues. The only code that runs outside a request is after()-deferred work in route handlers, plus the reusable OutboundEffect dedupe pattern. Buyers who need cron, durable execution, or long-running tasks bring their own provider. The kit doesn't pick a winner.
Vercel Cron
HTTP-triggered scheduled jobs, free on Vercel deployments. Easiest path if you're already on Vercel and need simple periodic work.
Inngest
Durable execution and step functions with at-least-once retries. Drop-in for multi-step workflows that need to survive crashes.
Trigger.dev
Alternative durable execution provider with a TypeScript-first SDK and deep Vercel and AWS integration.
Upstash QStash
HTTP-based scheduled and delayed delivery. Works with any HTTP endpoint, no agents to run. Same vendor as the Upstash Redis the kit already uses for abuse limits.
For non-application infra jobs, the kit already uses GitHub Actions cron for low-frequency CI work (.github/workflows/stripe-live.yml runs the live-billing suite at 03:00 UTC daily), the cheapest option you'll find.
after() for fire-and-forget work in a request
Next's after() runs a callback after the response is sent. Vercel keeps the lambda alive long enough to drain it (it counts toward billed duration, not toward client-facing latency). The kit uses it in the Stripe route to flush the OTLP log batch:
return new Response("OK", { status: 200 });
after(async () => {
await loggerProvider?.forceFlush();
});Use it for any fire-and-forget work that mustn't delay the response: log flushes, analytics flushes, secondary notifications, cache warm-ups. Keep the work bounded: after() is not a queue, and a stuck callback eventually times out with the lambda.
Reuse OutboundEffect for a scheduler or queue
If you wire in a real scheduler later (cron tick, durable queue, Inngest run), reuse the OutboundEffect table for at-least-once delivery. claimEffect(kind, key) plus releaseEffectClaim work the same regardless of 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 doesn't re-export them, matching the @internal annotation). Before reusing them from non-webhook code, promote the two functions to the public barrel, one line 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 dedupe pattern verbatim:
import { claimEffect, releaseEffectClaim } from "@syntaxkit/payments/server";
// In your scheduled job:
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 the helpers end up consumed from multiple packages (auth, AI, etc.), move them to @syntaxkit/shared instead so the dedupe pattern stays a package-neutral API.
Where To Go Next
Billing
The consumer of every Stripe event handled here, plus the broader subscription lifecycle this webhook drives.
The system that sends the billing emails the webhook fires, plus templates and the provider-swap story.
Monitoring
The structured logger every webhook branch uses, plus the after-flush pattern in detail.
Security
The signature-verification context plus the secret-rotation note for STRIPE_WEBHOOK_SECRET.
