DEV Community

Cover image for The production webhook checklist every tutorial skips (with a TypeScript example)
Omar A.
Omar A.

Posted on

The production webhook checklist every tutorial skips (with a TypeScript example)

Most webhook tutorials stop at app.post('/webhook', ...), log the payload, and call it a day.

That works for a demo. It does not work for production.

The business logic in a Stripe or GitHub webhook is usually the easy part. The annoying part is everything around it:

  • verifying signatures
  • managing secrets
  • deploying safely
  • choosing a stable public URL
  • seeing what failed and why
  • knowing when you're about to hit usage limits

I kept rebuilding those pieces around small event handlers. So I built Trigora — a hosted runtime and control plane for webhook and cron workflows in TypeScript.

This post is not a product pitch disguised as a tutorial. It's the checklist I wish existed before I shipped my tenth webhook handler — and a complete TypeScript example you can run locally, deploy, and operate in production.


What "production-ready" actually means for a webhook

Before we write code, here's the bar I use for any webhook that touches money, auth, or customer data.

1. Verify the sender

Never trust the request body until you've verified the signature.

For Stripe, that means validating the Stripe-Signature header against your webhook secret. For GitHub, it's the X-Hub-Signature-256 header.

Unsigned test payloads are fine locally. Production traffic must be verified.

2. Make critical writes idempotent

Webhooks retry. Networks flake. You'll get duplicates.

Your critical side effect — marking an order paid, provisioning an account, granting access — must be safe to run more than once. Use the provider's event ID as an idempotency key.

3. Split critical work from nice-to-have work

Not everything in a webhook handler deserves to block the response.

A good pattern:

  • Critical path: verify → validate → one state transition
  • Fan-out: Slack notification, analytics, email — best-effort

If Slack is down, the payment should still be recorded.

4. Return the right HTTP status

  • 2xx when you accepted and processed (or intentionally ignored) the event
  • 4xx for bad signatures, malformed payloads, or missing metadata you can't recover from
  • 5xx only when you want the provider to retry

Stripe will retry on 5xx. Don't return 500 for bad client input.

5. Log structured context, not console.log

When something breaks at 2am, you need:

  • event ID
  • event type
  • the business identifiers you care about (order ID, customer ID, repo name)
  • whether downstream calls failed

Structured logs beat stack traces in webhook debugging.

6. Keep secrets out of your repo

Webhook secrets, API tokens, and third-party keys belong in a secret store — not .env committed to git, and not hardcoded in the handler.

Local dev can read .env.local. Production should inject secrets at runtime.

7. Operate the endpoint like a real service

Production webhooks need more than a URL:

  • a stable path your provider can configure (/hooks/stripe, not /api/v1/tmp-handler-2)
  • deployment history
  • invocation history
  • failure visibility
  • optional custom domain (hooks.yourcompany.com)

This is the layer most teams rebuild on every project.


The example: a production-style Stripe checkout webhook

We'll build a handler for checkout.session.completed that:

  1. verifies the Stripe signature
  2. validates required metadata
  3. marks an order as paid (critical)
  4. fans out to Slack, analytics, and email (non-critical)
  5. logs everything with useful context

Here's the flow shape:

Stripe → verify signature → validate metadata → mark order paid → fan-out → return 200
Enter fullscreen mode Exit fullscreen mode

The handler

import { defineFlow } from '@trigora/sdk';
import { StripeWebhookVerificationError, verifyStripeWebhook } from '@trigora/sdk/stripe';

type CheckoutSession = {
  id: string;
  payment_status?: string | null;
  customer_email?: string | null;
  amount_total?: number | null;
  currency?: string | null;
  metadata?: {
    orderId?: string;
    userId?: string;
  };
};

type StripeEvent = {
  id: string;
  type: string;
  data?: {
    object?: CheckoutSession;
  };
};

async function appRequest(
  path: string,
  init: RequestInit,
  ctx: { env: Record<string, string | undefined> }
) {
  const response = await fetch(`${ctx.env.APP_API_URL}${path}`, {
    ...init,
    headers: {
      Authorization: `Bearer ${ctx.env.APP_API_TOKEN}`,
      'Content-Type': 'application/json',
      ...(init.headers || {}),
    },
  });

  if (!response.ok) {
    const body = await response.text();
    throw new Error(`App API failed (${response.status}): ${body}`);
  }
}

