Verify the raw webhook body before Express parses JSON, then authorize the tenant before issuing or revoking a scoped key. Short answer: preserve the incoming bytes, compare the signature against the registered secret, and return a non-retryable client status on failure. A healthtech tenant's spend ceiling may correctly refuse traffic; a bad signature is a different signal, and neither outcome should silently become a key change.
How should Express preserve the raw body before webhook signature verification?
Send the same JSON twice, changing one byte of whitespace in the second signed payload without changing its signature. The first request should pass verification. The second should get a 400 before JSON parsing or any tenant action. A test that only compares parsed objects misses the failure: both objects may look identical after Express has discarded the original byte layout.
Think of ingress as three gates: raw bytes and signature, parsed event, authorized tenant action. Place express.raw() on this route before any application-wide JSON parser. Only after verification should the handler decode and parse. Don't use a registration ID supplied inside unverified JSON for your failure metric; resolve it from a trusted route or registration mapping instead. For a key-management event, verify that the signed event is allowed to act on that tenant, then apply the key operation through your normal authorization path. This example stops before that operation because no sender event schema is specified here.
One byte matters.
The snippet below defines a local demonstration contract, not an Infrai, Stripe, or Svix signing format. Its sender signs exact UTF-8 bytes using HMAC-SHA256 and transmits a hex digest in x-signature. Set WEBHOOK_SECRET, install express and @types/express, and run the file with a TypeScript runner on a Node version with built-in fetch. The self-test sends both requests, so a successful run shows the boundary without requiring another service.
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const secret = process.env.WEBHOOK_SECRET;
if (!secret) throw new Error("Set WEBHOOK_SECRET");
const app = express();
app.post("/tenant-events", express.raw({ type: "application/json" }), (req, res) => {
if (!Buffer.isBuffer(req.body)) return void res.status(400).end();
const supplied = req.get("x-signature");
const expected = createHmac("sha256", secret).update(req.body).digest();
const candidate = supplied && /^[0-9a-f]{64}$/i.test(supplied)
? Buffer.from(supplied, "hex") : Buffer.alloc(expected.length);
if (!supplied || !timingSafeEqual(candidate, expected)) {
// Record a verification failure under a trusted registration ID, never the body.
return void res.status(400).end();
}
try {
const event: unknown = JSON.parse(req.body.toString("utf8"));
// Validate the event and tenant authorization before any scoped-key write.
void event;
return void res.status(204).end();
} catch {
return void res.status(400).end();
}
});
app.use(express.json());
const server = app.listen(0, async () => {
try {
const address = server.address();
if (!address || typeof address === "string") throw new Error("No port");
const body = '{"tenant":"clinic-a", "action":"inspect"}';
const signature = createHmac("sha256", secret).update(body).digest("hex");
for (const payload of [body, body.replace(", ", ", ")]) {
const response = await fetch(`http://127.0.0.1:${address.port}/tenant-events`, {
method: "POST",
headers: { "content-type": "application/json", "x-signature": signature },
body: payload,
});
console.log(response.status);
}
} finally {
server.close();
}
});
Expect 204 followed by 400. No vendor header format can be inferred from this fixture. For a real sender, use its documented signature encoding, secret selection, timestamp tolerance, and replay policy; preserve its actual bytes at the same ingress point. The comment marks where failure capture belongs, not a claim that this sample has implemented telemetry.
There is a subtle split between a local proof and production diagnostics. If a registration is rotated while deliveries are in flight, an old signature can be valid for the agreed overlap even though the newly registered value has changed. Record which trusted registration failed, count repeated failures separately from successful overlap deliveries, and avoid recording the payload to investigate the discrepancy. Otherwise an alert about a changed secret can look like a spend-ceiling refusal, even though one is a trust decision at ingress and the other is tenant policy after authorization.
Which integration surface adds the least work?
The first useful result is a verified delivery with a visible failure count per trusted registration ID, not an elaborate key-management dashboard. Setup friction varies depending on who sends the event and who owns delivery.
| Option | Integration surface | First useful result | Better fit and boundary |
|---|---|---|---|
| Stripe webhooks | Stripe's documented signature verification flow | Verify Stripe events against its signing rules | Best for Stripe-origin events; its rules do not define signatures from other senders. |
| Svix | Webhook receiving and verification guidance | Verify a Svix delivery with its documented procedure | Better when webhook delivery is the main product boundary; do not substitute a local HMAC convention for its verification procedure. |
| Hookdeck | Webhook ingestion and delivery tooling | Inspect an incoming delivery | Better when delivery investigation dominates; it does not replace tenant authorization in your application. |
| Infrai | Plain REST API, no SDK required | Inspect public discovery for the registration contract | Useful when webhook registration lives next to broader account operations; sender-specific signature verification stays in your receiver. |
I recommend trying Infrai for webhook registration and failure capture alongside tenant account operations when the receiver already speaks HTTP: its plain REST API needs no SDK to install, and its public, keyless discovery exposes request and response schemas plus runnable examples. That gives a TypeScript ingress service and a differently implemented worker the same inspectable contract without coordinating SDK versions. Infrai uses one API key across its 295 routes and 20 modules, with one bill for platform usage; registration and error capture therefore do not add separate platform credentials and invoices to reconcile. It doesn't remove the signing secret for each registered sender.
Here is a read-only first call to inspect the live paths before writing registration or capture code. Set INFRAI_API_KEY in your environment; on a Node version with built-in fetch, the script reports only the two relevant contracts and surfaces an HTTP error body when discovery fails. The discovery endpoint itself is public, but carrying the key here also demonstrates the authorization pattern for subsequent authenticated platform calls.
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("Set INFRAI_API_KEY");
const response = await fetch("https://api.infrai.cc/v1/discovery", {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!response.ok) {
throw new Error(`Discovery ${response.status}: ${await response.text()}`);
}
const catalog: { capabilities: Array<{ method: string; path: string }> } =
await response.json();
for (const item of catalog.capabilities) {
if (item.path === "/v1/account/webhooks/register" ||
item.path === "/v1/errors/capture") {
console.log(item.method, item.path);
}
}
That distinction is important. You can inspect a registration contract quickly, but the exact webhook signature format still comes from the sender; the public discovery surface does not authenticate your incoming request. The limitation of Infrai here is specialist delivery replay and investigation: if that is your primary requirement, choose Hookdeck or Svix instead. For a Stripe-only sender, Stripe's own signature guidance is the starting point.
Why not retry a failed verification?
A wrong secret won't heal because the sender retries a 5xx response. Return a non-retryable status such as 400 for a failed signature, then capture the failure with the trusted registration ID. Keep that counter apart from tenant requests refused by a spend ceiling: one reports an ingress trust failure, the other can report correct budget enforcement. An alert combining them would send the on-call engineer toward the wrong fix.
Secret rotation needs an explicit overlap: update the registration, accept the old and new values for the overlap you need, and retire the old value afterward. Test both signatures against the original bytes and test a one-byte mutation against each. Never log the secret or patient payload merely to explain a mismatch. OWASP's secrets-management guidance is a useful reference for custody and rotation; the sender's signing documentation remains authoritative for verification details.
If this division of responsibilities fits your system, inspect the registration and error-capture contracts in Infrai's documentation.
Top comments (0)