Building a webhook receiver

A complete, runnable plan.launched receiver — raw-body signature verification, transactional deduplication, persist-before-acknowledge, asynchronous processing — plus the test checklist to run before going live.

The signature verification helpers show the cryptographic core; this page puts everything together into one continuous, runnable receiver you can start from. It demonstrates the full lifecycle in order:

  1. Read the raw request bytes (never the re-serialized JSON).
  2. Verify the timestamp and HMAC signature before doing anything else.
  3. Parse the JSON only after verification succeeds.
  4. Cross-check the headers against the signed body (defense in depth).
  5. Deduplicate and persist atomically on eventId, before acknowledging.
  6. Return a 2xx immediately, then process asynchronously.

A complete receiver (Node.js / Express)

mkdir callplus-receiver && cd callplus-receiver
npm install express better-sqlite3
WEBHOOK_SECRET=test-secret node receiver.js
// receiver.js — a complete Call+ plan.launched webhook receiver.
const crypto = require("node:crypto");
const express = require("express");
const Database = require("better-sqlite3");

const SECRET = process.env.WEBHOOK_SECRET; // exchanged at onboarding
if (!SECRET) {
  console.error("WEBHOOK_SECRET is not set — refusing to start");
  process.exit(1);
}
const TOLERANCE_SECONDS = 300;             // replay window: 5 minutes

// One table is enough: the eventId primary key IS the deduplication.
const db = new Database("webhook-events.db");
db.exec(`CREATE TABLE IF NOT EXISTS webhook_event (
  event_id     TEXT PRIMARY KEY,
  event_type   TEXT NOT NULL,
  payload      TEXT NOT NULL,
  received_at  TEXT NOT NULL DEFAULT (datetime('now')),
  processed_at TEXT
)`);
const insertEvent = db.prepare(
  "INSERT OR IGNORE INTO webhook_event (event_id, event_type, payload) VALUES (?, ?, ?)"
);
const markProcessed = db.prepare(
  "UPDATE webhook_event SET processed_at = datetime('now') WHERE event_id = ?"
);

const app = express();

// 1. Raw bytes: the signature covers the body exactly as sent.
//    express.json() would re-parse it and make verification impossible.
app.post("/webhooks/callplus", express.raw({ type: "application/json" }), (req, res) => {
  // 2. Verify timestamp + signature before trusting anything in the request.
  if (!verifyWebhook(req.get("X-Callplus-Signature"), req.body, SECRET)) {
    return res.status(401).end();
  }

  // 3. Parse only after the signature checks out.
  let event;
  try {
    event = JSON.parse(req.body);
  } catch {
    return res.status(400).end();
  }

  // 4. Defense in depth (your own hardening, not a documented Call+ error
  //    contract): the headers should describe the body you just verified.
  if (req.get("X-Callplus-Event-Id") !== event.eventId ||
      req.get("X-Callplus-Event-Type") !== event.eventType) {
    return res.status(400).end();
  }

  // 5. Deduplicate and persist in ONE atomic write, before acknowledging.
  //    A retried delivery has the same eventId, so INSERT OR IGNORE makes
  //    redelivery a no-op — you acknowledge it again, but process it once.
  const inserted =
    insertEvent.run(event.eventId, event.eventType, req.body.toString()).changes === 1;

  // 6. Acknowledge fast — any 2xx counts, and the event is already safe on disk.
  res.status(204).end();

  // 7. Do the real work off the request path, only for first-time events.
  if (inserted) setImmediate(() => processEvent(event));
});

function processEvent(event) {
  // Additive changes arrive within the same apiVersion — unknown fields are
  // normal, ignore them. An apiVersion you don't support is a different
  // matter: keep the stored event unprocessed and alert a human.
  if (event.apiVersion !== 1) {
    console.error(`unsupported apiVersion ${event.apiVersion} for ${event.eventId} — parked`);
    return;
  }
  // Dispatch on the event type — you only receive types you subscribed to,
  // but route explicitly so a new type can never fall into the wrong handler.
  if (event.eventType !== "plan.launched") {
    console.error(`unhandled eventType ${event.eventType} for ${event.eventId} — parked`);
    return;
  }
  // Your business logic goes here — e.g. prepare the fiche complémentaire for
  // the payroll period containing event.data.plan.taxationDate.
  console.log(`processing ${event.eventType} ${event.eventId}`);
  markProcessed.run(event.eventId);
}

