Skip to content
OTFotf
All posts

Why Payments and License Delivery Should Never Be Hand-Rolled

D
DaveAuthor
7 min read
Why Payments and License Delivery Should Never Be Hand-Rolled

Cold open

Stripe webhooks are the most consequential 40 lines in your repo. Not because they're hard to write — they're not. Because when they're wrong, the failure is silent. There is no error log that lights up red. There is no 500 in production. There is a customer with a license they didn't pay for, a refund that didn't revoke, or a charge you fulfilled twice. The dashboard says revenue is up. The bank statement says it isn't.

This post is about the four places that quietly lose money in a payments integration, what the correct wiring looks like, and what a kit means by "Stripe wired" so you don't have to freelance the receipt.

1. The receipt — what a real Stripe event looks like

When a customer completes checkout, Stripe POSTs JSON to your webhook endpoint:

{
  "id": "evt_1NqR2x2eZvKYlo2C...",
  "type": "checkout.session.completed",
  "data": {
    "object": {
      "id": "cs_test_...",
      "customer": "cus_...",
      "amount_total": 9900,
      "metadata": { "plan": "pro", "user_id": "u_abc123" }
    }
  }
}

Two things matter immediately: the id is your idempotency key, and the metadata.user_id is what you'll use to attach a license. If you trusted nothing else from the event body — only the id and your own lookup against the session id — you would still ship a working integration. The rest is enrichment.

The hard part isn't reading this. The hard part is making sure the request actually came from Stripe.

11 production screens. Login, database, payments — all wired.

The SaaS Dashboard Kit ships everything already connected. Nothing to set up. Live demo at saas.otf-kit.dev.

See the live demo

2. Signature verification is not optional

Stripe signs every webhook with HMAC-SHA256 over {timestamp}.{body} using your endpoint secret. The signature lands in the stripe-signature header in the form t=1492774577,v1=.... You reconstruct the signed payload on your server, recompute the HMAC with your secret, and compare with crypto.timingSafeEqual.

import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function POST(req: Request) {
  const body = await req.text()                          // raw, not JSON
  const sig = req.headers.get('stripe-signature')

  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(
      body,
      sig ?? '',
      process.env.STRIPE_WEBHOOK_SECRET!,
    )
  } catch (err) {
    return new Response(`Webhook Error: ${(err as Error).message}`, { status: 400 })
  }
  // ...
}

Three things people get wrong here, in order of how much money they lose:

They parse the body as JSON first. constructEvent needs the raw bytes; the signature was computed over the exact string Stripe sent. Parse first, verify second, and the HMAC mismatches. Every valid Stripe event looks forged to your code.

They use === to compare the signature. That's a timing oracle. An attacker who can measure response latency can recover the signature byte by byte. Use crypto.timingSafeEqual and pad both buffers to the same length.

They skip the timestamp check. Stripe's signed payload includes a timestamp, and the SDK rejects events older than 5 minutes by default. Without it, a leaked signature from a year ago is still valid. Keep the default.

A Stripe webhook event from checkout to license grant with the four failure points marked

3. Idempotency — the part where Stripe's retries become your revenue leak

Stripe will retry any non-2xx response. Network blip, server GC pause, deploy rolling restart — your endpoint times out for 800ms, Stripe gives up, retries in 30 seconds. If you didn't dedupe on event.id, you fulfill twice. The customer gets two license keys. You got one charge.

The fix is a one-row table:

const seen = await db.eventLog.findUnique({ where: { id: event.id } })
if (seen) return new Response('ok', { status: 200 })

Insert the row inside the same transaction that grants the license. If the insert fails on a unique constraint, the event is a duplicate; return 200 and stop. Stripe sees 200, stops retrying, the customer gets one license, your bank account is intact.

The trap is doing this check before fulfillment but outside the transaction. Race condition: two retries hit your endpoint at the same millisecond, both pass the dedupe check, both fulfill. The transaction has to be the dedupe.

4. The 200 trap — return 200 last, not first

return new Response('ok', { status: 200 }) is not a polite acknowledgment. It is a contract: I have durably recorded this event, and you do not need to retry. If you return 200 before you've written the license to the database, you have lied to Stripe. The retry won't come. The license is lost.

