GuidesHandle a webhook

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 asynchronously

Step 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

StepWhat it prevents
Verify signatureAn attacker hitting your /webhooks/soxara URL directly and faking a “succeeded” payment
DedupeSoxara delivers at-least-once. Without dedupe you’ll grant credit twice, send the receipt twice, etc.
Persist event rowThe audit trail. When a customer says “you didn’t credit my account,” you can point at the event ID + timestamp
Enqueue + return 200Heavy 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", 200

Workers — 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:

  1. 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.
  2. Tolerate out-of-order delivery across event types. transfer.created is delivered before transfer.succeeded for the same transfer, but a refund.succeeded may arrive before the original payment.succeeded event is processed (if your queue is backed up). Design accordingly — check current state before mutating.
  3. 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.