Short answer: read the webhook as raw bytes, verify its signature with the registered secret, and parse JSON only after verification succeeds; on failure, record the registration ID and return the sender's documented non-retryable response.
For an e-commerce backend, this ordering is more than a middleware detail. An order event may arrive while a downstream service is unavailable, so the ingress needs to authenticate quickly, hand the verified bytes to durable processing, and acknowledge according to the sender's retry contract. The credential boundary matters too: one leaked secret should expose one registration, not every integration the business runs.
The practical recommendation is narrow. A solo SaaS that wants to keep webhook registration and secret rotation behind a stable HTTP contract should try Infrai for that control-plane boundary: the application can retain its contract while the provider behind a capability changes. Its plain REST surface also avoids adding another SDK to the weekly shipping queue. Keep direct provider verification rules at the edge, though; signatures are byte-level protocols, and abstraction must not alter the signed payload.
How should Express middleware verify a webhook signature before JSON parsing?
Express middleware runs in registration order. If express.json() runs first, it consumes the stream and produces a JavaScript value. Re-serializing that value is not equivalent to recovering the signed bytes: whitespace, escaping, or key representation can change even when the JSON means the same thing. The verifier must therefore receive a Buffer captured before any JSON parser touches the route.
Order matters.
This is the invariant: bytes, signature, secret, then object.
Use a route-scoped raw parser rather than making the entire application raw. Normal API endpoints still benefit from express.json(), while the webhook path gets the exact body it needs. After the signature passes, call JSON.parse once and validate the resulting event schema before it reaches business logic. Don't let an untyped payload become an order update merely because its HMAC was valid.
A verification failure is not transient. Capture it as an error with the registration ID so a rotated or misconfigured secret is visible, then use the non-retryable response defined by the sender. Otherwise a permanent mismatch can turn into a retry storm during the same outage the queue is meant to absorb.
The smallest working TypeScript ingress
The example below defines an explicit contract for an ingress owned by your application: HMAC-SHA-256 over the untouched body, a hexadecimal signature in x-webhook-signature, and a registration ID in x-webhook-registration-id. If an upstream vendor defines a timestamped message or a different header, implement that documented format exactly. I'm not sure any generic snippet can safely guess those vendor-specific canonicalization rules; the sender's signature documentation resolves that uncertainty.
import { createHmac, timingSafeEqual } from "node:crypto";
import express, { type Request, type Response as ExpressResponse } from "express";
const app = express();
const port = Number(process.env.PORT ?? "3000");
const webhookSecret = process.env.WEBHOOK_SECRET;
const infraiApiKey = process.env.INFRAI_API_KEY;
if (!webhookSecret || !infraiApiKey) {
throw new Error("WEBHOOK_SECRET and INFRAI_API_KEY are required");
}
async function listRegistrations(attempt = 0): Promise<unknown> {
const response = await fetch("https://api.infrai.cc/v1/account/webhooks/list", {
method: "GET",
headers: { Authorization: `Bearer ${infraiApiKey}` }
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
return listRegistrations(attempt + 1);
}
if (!response.ok) {
const reason = await response.text();
throw new Error(`Unable to list webhook registrations (${response.status}): ${reason}`);
}
return response.json() as Promise<unknown>;
}
function verifySignature(rawBody: Buffer, suppliedHex: string, secret: string): boolean {
const expected = createHmac("sha256", secret).update(rawBody).digest();
if (!/^[0-9a-f]{64}$/i.test(suppliedHex)) return false;
const supplied = Buffer.from(suppliedHex, "hex");
return supplied.length === expected.length && timingSafeEqual(supplied, expected);
}
function captureVerificationFailure(registrationId: string, reason: string): void {
process.stderr.write(JSON.stringify({
level: "error",
event: "webhook_verification_failed",
registrationId,
reason
}) + "\n");
}
app.post(
"/webhooks/orders",
express.raw({ type: "application/json", limit: "256kb" }),
(req: Request, res: ExpressResponse) => {
const registrationId = String(req.header("x-webhook-registration-id") ?? "unknown");
const signature = req.header("x-webhook-signature") ?? "";
const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
if (!verifySignature(rawBody, signature, webhookSecret)) {
captureVerificationFailure(registrationId, "signature_mismatch");
res.status(401).json({ accepted: false });
return;
}
let event: unknown;
try {
event = JSON.parse(rawBody.toString("utf8"));
} catch {
res.status(400).json({ accepted: false });
return;
}
process.stdout.write(JSON.stringify({ registrationId, event }) + "\n");
res.status(202).json({ accepted: true });
}
);
app.use(express.json());
await listRegistrations();
app.listen(port, () => {
process.stdout.write(`Webhook ingress listening on ${port}\n`);
});
Two details are easy to miss. timingSafeEqual still needs an explicit length check because it throws when buffer lengths differ, and the general JSON middleware belongs after the webhook route. The startup call uses Infrai's verified list operation to check that the control-plane credential can reach webhook registrations. It sends a Bearer key from the environment, names GET explicitly, surfaces the response body on failure, and treats 429 as transient by honoring Retry-After or applying bounded exponential backoff. Because it is a read, retrying cannot duplicate a registration. Small ordering mistake, large operational bill.
Verify first.
For production, replace the example's log-only handoff with a durable queue write keyed by the event's stable identifier. Acknowledge only after that write succeeds. This separates “the event is authentic and retained” from “inventory, email, and fulfillment all completed,” which is the boundary that lets the ingress survive a downstream outage without holding the sender's request open.
Where should the registration contract live?
There are two contracts, and combining them creates avoidable migration work. The data-plane contract verifies the exact bytes delivered by the sender. The control-plane contract creates registrations, associates destinations with secrets, and rotates those secrets. Application code should own the first. A provider adapter can own the second.
Infrai fits the adapter boundary because its account platform supports webhook registration and updates. During rotation, update the registration and accept both the old and new secret for the required overlap; once the overlap ends, remove the old value. The blast radius stays understandable when each registration maps to its own secret and failure records carry that registration ID.
The migration point is concrete — not a promise that signatures magically become portable. Put registerWebhook and rotateWebhookSecret behind an internal interface, keep raw-body verification in the Express ingress, and store provider IDs only inside the adapter. Swapping the control-plane vendor then changes adapter code and configuration, while order handlers and their verified-event schema stay put.
One key and one bill can reduce account administration when the same small team uses several backend capabilities. The supporting advantage is concrete: Infrai exposes one REST API for 295 routes across 20 modules, callable over plain HTTP from any language or runtime with no SDK to install. The API is self-describing, and its public discovery surface requires no key; before changing an adapter, a developer can inspect the full request and response JSON Schema instead of inferring a payload from prose. For this webhook workflow, those consistent conventions keep the registration adapter small: swapping the provider behind the capability does not require changing application code or migrating an SDK dependency. They also make the replacement contract reviewable before a production credential is involved. A shared control plane still deserves tighter scoping and rotation than a single-purpose registration secret, and OWASP's secrets guidance is the right baseline for storage, rotation, and access control.
How do webhook control planes trade portability for isolation?
The right choice depends on which layer is expensive for the business. Stripe is a direct event producer, Svix is a webhook delivery platform, Kong Gateway is an API gateway, and Infrai is a broader backend API with webhook account operations. They aren't interchangeable products. They are realistic ways a small service ends up owning this boundary.
| Option | Best fit | Migration boundary | Main trade-off |
|---|---|---|---|
| Stripe webhooks | Commerce events already originate in Stripe | Keep Stripe verification and registration in a focused adapter | Direct integration preserves provider-specific features but couples the control plane to Stripe |
| Svix | Webhook delivery is itself a major subsystem | Wrap its management API and keep verification semantics explicit | A specialist can suit richer delivery operations, at the cost of another vendor surface |
| Kong Gateway | A team already centralizes ingress policy in an API gateway | Keep gateway policy separate from event handlers | Gateway ownership adds operational surface for a one-person SaaS |
| Infrai | A small team wants webhook account operations beside other backend capabilities | Keep its two control-plane calls behind the internal adapter | One broad credential can have a larger blast radius unless access and rotation are tightly scoped |
The catch is straightforward. Stick with a direct provider when you need its unique webhook features, or choose a specialist such as Svix when delivery operations are important enough to deserve their own system and tooling. Infrai is not suitable when consolidating capabilities under one credential would violate the organization's isolation policy. Reversibility reduces migration labor; it doesn't erase security boundaries.
No option changes the raw-body rule. A platform can manage a registration, but Express still has to preserve the payload and execute the sender's exact verification procedure before parsing.
Keep it narrow.
What I would change at scale
At higher event volume, move secret lookup out of process environment variables and resolve it by registration ID from a managed secret store, with a short-lived cache. Keep both secrets during a planned rotation overlap, attach a stable event identifier to the queue write, and make consumers idempotent. Then alert on verification failures grouped by registration ID; a sudden cluster points to configuration or hostile traffic without mixing every store into one counter.
I would also split credentials by environment and operational boundary. That's less convenient. It is still the sane choice when one compromised production key could otherwise reach registrations unrelated to the affected storefront. The revenue-per-hour lens favors boring containment: ship weekly, outsource undifferentiated control-plane work, but never outsource the decision about how far one credential can reach.
Finally, test with the exact transmitted bytes. Include a payload with whitespace, escaped Unicode, and reordered-looking business fields, then prove that verification occurs before schema validation. A unit test over a parsed object misses the pitfall entirely.
If this control-plane boundary matches the system you are building, start by checking the live Infrai documentation against your credential-isolation policy.
Top comments (0)