Order matters:

const result = await db.$transaction(async (tx) => {
  // 1. dedupe
  const existing = await tx.eventLog.findUnique({ where: { id: event.id } })
  if (existing) return { duplicate: true }

  // 2. fulfill — grant license, send email, update entitlement
  await grantLicense(tx, event.data.object.metadata.user_id, event.data.object.metadata.plan)

  // 3. record
  await tx.eventLog.create({ data: { id: event.id, type: event.type, payload: event } })

  return { ok: true }
})

return new Response('ok', { status: 200 })

If the transaction throws, do not return 200. Return 500, and let Stripe retry. The 200 is the last line, not the first.

5. Refunds are a separate event

charge.refunded is a different event type with a different data.object. The amount, the reason, the customer — all there. If your handler only listens for checkout.session.completed, the refund arrives, you process nothing, the customer's card is credited, and the license sits in the database forever.

case 'charge.refunded': {
  const charge = event.data.object
  const userId = charge.metadata?.user_id
  if (!userId) break
  await revokeLicense(tx, userId, { reason: 'refund', chargeId: charge.id })
  break
}

charge.refunded fires for full and partial refunds. Partial-refund handling is a product decision — downgrade? prorate? do nothing? — and is the most common place I've seen teams ship a revocation flow that "looks right" in the dashboard but quietly keeps the original entitlement intact. A test that issues a $9.90 refund against a $99 charge and asserts that the license downgrades — not disappears — is the test that prevents this from shipping.

6. What "Stripe wired" means in a kit

A full-stack kit that says it ships Stripe wired is making four claims, each of which a coding agent will not invent for you from a prompt:

WiredWhat it includes
Verified endpointPOST /api/stripe/webhook with constructEvent + timing-safe compare
Idempotent handlereventLog table, unique index on event.id, dedupe inside the fulfill transaction
FulfillmentLicense grant, transactional email, entitlement table write — one DB transaction
Refund pathcharge.refunded handler with revocation + downgrade branches

The reason the last column matters: an AI coding agent told "wire up Stripe checkout" will write the happy path. It will not write a transaction that dedupes on event.id, because the dedupe is not in the user's prompt and not in the public docs. The agent will return 200 first, because that's what the example snippets do. The agent will not subscribe to charge.refunded, because nobody asked for refunds.

hand-rolled Stripe webhook vs kit-wired Stripe webhook — hand-rolled has happy path only,

The kit is also the part your coding agent extends instead of regenerates. The shipped CLAUDE.md and .cursorrules carry the test fixtures, the env var list, and the convention: do not modify /api/stripe/webhook — extend the handler map in webhook-handlers.ts. When your agent gets the prompt "add a new event type for invoice payment failure," it appends to the map. The verification, the dedupe, the 200 ordering, the refund branch — none of them are re-derived from scratch. That's the layer that doesn't change when Stripe's API does.

What this gets you

The combined effect is small in code, large in sleep. A $20/month product that loses 1 in 200 charges to double-fulfillment and 1 in 100 refunds to non-revocation loses more in a year than the kit costs. The hard part is not noticing: chargebacks show up 60–120 days later, attached to a Stripe event you can't easily correlate to a license you granted a year ago.

The other thing you get is speed-to-respond. A charge.dispute.created event is your 7-day window to submit evidence. If the webhook handler doesn't know how to flag the account, freeze the entitlement, and queue the evidence bundle, you lose the dispute by default. A kit that ships the dispute handler alongside the charge handler is the difference between a Tuesday afternoon and a Friday night.

Closing

Stripe webhooks are short to write and brutal to get wrong, because every failure mode is silent. Verification, idempotency, transactional 200, refund branch — the wiring is the same in every project that ever takes money. That's the part worth not hand-rolling. Ship the kit, point your domain at it, and let the parts that don't change stay shipped.

backendkitsstripe
OTF SaaS Dashboard Kit

Ship the product, not the setup.

  • 11 production screens — auth, billing, team, analytics, settings
  • Real database, payments, and login — all wired on day 1
  • AI configs pre-tuned so your agent extends instead of regenerates