๐Ÿช

Webhooks

๐Ÿ‘จโ€๐Ÿณ Chef

Real-time events

Webhooks allow services to notify you when something happens.


How they work

Event in external service
         โ†“
POST to your endpoint
         โ†“
Your code processes

Example: Stripe webhook

// app/api/webhooks/stripe/route.ts
export async function POST(request: Request) {
  const body = await request.text()
  const signature = request.headers.get('stripe-signature')!

  const event = stripe.webhooks.constructEvent(
    body,
    signature,
    process.env.STRIPE_WEBHOOK_SECRET!
  )

  switch (event.type) {
    case 'payment_intent.succeeded':
      // Mark order as paid
      break
    case 'customer.subscription.deleted':
      // Cancel subscription
      break
  }

  return new Response('OK')
}

Verify signature

Always verify the webhook comes from the real service:

import crypto from 'crypto'

function verifySignature(payload: string, signature: string, secret: string) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex')

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  )
}

Services with webhooks

  • Stripe (payments)
  • GitHub (commits, PRs)
  • Slack (messages)
  • Twilio (SMS, calls)

๐Ÿ” Payment Webhooks: Critical Security

Payment webhooks are the most vulnerable point of your application. An attacker can simulate a successful payment.

Idempotency: Don't process twice

Stripe can resend the same webhook multiple times. Without idempotency, you charge/credit double.

export async function POST(request: Request) {
  const event = await verifyAndParseWebhook(request)

  // โš ๏ธ CRITICAL: Check if we already processed this event
  const processed = await db.query(
    'SELECT 1 FROM processed_webhooks WHERE event_id = $1',
    [event.id]
  )

  if (processed.rows.length > 0) {
    // Already processed, respond OK without doing anything
    return new Response('Already processed', { status: 200 })
  }

  // Process the event
  await processPaymentEvent(event)

  // Mark as processed AFTER successful processing
  await db.query(
    'INSERT INTO processed_webhooks (event_id, processed_at) VALUES ($1, NOW())',
    [event.id]
  )

  return new Response('OK')
}

Respond fast, process later

Stripe waits for response in 20 seconds. If your processing is slow, make it async:

export async function POST(request: Request) {
  const event = await verifyAndParseWebhook(request)

  // Respond immediately
  // Process in background (queue, worker, etc.)
  await queue.add('process-payment', { eventId: event.id })

  return new Response('OK') // < 1 second
}

Common Fintech Errors

ErrorConsequenceSolution
Not validating signatureFraud: fake paymentsconstructEvent() always
Not handling duplicatesDouble charge/creditIdempotency table
Timeout in processingStripe retries = duplicatesRespond fast, process async
Not logging eventsImpossible to debugFull log with timestamp

Practice

โ†’ Webhook Receiver