Skip to content
Docs just relaunched - explore the new sidebar, OG images, and AI-ready content.
Operate And Ship

Webhooks And Async Workflows

External events and background processing.

Last updated on

9 min read

SyntaxKit 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.

What Ships Today

One inbound endpoint, nothing else: no GitHub, no Slack, no Discord. Adding another follows Adding A Different Webhook Provider.

SurfaceEndpointPurpose
Stripe webhookapps/web/app/api/webhooks/stripe/route.tsSignature-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.

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, which claimEvent inspects to pick one of three outcomes:

Claim outcomeWhat it meansRoute response
claimedFirst successful claim, or stale/failed reclaim. Run the handler.200 after the handler completes
processedAnother worker already finished this event.200 (duplicate suppression)
in_progressAnother 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:

  1. Failed reclaim. A row at status: failed is re-claimable. Failed events get another shot every Stripe retry.
  2. Stale reclaim. A row whose createdAt is 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.

ScenarioWhat the kit does
Two Stripe deliveries arrive milliseconds apartOne 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-handlerThe 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 processedStripe's retry sees status: processed and returns 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're idempotent. Email and analytics see the existing OutboundEffect claim and skip.
Email provider is down when the handler runssendBestEffortWebhookEmail 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.warn and a released OutboundEffect claim; 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.

EventDrives
checkout.session.completedPersist stripeCustomerId on the org
customer.subscription.createdUpsert subscription, sync entitlement, send welcome email, capture subscription_started analytics
customer.subscription.updatedUpsert subscription, sync entitlement
customer.subscription.deletedMark subscription canceled, sync entitlement, send cancellation email
customer.subscription.trial_will_endSend trial-ending reminder email
invoice.finalizedStructured log only (no side effects)
invoice.payment_action_requiredSend 3DS / SCA confirmation email
invoice.payment_failedSend payment-failed email
invoice.payment_succeededSend 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:

  1. Reuse claimEffect("<provider>_event", eventId) and releaseEffectClaim from OutboundEffect. Cheap, no new table, but couples your provider's dedupe to the row pattern emails and analytics use.
  2. 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.

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

Was this page helpful?

On this page