async function markOrderPaid(session: CheckoutSession, eventId: string, ctx: any) {
  await appRequest(
    `/internal/orders/${session.metadata?.orderId}/paid`,
    {
      method: 'POST',
      headers: {
        'Idempotency-Key': eventId,
      },
      body: JSON.stringify({
        stripeSessionId: session.id,
        amountTotal: session.amount_total,
        currency: session.currency,
        userId: session.metadata?.userId,
      }),
    },
    ctx
  );
}

async function sendSlackNotification(session: CheckoutSession, ctx: any) {
  if (!ctx.env.SLACK_WEBHOOK_URL) return;

  await fetch(ctx.env.SLACK_WEBHOOK_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      text: `Payment received for order ${session.metadata?.orderId ?? 'unknown'}`,
    }),
  });
}

export default defineFlow<StripeEvent>({
  id: 'stripe-checkout',
  trigger: {
    type: 'webhook',
    route: '/hooks/stripe',
  },

  async run(event, ctx) {
    let stripeEvent: StripeEvent;

    try {
      stripeEvent = await verifyStripeWebhook<StripeEvent>(event, {
        secret: ctx.env.STRIPE_WEBHOOK_SECRET,
      });
    } catch (error) {
      if (error instanceof StripeWebhookVerificationError) {
        await ctx.log.warn('Rejected webhook signature', {
          reason: error.message,
        });

        return new Response('Invalid signature', { status: 400 });
      }

      throw error;
    }

    if (stripeEvent.type !== 'checkout.session.completed') {
      await ctx.log.info('Ignored Stripe event', {
        type: stripeEvent.type,
        eventId: stripeEvent.id,
      });

      return { ok: true, ignored: true };
    }

    const session = stripeEvent.data?.object;

    if (!session?.id || !session.metadata?.orderId) {
      await ctx.log.warn('Missing required checkout metadata', {
        eventId: stripeEvent.id,
        sessionId: session?.id,
      });

      return new Response('Missing order metadata', { status: 400 });
    }

    if (session.payment_status !== 'paid') {
      await ctx.log.info('Ignoring unpaid checkout session', {
        eventId: stripeEvent.id,
        sessionId: session.id,
        paymentStatus: session.payment_status,
      });

      return { ok: true, ignored: true };
    }

    // Critical path
    await markOrderPaid(session, stripeEvent.id, ctx);

    // Non-critical fan-out
    const fanout = await Promise.allSettled([
      sendSlackNotification(session, ctx),
    ]);

    const failures = fanout.filter((result) => result.status === 'rejected');

    if (failures.length > 0) {
      await ctx.log.warn('Non-critical downstream task failed', {
        eventId: stripeEvent.id,
        failedTasks: failures.length,
      });
    }

    await ctx.log.info('Checkout processed', {
      eventId: stripeEvent.id,
      sessionId: session.id,
      orderId: session.metadata.orderId,
      amountTotal: session.amount_total,
      currency: session.currency,
    });

    return {
      ok: true,
      orderId: session.metadata.orderId,
      sessionId: session.id,
    };
  },
});
Enter fullscreen mode Exit fullscreen mode

A few things worth calling out:

  • Signature verification happens first. No business logic runs on untrusted input.
  • The idempotency key is Stripe's event ID. Retries won't double-charge your internal state.
  • Slack failure doesn't fail the webhook. The payment write already succeeded.
  • Logs carry the fields you'll search for later. Not just "error happened."

Run it locally with real Stripe traffic

Install and initialize:

npm install trigora @trigora/sdk
npx trigora init
Enter fullscreen mode Exit fullscreen mode

Add secrets to .env.local:

STRIPE_WEBHOOK_SECRET=whsec_...
APP_API_URL=http://localhost:3000
APP_API_TOKEN=dev_app_token
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
Enter fullscreen mode Exit fullscreen mode

Start the local webhook server:

npx trigora dev stripe-checkout
Enter fullscreen mode Exit fullscreen mode

Forward signed Stripe events:

stripe listen --forward-to http://localhost:5252
stripe trigger checkout.session.completed
Enter fullscreen mode Exit fullscreen mode

trigora dev starts a local HTTP server on port 5252. It only runs your handler when a real request arrives — same model as production, without deploying anything yet.


Deploy it without rebuilding the ops layer

Once the handler works locally, the production path is:

  1. Create a workspace at app.trigora.dev
  2. Generate a deploy token
  3. Deploy the flow
  4. Set hosted secrets
  5. Point Stripe at your hosted endpoint
