Handle a webhook
A production-grade webhook handler that’s correct under retries, signature spoofing attempts, slow downstream systems, and your own deploys.
The shape:
1. Verify signature (reject 400 if bad)
2. Parse JSON
3. Dedupe by event.id (return 200 if seen)
4. Enqueue work + persist the event row (transactional)
5. Return 200
6. Workers process the queue asynchronouslyStep 6 is the long-running part; the handler itself is tiny and fast. If your handler takes more than ~500ms, push more into the worker.
Why each step exists
| Step | What it prevents |
|---|---|
| Verify signature | An attacker hitting your /webhooks/soxara URL directly and faking a “succeeded” payment |
| Dedupe | Soxara delivers at-least-once. Without dedupe you’ll grant credit twice, send the receipt twice, etc. |
| Persist event row | The audit trail. When a customer says “you didn’t credit my account,” you can point at the event ID + timestamp |
| Enqueue + return 200 | Heavy work in the handler = timeouts = retries = inconsistent state. Offload it. |
Node + Express + Postgres + BullMQ
import express from 'express';
import crypto from 'node:crypto';
import { Pool } from 'pg';
import { Queue } from 'bullmq';
const app = express();
const db = new Pool({ connectionString: process.env.DATABASE_URL });
const queue = new Queue('soxara-events', { connection: { url: process.env.REDIS_URL } });
const SECRET = process.env.SOXARA_WEBHOOK_SECRET;
function verifySignature(rawBody, header) {
if (!header) return false;
const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
const t = parts.t, v1 = parts.v1;
if (!t || !v1) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - parseInt(t, 10)) > 300) return false;
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${t}.${rawBody}`)
.digest('hex');
try {
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(v1, 'hex'),
);
} catch { return false; }
}
app.post('/webhooks/soxara',
express.raw({ type: 'application/json' }),
async (req, res) => {
// 1. Verify
if (!verifySignature(req.body, req.headers['soxara-signature'])) {
return res.status(400).send('bad signature');
}
// 2. Parse
let event;
try {
event = JSON.parse(req.body.toString());
} catch {
return res.status(400).send('bad json');
}
// 3 + 4. Insert with ON CONFLICT DO NOTHING gives us idempotent dedupe.
// If insert returns 0 rows it's a duplicate — return 200 without
// re-enqueueing.
const result = await db.query(
`INSERT INTO soxara_events (event_id, type, livemode, payload)
VALUES ($1, $2, $3, $4)
ON CONFLICT (event_id) DO NOTHING`,
[event.id, event.type, event.livemode, event],
);
if (result.rowCount === 1) {
// New event — enqueue work
await queue.add(event.type, event, { jobId: event.id });
}
// 5. Always return 200 once verified + persisted
res.status(200).send('ok');
},
);
app.listen(3000);The soxara_events table:
CREATE TABLE soxara_events (
event_id TEXT PRIMARY KEY,
type TEXT NOT NULL,
livemode BOOLEAN NOT NULL,
payload JSONB NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);The PRIMARY KEY on event_id is what makes dedupe trivial — ON CONFLICT DO NOTHING returns rowCount: 0 for replays.
Python + Flask + Postgres + Redis Queue
import os, json, time, hmac, hashlib
from flask import Flask, request
import psycopg
from rq import Queue
from redis import Redis
app = Flask(__name__)
db = psycopg.connect(os.environ["DATABASE_URL"])
q = Queue("soxara-events", connection=Redis.from_url(os.environ["REDIS_URL"]))
SECRET = os.environ["SOXARA_WEBHOOK_SECRET"]
def verify(raw: bytes, sig: str) -> bool:
if not sig:
return False
parts = dict(p.split("=", 1) for p in sig.split(","))
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1:
return False
if abs(int(time.time()) - int(t)) > 300:
return False
expected = hmac.new(
SECRET.encode(),
f"{t}.".encode() + raw,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, v1)
@app.post("/webhooks/soxara")
def handler():
raw = request.get_data()
if not verify(raw, request.headers.get("Soxara-Signature")):
return "bad signature", 400
try:
event = json.loads(raw)
except json.JSONDecodeError:
return "bad json", 400
# Idempotent insert; rowcount tells us if we'd seen it before
with db.cursor() as cur:
cur.execute(
"""
INSERT INTO soxara_events (event_id, type, livemode, payload)
VALUES (%s, %s, %s, %s)
ON CONFLICT (event_id) DO NOTHING
""",
(event["id"], event["type"], event["livemode"], json.dumps(event)),
)
is_new = cur.rowcount == 1
db.commit()
if is_new:
q.enqueue("worker.process_event", event, job_id=event["id"])
return "ok", 200Workers — what they do
Workers pick up soxara_events and apply business logic. A few patterns:
- For
payment.succeeded: mark the corresponding order as paid, send the receipt email, ping your analytics. - For
transfer.claimed: in a two-sided marketplace, credit the recipient’s account in your own system. - For
charge.dispute.created: notify ops, attach the original order context to the dispute thread.
Workers should:
- Be idempotent within themselves too. Soxara dedupes inbound; you should also dedupe outbound side effects. Marking the same order paid twice should be a no-op.
- Tolerate out-of-order delivery across event types.
transfer.createdis delivered beforetransfer.succeededfor the same transfer, but arefund.succeededmay arrive before the originalpayment.succeededevent is processed (if your queue is backed up). Design accordingly — check current state before mutating. - Retry on failure. If your worker can’t reach an upstream system, retry with backoff. Don’t ACK the job until it really succeeded.
What not to do in the handler
- Don’t send emails inline. (Slow + can fail + makes the handler timeout.)
- Don’t update analytics inline.
- Don’t make synchronous calls to other services.
- Don’t acquire long-held locks.
All of these go in the worker.
Testing your handler
Testing webhooks covers tunnels, replay, and how to trigger specific events from test mode. Build that local feedback loop before deploying — it’s faster than reading logs after a deploy.