// Crash recovery: an event persisted but not yet processed (the process died
// between step 5 and completing processEvent) is picked up again at startup.
for (const row of db
  .prepare("SELECT payload FROM webhook_event WHERE processed_at IS NULL")
  .all()) {
  setImmediate(() => processEvent(JSON.parse(row.payload)));
}

// Signature verification — identical to the helper on the
// "Verifying webhook signatures" page.
function computeSignature(secret, timestamp, body) {
  return crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.`)
    .update(body)
    .digest("hex");
}

function verifyWebhook(signatureHeader, rawBody, secret, toleranceSeconds = TOLERANCE_SECONDS) {
  if (!signatureHeader) return false;
  const parts = Object.fromEntries(
    signatureHeader.split(",").map((item) => {
      const i = item.indexOf("=");
      return i === -1 ? [item, ""] : [item.slice(0, i), item.slice(i + 1)];
    })
  );
  const timestamp = Number(parts.t);
  const received = parts.v1;
  if (!Number.isInteger(timestamp) || !/^[0-9a-f]{64}$/.test(received ?? "")) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false; // replay window
  const expected = computeSignature(secret, timestamp, rawBody);
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
}

app.listen(8080, () => console.log("listening on :8080"));

Try it against the published test vector — with WEBHOOK_SECRET=test-secret and a current timestamp, a correctly signed request is accepted and deduplicated on the second attempt:

BODY='{"eventId":"3f8e9a3c-6a68-4c69-9a1d-1f9f8b1f2a34","eventType":"plan.launched","apiVersion":1,"occurredAt":"2026-08-10T12:00:00+02:00","data":{}}'
T=$(date +%s)
SIG=$(printf '%s' "$T.$BODY" | openssl dgst -sha256 -hmac 'test-secret' | awk '{print $NF}')
curl -i -X POST http://localhost:8080/webhooks/callplus \
  -H "Content-Type: application/json" \
  -H "X-Callplus-Event-Id: 3f8e9a3c-6a68-4c69-9a1d-1f9f8b1f2a34" \
  -H "X-Callplus-Event-Type: plan.launched" \
  -H "X-Callplus-Signature: t=$T,v1=$SIG" \
  --data-binary "$BODY"

Adapting it for production

  • Keep the order. Whatever your stack, preserve the sequence: raw bytes → verify → parse → persist → acknowledge → process. In particular, never acknowledge before the event is safely persisted, and never do slow work before acknowledging — the delivery times out after 10 seconds and gets retried.
  • Swap the storage, keep the atomicity. Replace SQLite with your database; the dedup-and-persist step must stay a single atomic operation (a unique constraint on eventId does this in any SQL database).
  • Use a real job mechanism. setImmediate stands in for your job queue or worker. The startup sweep of unprocessed rows is the minimum crash recovery; a periodic sweep does the same job in long-running deployments.
  • Framework raw-body notes. Express: express.raw() as above (or the verify callback of express.json). FastAPI/Starlette: await request.body(). Frameworks that only hand you parsed JSON cannot verify signatures reliably.

Test checklist before going live

Turn these into automated tests against your receiver — the pilot phase of onboarding is the place to confirm them against real deliveries:

  • A correctly signed request is accepted with a 2xx.
  • An invalid signature is rejected, with no processing.
  • A stale timestamp (t outside your tolerance) is rejected.
  • A tampered body (signature no longer matches) is rejected.
  • Redelivering an already-processed eventId returns 2xx but does not repeat business processing.
  • A mismatch between the X-Callplus-Event-Id / X-Callplus-Event-Type headers and the body is rejected — defensive hardening on your side, not a documented Call+ error contract.
  • The receiver responds well within 10 seconds, including under load.
  • Unknown additional JSON fields do not break parsing — additive changes arrive within the same apiVersion.
  • An apiVersion you do not support is handled explicitly (stored and alerted, not silently dropped or misprocessed).
  • A retried delivery carries the same eventId and a fresh signature timestamp — your deduplication treats it as the same event, and your replay-window check still passes.

Did this page help you?