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.