# authenticate
export TRIGORA_DEPLOY_TOKEN=your-deploy-token

# deploy
npx trigora deploy stripe-checkout

# set production secrets
npx trigora secrets set STRIPE_WEBHOOK_SECRET --flow stripe-checkout
npx trigora secrets set APP_API_URL --flow stripe-checkout
npx trigora secrets set APP_API_TOKEN --flow stripe-checkout
npx trigora secrets set SLACK_WEBHOOK_URL --flow stripe-checkout
Enter fullscreen mode Exit fullscreen mode

Your hosted endpoint:

https://<workspace>.trigora.dev/hooks/stripe
Enter fullscreen mode Exit fullscreen mode

On a Pro workspace, you can attach a custom domain so Stripe calls something like:

https://hooks.yourcompany.com/hooks/stripe
Enter fullscreen mode Exit fullscreen mode

That last step matters more than people expect. Vendor-branded URLs are fine for experiments. Production integrations feel better on your own domain.


What you get after deploy (the part tutorials never show)

This is where most DIY webhook setups get expensive in engineering time.

With the handler deployed, you also get:

  • Invocation history — every run, success or failure, with timestamps
  • Structured logs — the same ctx.log.info/warn output, browsable per invocation
  • Secrets management — no redeploy to rotate a key
  • Usage tracking — see execution volume against your plan limits
  • Custom routes — stable paths like /hooks/stripe separate from internal flow IDs

You write the business logic. The platform handles the operational shell around it.

The dashboard shows invocations, runtime output, and failure context — the stuff you normally wire up with CloudWatch, a random /admin/invocations page, or "check the logs" Slack messages.


DIY vs hosted: an honest comparison

You can absolutely build this on raw Cloudflare Workers, AWS Lambda, or a Next.js API route. I did that for years.

Concern DIY Hosted workflow runtime
Compute
Signature verification Your code Your code
Secrets Wrangler/SSM/manual Managed per flow
Deploy pipeline You build it trigora deploy
Invocation logs You build it Built in
Public URL + custom domain You configure it Workspace endpoint + optional custom domain
Usage limits / billing You build it Plan-based guardrails

The tradeoff is control vs speed.

If you want total infrastructure ownership, use the underlying platform directly.

If you want to ship webhook and cron handlers without rebuilding the control plane every time, a hosted runtime is the faster path.


What this is — and isn't — good for today

I'll be direct because I'd rather earn trust than overclaim.

Trigora is a good fit today for:

  • Stripe, GitHub, Slack, and custom webhook handlers
  • scheduled cron jobs with the same deploy path
  • small-to-medium backend glue between SaaS tools and your APIs
  • teams tired of rebuilding deploys, secrets, logs, and dashboards per integration

It's not the right tool yet if you need:

  • managed retries with durable execution guarantees
  • queues and long-running orchestration
  • Temporal/Inngest-style step replay

Queues, retries, and durable orchestration are on the roadmap. I shipped the boring operational foundation first — deploys, secrets, logs, usage, billing, custom domains — because that's the part everyone rebuilds and nobody talks about in tutorials.


The checklist, on one page

Save this for your next webhook:

□ Verify signatures before parsing business logic
□ Use provider event ID as idempotency key for critical writes
□ Separate critical path from fan-out work
□ Return 4xx for client errors, 5xx only when retry is desired
□ Log structured context (event ID, type, business IDs)
□ Keep secrets out of source control
□ Use a stable public route (/hooks/stripe)
□ Make failures visible without SSH-ing into logs
Enter fullscreen mode Exit fullscreen mode

The code is the easy part. Operations is what makes it production-ready.


Try it

If you're building webhook or cron handlers in TypeScript and don't want to rebuild the platform layer again:

Free tier includes 25k executions/month. Pro is $15/month with custom domains and 1M executions.

If you've shipped production webhooks before — what part of the ops layer annoyed you most? Deploys, secrets, logs, retries, something else? Genuinely curious what's still missing.

Top comments (1)

Collapse
 
hypervs profile image
Omar A.

Hey everyone — Omar here, author of the post.

If you want to try this without hunting through the article, here are the direct links:

Quick start:

npm install trigora @trigora/sdk
npx trigora init
npx trigora dev <flow-id>
Enter fullscreen mode Exit fullscreen mode

Happy to answer questions about webhooks, cron flows, deploys, secrets, or how Trigora compares to rolling your own on Workers/Lambda.