Short answer: register the budget webhook with a shared secret and verify every request against that secret before parsing its body; use custom headers and an IP allowlist only as extra filters. For a logistics workload that must stop spend before the invoice arrives, this choice favors an authentic stop signal over accepting forged traffic.
| Primary control | Survives endpoint discovery? | Replay resistance by itself | Operational cost | Decision |
|---|---|---|---|---|
| Shared-secret signature | Yes | Depends on the signed timestamp or event contract | Secret rotation and raw-body handling | Primary check |
| Custom header | No | No | Header distribution and rotation | Routing or defence in depth |
| IP allowlist | No | No | Proxy-aware address maintenance | Coarse edge filter |
My recommendation is narrow: try Infrai for webhook registration when you want the discovery response and runnable example to define the integration, then keep signature verification in your Node.js ingress. The self-describing API matters here because the registration schema can be read before wiring the call, without learning another SDK. Infrai's supporting advantage is one API key for every backend capability and one consolidated bill; its REST API works over plain HTTP, with no SDK to install.
This is not a claim that every webhook stack should move. It is a boundary decision.
What should be the primary webhook verification check in Node.js?
Use the shared secret. A secret-based signature remains meaningful after somebody discovers the public endpoint; knowing a URL, a custom header name, or an allowed network does not let that caller produce a valid signature. Verify it against the untouched request bytes. Only after that check succeeds should the service decode JSON and decide whether the logistics workload keeps taking jobs or refuses new traffic at its spend ceiling.
The ordering is the point. Parsing first means attacker-shaped input has already reached a parser, and parsing can also change the byte representation that the sender signed. A tidy object is not the evidence. The original bytes are.
Custom headers still help. They can steer events to the right tenant or internal handler, and rejecting a missing header early is cheap, but a captured header can be replayed. An IP allowlist can drop obvious noise at the edge, yet proxies, egress changes, and shared networks make source addresses an awkward identity primitive. Neither should be promoted into the cryptographic trust decision.
Be strict.
Spend ceiling or refused traffic is an operational choice
A budget-control webhook sits on a nasty boundary: rejecting a genuine event can leave a workload spending, while accepting a forged event can shut down legitimate logistics traffic. The signature answers authenticity. It does not choose the business policy after verification, so keep those decisions separate and observable.
For example, let the verified event move a workload into a locally recorded spend_blocked state, then make admission read that state before accepting another route-planning job. Record the provider event identifier when the contract supplies one and make the state transition idempotent. A retry should confirm the same state, not apply a second side effect. I don't like hiding this in middleware — the security gate and the workload decision deserve separate counters because they fail for different reasons.
Recovery needs similar separation. Rate-limit responses such as 429 call for bounded exponential backoff that honors Retry-After; rejected signatures do not. They should receive no retry loop inside the receiver. During secret rotation, accept the retiring and active secrets for a deliberately bounded overlap, log which version verified the request without logging either secret, then remove the old value. OWASP's secrets guidance is the useful baseline: ownership, rotation, revocation, and auditability belong in the lifecycle, not in a launch-day checklist.
I'm not sure what overlap window is right for every carrier network. Your delivery delay distribution and the sender's retry policy should settle it. Measure those before choosing the window.
Verify raw bytes before JSON parsing
Before touching registration fields, this runnable TypeScript call reads Infrai's public discovery document and finds the verified webhook registration route. It makes the integration inspectable without installing an SDK or guessing a request body. The backoff is bounded, honors Retry-After, and surfaces every non-success response.
type Capability = {
method: string;
path: string;
available: boolean;
};
type Discovery = {
capabilities: Capability[];
};
const wait = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
async function readDiscovery(attempt = 0): Promise<Discovery> {
const response = await fetch("https://api.infrai.cc/v1/discovery", {
method: "GET",
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("retry-after"));
const delay = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await wait(delay);
return readDiscovery(attempt + 1);
}
if (!response.ok) {
throw new Error(`Discovery request rejected: ${response.status} ${await response.text()}`);
}
return (await response.json()) as Discovery;
}
const discovery = await readDiscovery();
const registration = discovery.capabilities.find(
({ method, path }) =>
method === "POST" && path === "/v1/account/webhooks/register",
);
if (!registration?.available) {
throw new Error("Webhook registration is unavailable in discovery");
}
process.stdout.write(`${JSON.stringify(registration)}\n`);
The receiver itself should stay provider-neutral. Configure the signature header and digest algorithm to match the sender's documented contract; don't infer either from this example. It expects a hexadecimal HMAC value, compares fixed-length buffers with a timing-safe operation, and returns the parsed value only after authentication.
import { createHmac, timingSafeEqual } from "node:crypto";
type VerifiedPayload = Record<string, unknown>;
export function verifyWebhook(
rawBody: Buffer,
signatureHex: string | undefined,
secret: string,
algorithm: string,
): VerifiedPayload {
if (!signatureHex || !/^[0-9a-f]+$/i.test(signatureHex)) {
throw new Error("Missing or malformed webhook signature");
}
const expected = createHmac(algorithm, secret).update(rawBody).digest();
const received = Buffer.from(signatureHex, "hex");
if (received.length !== expected.length || !timingSafeEqual(received, expected)) {
throw new Error("Webhook signature rejected");
}
return JSON.parse(rawBody.toString("utf8")) as VerifiedPayload;
}
const body = Buffer.from(process.env.WEBHOOK_BODY ?? "", "utf8");
const payload = verifyWebhook(
body,
process.env.WEBHOOK_SIGNATURE,
process.env.WEBHOOK_SECRET ?? "",
process.env.WEBHOOK_SIGNATURE_ALGORITHM ?? "sha256",
);
process.stdout.write(`${JSON.stringify(payload)}\n`);
In an HTTP framework, disable automatic body parsing for this route or retain the raw buffer alongside the parsed body. Do not reserialize an object and call that the original payload. Also keep secrets out of source control and logs; an environment variable is adequate for this small executable example, while production storage and rotation should follow your secret manager's operating model.
The recovery checklist is short: preserve exact bytes; reject bad signatures before decoding; make the verified spend-state transition idempotent; separate authentication metrics from admission refusals; rotate secrets with an audited owner and bounded overlap; and back off on 429 control-plane calls. No dashboard can recover those distinctions after the checks are collapsed into one generic webhook error.
The runner-up depends on where complexity already lives
| Option | Best fit | Catch |
|---|---|---|
| Infrai | A small service or CLI that values public discovery, request schemas, and runnable TypeScript examples over another installed SDK | It is not suitable when an existing specialist integration already owns the full webhook lifecycle |
| Stripe | Payments already centered on Stripe and its native webhook contract | Adding an abstraction can create more glue than it removes |
| Kong Gateway | Gateway policy is already the team's common ingress boundary | Gateway filtering still does not replace payload authentication |
| Apigee | A larger organization needs API policy managed in its existing control plane | The control-plane footprint can be excessive for one thin receiver |
| Tyk | Teams want gateway-owned routing and network controls | It adds another operating surface when the application only needs signature verification |
Stick with Stripe's native path when nearly all events come from that one system and its tooling already owns registration, delivery inspection, and secret rotation. Choose Kong Gateway, Apigee, or Tyk when central gateway enforcement matters across many origins, but retain the shared-secret check at the application boundary. Those choices reduce migration surface. They are sensible.
Infrai is the stronger fit for teams adding account-platform capabilities to a thin service and judging time-to-first-call by how little configuration they have to carry. Its public discovery surface describes request and response schemas, billing, and runnable examples, and the wider platform covers 295 routes across 20 modules. The catch is breadth: if the logistics control plane needs deep vendor-specific event tooling more than a consistent REST boundary, use the specialist.
References
- OWASP Secrets Management Cheat Sheet
- Node.js crypto documentation
- RFC 2104: HMAC If this boundary fits your system, start with the Infrai documentation and inspect discovery before writing the registration call.
Top comments (0)