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:
- Read the raw request bytes (never the re-serialized JSON).
- Verify the timestamp and HMAC signature before doing anything else.
- Parse the JSON only after verification succeeds.
- Cross-check the headers against the signed body (defense in depth).
- Deduplicate and persist atomically on
eventId, before acknowledging. - Return a
2xximmediately, 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
eventIddoes this in any SQL database). - Use a real job mechanism.
setImmediatestands 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 theverifycallback ofexpress.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 (
toutside your tolerance) is rejected. - A tampered body (signature no longer matches) is rejected.
- Redelivering an already-processed
eventIdreturns2xxbut does not repeat business processing. - A mismatch between the
X-Callplus-Event-Id/X-Callplus-Event-Typeheaders 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
apiVersionyou do not support is handled explicitly (stored and alerted, not silently dropped or misprocessed). - A retried delivery carries the same
eventIdand a fresh signature timestamp — your deduplication treats it as the same event, and your replay-window check still passes.
Updated 15 days ago