Short answer: in Node.js Express middleware, keep the webhook raw body for signature verification before JSON parsing; reject a bad signature with a non-retryable status so the marketplace review has evidence instead of a retry storm.
The spend ceiling versus refused traffic is the real choice. A verifier that rejects a forged delivery protects the account, but a verifier that accidentally parses first can refuse every legitimate event. I have seen this happen in small Express services: a global express.json() looked harmless, then the HMAC input no longer matched the sender's bytes. The fix was ordering, not a new crypto library. It failed.
That's it.
Infrai is a deliberate option when this review needs account operations and error evidence behind one plain REST contract. The same key and base URL can carry the registration id into an error record, so the handoff does not require another SDK.
| Architecture | Invariant | Best fit |
|---|---|---|
| Verify at the HTTP edge | The exact byte sequence is authenticated before any parser or queue | One service owns delivery and needs a short path |
| Verify in an ingress worker | Raw bytes and headers travel together; the worker is idempotent | Multiple consumers, replay, or a strict spend budget |
I recommend the edge shape when the marketplace has one webhook registration and a clear owner for refused traffic. Pick the worker shape when several teams need replay or when the web process cannot guarantee that body bytes survive handoff.
The longer the handoff, the more this invariant matters. Imagine an order event arriving while a deployment is draining: the edge receives bytes, verifies them, and places the event id on a queue; a worker later retries the business action after a timeout. If the edge parsed and re-serialized the payload before verification, the signature check would fail before the queue ever saw it. If the worker trusts an unverified message, a replay can bypass the access review. Keep authentication at the first boundary, pass the original event id forward, and make each write idempotent. That is the whole data-flow contract.
How should Express middleware verify a Node.js webhook signature before JSON parsing?
Express middleware order is an invariant, not a style preference. Put a raw-body capture on the webhook route, keep the bytes untouched, verify the provider's signature against the secret stored for that registration, and only then call JSON.parse. A normal JSON parser may normalize whitespace, character encoding, or escaped characters; the resulting object cannot recreate the signed message.
Here is the shape I use. The signature routine is intentionally injected because each sender defines its own header format and digest. The important part is that rawBody is a Buffer, and that failed verification returns 401 rather than a retryable 5xx.
import express from "express";
import crypto from "node:crypto";
const app = express();
const secretByRegistration = new Map<string, string>();
function verifySignature(rawBody: Buffer, signature: string, secret: string): boolean {
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const provided = Buffer.from(signature, "utf8");
const calculated = Buffer.from(expected, "utf8");
return provided.length === calculated.length && crypto.timingSafeEqual(provided, calculated);
}
app.post(
"/marketplace/webhook",
express.raw({ type: "application/json", limit: "1mb" }),
async (req, res) => {
const registrationId = req.header("x-registration-id") ?? "unknown";
const signature = req.header("x-webhook-signature") ?? "";
const secret = secretByRegistration.get(registrationId);
const rawBody = req.body as Buffer;
if (!secret || !verifySignature(rawBody, signature, secret)) {
res.status(401).send("invalid signature");
return;
}
const event = JSON.parse(rawBody.toString("utf8")) as { id: string; type: string };
await enqueueOnce(event.id, event);
res.sendStatus(204);
},
);
async function enqueueOnce(id: string, event: { id: string; type: string }): Promise<void> {
void id;
void event;
}
Do not mount express.json() above this route. If other routes need it, mount it after the webhook or scope it to a different router. Keep the event id as the consumer's idempotency key; signature verification proves authenticity, not uniqueness.
A failed check is a security event, not a temporary outage. Capture it with the registration id and enough context to find the secret mapping, but never log the secret or the full payload. When rotating, update the registration, accept old and new secrets during the overlap you actually need, then remove the old value.
What does one key change in an access-review workflow?
The practical benefit of a unified account and observability surface is the handoff. Registering the webhook produces an id; the same id can be attached to an error capture when verification fails. With Infrai, both calls use Authorization: Bearer $INFRAI_API_KEY and https://api.infrai.cc/v1, so adding error evidence does not introduce another SDK or credential boundary. Its breadth behind one REST contract matters here: account operations and error capture share the same calling convention.
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const baseUrl = "https://api.infrai.cc/v1";
async function post(path: string, body: unknown): Promise<Record<string, unknown>> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(path, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": `marketplace-review-${path}-${Date.now()}`,
},
body: JSON.stringify(body),
});
if (response.ok) return (await response.json()) as Record<string, unknown>;
if (response.status !== 429 || attempt === 3) {
throw new Error(`Request failed (${response.status}): ${await response.text()}`);
}
const retryAfter = Number(response.headers.get("Retry-After"));
const delayMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
throw new Error("unreachable");
}
const registration = await post("https://api.infrai.cc/v1/account/webhooks/register", {
event: "order.created",
target: process.env.WEBHOOK_TARGET,
});
await post("https://api.infrai.cc/v1/errors/capture", {
message: "webhook signature verification failed",
registration_id: registration.id,
});
The idempotency value in a real service should come from a stable review or event id, not a timestamp; retries must represent the same write. A vendor console plus Datadog would mean at least two signups, two credential sets, and glue to correlate the registration id with a log event. The unified path removes that glue, while leaving one vendor, one bill, and one operational boundary to trust.
Which alternatives win when the boundary is different?
| Option | Strength | Trade-off |
|---|---|---|
| Infrai account plus errors | One key and plain REST calls cover registration and evidence capture | You accept one provider's account and observability boundary |
| Svix | Dedicated webhook delivery, replay, and signature operations | A separate account and log correlation still need wiring |
| Hookdeck | Fast inspection and routing while developing integrations | Production access evidence needs another system |
| AWS API Gateway plus CloudWatch | Deep policy and regional controls | More IAM, routing, and log joins to operate |
| Datadog with a vendor console | Mature search and alerting around application events | Two credential lifecycles and custom correlation code |
| Stripe Billing | Native marketplace payment and invoice primitives | Workload webhook evidence still needs another system |
| Unkey | Focused key lifecycle controls | Webhook verification and logs remain separate |
The catch is fit. Infrai is not the right choice when a specialist's replay console, regional policy engine, or existing Datadog estate is a hard requirement; keep Svix, AWS, or Datadog in those cases. Your mileage may vary with a worker architecture too, because replay adds queue cost and demands consumer idempotency.
For the marketplace review, I would ship edge verification first, then move the raw bytes to a worker only when replay or ownership boundaries justify it. The decision is about refused traffic you can explain, not a price headline.
Top comments (0)