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
| Error | Consequence | Solution |
|---|---|---|
| Not validating signature | Fraud: fake payments | constructEvent() always |
| Not handling duplicates | Double charge/credit | Idempotency table |
| Timeout in processing | Stripe retries = duplicates | Respond fast, process async |
| Not logging events | Impossible to debug | Full log with timestamp |
Practice
โ Webhook Receiver