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 the Soxara-Delivery header          (return 200 if seen)
4. Enqueue work + persist the delivery 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. Dedupe on Soxara-Delivery — it's stable across retries of the
    //        SAME event (a payment.completed and a payroll_run.completed
    //        for different resources get different ids, but a retried
    //        delivery of one you already saw keeps the same id).
    //        Insert with ON CONFLICT DO NOTHING gives us idempotent dedupe;
    //        0 rows back means it's a duplicate — return 200 without
    //        re-enqueueing.
    const deliveryId = req.headers['soxara-delivery'];
    const result = await db.query(
      `INSERT INTO soxara_deliveries (delivery_id, type, payload)
       VALUES ($1, $2, $3)
       ON CONFLICT (delivery_id) DO NOTHING`,
      [deliveryId, event.type, event],
    );
 
    if (result.rowCount === 1) {
      // New delivery — enqueue work
      await queue.add(event.type, event, { jobId: deliveryId });
    }
 
    // 5. Always return 200 once verified + persisted
    res.status(200).send('ok');
  },
);
 
app.listen(3000);

The soxara_deliveries table:

CREATE TABLE soxara_deliveries (
    delivery_id  TEXT PRIMARY KEY,
    type         TEXT NOT NULL,
    payload      JSONB NOT NULL,
    received_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

The PRIMARY KEY on delivery_id is what makes dedupe trivial — ON CONFLICT DO NOTHING returns rowCount: 0 for a retried delivery.

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
 
    delivery_id = request.headers.get("Soxara-Delivery")
 
    # Idempotent insert; rowcount tells us if we'd seen it before
    with db.cursor() as cur:
        cur.execute(
            """
            INSERT INTO soxara_deliveries (delivery_id, type, payload)
            VALUES (%s, %s, %s)
            ON CONFLICT (delivery_id) DO NOTHING
            """,
            (delivery_id, event["type"], json.dumps(event)),
        )
        is_new = cur.rowcount == 1
    db.commit()
 
    if is_new:
        q.enqueue("worker.process_event", event, job_id=delivery_id)
 
    return "ok", 200

Workers — what they do

Workers pick up soxara_events and apply business logic. A few patterns:

  • For payment.completed: mark the corresponding order as paid, send the receipt email, ping your analytics.
  • For payment.failed: start dunning on a recurring charge, or just log it and let the customer retry the link.
  • For payroll_run.partial / .failed: alert whoever runs payroll — some or all payees weren’t paid and need attention.
  • For inventory.low_stock: kick off your own reorder workflow, or just notify the person who restocks.

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. A payment.failed for an earlier attempt might land after a payment.completed for a retried one, if your queue is backed up. Check current state before mutating — a WHERE status = 'pending' guard, not a blind write.
  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, redelivering a failed delivery, 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.