Webhooks And Async Workflows
External events and background processing.
SyntaxKit ships inbound webhooks for its billing providers: three routes, one each for Stripe, Polar, and Creem. Only the active provider (BILLING_PROVIDER) processes events. Each route is signature-verified, deduped at two levels (event id and semantic side-effect key) through one shared ledger, 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.
There is still no queue vendor (no Inngest, Trigger.dev, or QStash). The one shipped worker is the usage-report cron at /api/cron/usage-reports when a fork declares a Stripe meter; see Usage billing. 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.
The Polar webhook
Standard Webhooks verification, webhook-id dedup, and the event set.
The Creem webhook
HMAC signature verification, event-id dedup, and the event set.
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
Three billing webhook routes, nothing else: no GitHub webhook, 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 |
| Polar webhook | apps/web/app/api/webhooks/polar/route.ts | Standard-Webhooks-verified Polar handler; same ledger, same idempotency, same logs |
| Creem webhook | apps/web/app/api/webhooks/creem/route.ts | Same processBillingWebhook path. The deployment's active provider is the only one that processes events. |
These routes are thin: they read the raw body and call processBillingWebhook(provider, { body, headers }) from @syntaxkit/payments/server. That facade rejects a provider that is not the deployment's active one with BillingProviderInactiveError (the route answers 503 so the sender keeps retrying while an operator is mid-switch, instead of losing the event with a 2xx), and otherwise dispatches to the provider's adapter. Signature and header failures throw BillingWebhookSignatureError / BillingWebhookRequestError, which the routes map to 400; anything else is a 500 with a structured log and the provider retries.
The Stripe Webhook
The route at apps/web/app/api/webhooks/stripe/route.ts delegates to processStripeWebhookEvent in packages/payments/src/stripe/webhook.ts through the processBillingWebhook("stripe", …) facade.
Endpoint And Signature Verification
The adapter reads the raw body, verifies the Stripe signature against STRIPE_WEBHOOK_SECRET, and rejects a missing signature before any ledger write:
const signature = request.headers.get("stripe-signature");
if (!signature) {
throw new BillingWebhookRequestError("Missing stripe-signature header");
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(request.body, signature, webhookSecret);
} catch (error) {
throw new BillingWebhookSignatureError(
error instanceof Error ? error.message : "Invalid Stripe signature"
);
}The route maps BillingWebhookRequestError to a 400 with a log.warn (a missing header is noise, not an incident) and BillingWebhookSignatureError to a 400 with a log.error; 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 (WEBHOOK_ATTEMPT_LEASE_MSinpackages/payments/src/ledger/webhook-events.ts) is assumed dead and re-claimed.
export const WEBHOOK_ATTEMPT_LEASE_MS = 5 * 60 * 1000;
const staleBefore = new Date(reclaimedAt.getTime() - WEBHOOK_ATTEMPT_LEASE_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 failed send becomes a
log.warnand a releasedOutboundEffectclaim; the next provider 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 |
The Polar Webhook
The route at apps/web/app/api/webhooks/polar/route.ts (/api/webhooks/polar) delegates to processPolarWebhookEvent in packages/payments/src/polar/webhook.ts through processBillingWebhook("polar", …). It writes to the same StripeWebhookEvent and OutboundEffect tables as the Stripe route, tagged provider = polar, so two-layer idempotency, stale processing recovery, and best-effort side effects apply unchanged.
Verification
Polar signs with Standard Webhooks: the webhook-id, webhook-timestamp, and webhook-signature headers, with a five-minute timestamp tolerance. verifyPolarWebhook uses the standardwebhooks library and tries both key encodings a Polar secret can have (Polar changed its encoding on 2026-09-08; older secrets sign with the UTF-8 bytes of the whole whsec_… string, newer ones with the base64-decoded remainder), so an installation keeps verifying across the rotation without knowing which generation its secret is. Missing headers throw BillingWebhookRequestError (400, Polar does not retry); a bad signature or a timestamp outside the window throws BillingWebhookSignatureError (400). Both happen before any ledger write, so an attacker probing the endpoint cannot fill the event table. The verified body is then parsed with the SDK's typed schemas for handled event types; a signed payload this SDK version cannot read fails the event (5xx, Polar retries) so operators see the deployment problem instead of a silent ack.
Dedup and ordering
The dedup key is the webhook-id header, stored in StripeWebhookEvent.eventId. It is stable across Polar's redeliveries of the same event and distinct for distinct events, unlike the resource id inside the payload. The ordering signal is the payload timestamp (the event time), written to stripeLastEventAt as the provider event time; Polar documents send order but not delivery order, and the recency guard in the canonical subscription write makes a late delivery harmless. Polar retries a failed delivery up to 10 times with exponential backoff and disables an endpoint after 10 consecutive failures, so after an outage check Settings -> Webhooks in the Polar dashboard and re-enable the endpoint, then run pnpm billing:reconcile to catch up.
Correlation
Before a handler runs, correlatePolarEvent checks that every identifier the signed payload asserts about the owning organization agrees: the stored Organization.polarCustomerId mapping, the persisted subscription row (which must be a polar row), metadata.organizationId, and the customer's external_id. Any disagreement marks the association invalid; the event is recorded as failed for support visibility and no handler can fall back to one of the conflicting identifiers.
Events handled today
| Event | Drives |
|---|---|
subscription.created / updated / active / canceled / uncanceled / past_due / revoked | Upsert the subscription through upsertProviderSubscriptionRecord, sync entitlement. Welcome email + subscription_started analytics on the first active/trialing write; payment-failed email on past_due; canceled email when the write is terminal. revoked forces status canceled. |
checkout.updated (status succeeded) | Persist polarCustomerId on the org when missing (compare-and-set, never overwrites) |
order.paid | Structured log only; Polar issues the invoice and receipt |
Everything else (subscription.cycled, paused, resumed, benefit and refund events) | Claimed and acknowledged with no handler, so Polar stops redelivering; the state they imply arrives via subscription.updated |
The lifecycle-level view (which phase each event lands in, which emails the kit sends versus Polar) is on Billing: Lifecycle semantics.
Recovery without manual replay
Polar exposes no authenticated "retrieve event by id" API, so the support console's manual webhook replay is unavailable for polar events (getWebhookRetryEligibility answers provider_unsupported). The recovery path is Compare and Resync on the organization in the support console, which re-fetch the live subscription and write it through the same canonical upsert, or pnpm billing:reconcile for a sweep. See Support console.
The Creem Webhook
The route at apps/web/app/api/webhooks/creem/route.ts (/api/webhooks/creem) delegates to processCreemWebhookEvent in packages/payments/src/creem/webhook.ts through processBillingWebhook("creem", …). Like Polar, it writes to the shared StripeWebhookEvent and OutboundEffect tables, tagged provider = creem, so two-layer idempotency, stale processing recovery, and best-effort side effects apply unchanged.
Verification
Creem signs the raw body with HMAC-SHA256 and sends the result in the creem-signature header. verifyCreemWebhook checks it against CREEM_WEBHOOK_SECRET with the Creem SDK's timing-safe helper. A missing header or a body that is not JSON throws BillingWebhookRequestError (400); a bad signature throws BillingWebhookSignatureError (400). Both happen before any ledger write. A verified payload that the kit cannot project is recorded as a failed event, reported as billing drift (creem_payload_unparseable), and acknowledged, so it shows up in the support console instead of being retried forever.
Dedup, ordering, and correlation
The dedup key is the payload event id (evt_…), stored in StripeWebhookEvent.eventId. The ordering signal is the payload created_at, written as the provider event time, so the recency guard in the canonical subscription write makes a late delivery harmless. Before a handler runs, correlateCreemEvent checks that the stored Organization.creemCustomerId, the persisted subscription row (which must be a creem row), and the asserted metadata.organizationId agree; any disagreement records the event as failed with no handler run.
Events handled today
| Event | Drives |
|---|---|
subscription.paid / active / trialing / update / scheduled_cancel / past_due / unpaid / expired / paused / canceled | Upsert the subscription through upsertProviderSubscriptionRecord, sync entitlement. Welcome email on the first active or trialing write; payment-failed email when past_due or unpaid lands as past_due; canceled email when the write is terminal. |
checkout.completed | Persist creemCustomerId on the org when missing (compare-and-set, never overwrites). Never grants access on its own; subscription events do. |
refund.created / dispute.created | Revoke paid access for the referenced subscription |
| Everything else | Claimed and acknowledged with no handler, so Creem stops redelivering |
Recovery without manual replay
The Creem adapter does not support manual webhook replay (getWebhookRetryEligibility answers provider_unsupported for creem events). Recover with Compare and Resync on the organization in the support console, which re-fetch the live subscription and write it through the same canonical upsert, or pnpm billing:reconcile for a sweep. See Support console.
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 a weekly catalog price-drift check), 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/ledger/webhook-events.ts (shared by the Stripe, Polar, and Creem adapters) and are intra-package only today (the @syntaxkit/payments/server barrel doesn't re-export them). 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 "./ledger/webhook-events";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.