connecting
github ↗
Payment integration teardown

The webhook flow that
never loses money

Signature verification, idempotent credits, and a recovery sweep — built from a production marketplace. Fire a real event and watch it move through the system.

Fire a webhook event

Sends a signed charge.success through the full pipeline. Send the same ref twice to see idempotency block it.

Paystack
🔐
Verify sig
Idempotency
Credit
Awaiting simulation...
Events log

Every processed event, deduped by event ID. Polls every 3 seconds.

Time Reference Amount Status
No events yet — send a simulation above
polling in 3s
Recovery sweep

Webhooks get dropped. This cron re-checks pending transactions against Paystack's verify endpoint and credits anything that succeeded but whose event never arrived.

Checked
pending verified
Recovered
credits applied
Failed
marked failed
How the verification works

The exact flow this Worker runs before crediting a single kobo.

01
Raw body first, always
HMAC-SHA512 runs on the exact bytes Paystack sent. Parsing then re-serializing changes whitespace — the signature breaks. Read raw, verify, then parse.
const rawBody = await c.req.text()
const sig    = c.req.header('x-paystack-signature')
const valid  = await verifySignature(rawBody, sig, secret)
if (!valid) return c.json({ error: 'Invalid signature' }, 401)
const payload = JSON.parse(rawBody)
02
HMAC-SHA512 via Web Crypto
Node's crypto module doesn't exist in Workers — crypto.subtle does. Timing-safe XOR comparison prevents timing attacks on digest equality.
const key = await crypto.subtle.importKey(
  'raw', new TextEncoder().encode(secret),
  { name: 'HMAC', hash: 'SHA-512' }, false, ['sign']
)
// compare with timingSafeEqual(), not ===
03
The database is the idempotency gate
A SELECT-then-INSERT check has a race window. The unique constraint on webhook_events.event_id is the real gate — the batch catches violations and turns duplicates into no-ops.
await db.batch([
  db.insert(webhookEvents).values({ eventId, ... }), // unique constraint
  db.insert(transactions).values({ reference, ... }),
  db.update(wallets).set({ balanceKobo: sql`balance + ${n}` })
])
// SQLite error 2067 on duplicate → { credited: false }
04
D1 uses batch, not transaction
Drizzle's D1 driver does not support db.transaction() callbacks — it throws at runtime. db.batch([]) is the correct atomic primitive: all statements commit together or none do.