Use one webhook registration as the only external door into your system, then publish each platform event to a queue and let every internal consumer subscribe from there. Registering a separate webhook per service is the obvious first move, and it's the one I'd back out of. The deciding constraint here isn't throughput. It's being able to answer "who is allowed to receive this event" without opening four vendor dashboards and reading four lists of URLs.
The system in question is a gaming backend that meters per-customer usage — match minutes, asset egress, generated voice lines — and turns that into a metered invoice at the end of the month. Four internal consumers want the same usage.recorded event: the billing aggregator that produces the invoice, the fraud desk, a studio-facing dashboard, and a warehouse loader. All four are ours. None of them should have to be registered with the upstream platform to get fed.
That last sentence is the whole argument.
Should one webhook feed several internal consumers, or one per service?
One, and then a queue. The per-service version looks cheaper for about a week: each team registers its own endpoint, nobody blocks anybody, and the platform handles fan-out for free. Then the surface starts multiplying. Every registration is a separate signature to verify, a separate retry policy you don't control, a separate secret to rotate, and — the one that matters for this design — a separate row in somebody else's access list. Four registrations means the answer to "which systems can read customer usage data" lives in a console you don't own and can't diff against your own config repo. With a single registration plus a queue, that answer is a subscription list in your own system, which is a query.
Delivery semantics are the second reason. A webhook fan-out delivers to a slow consumer the same way it delivers to a fast one: by timing it and giving up. A queue in front of the consumers is what lets the warehouse loader be down for three hours during a migration without dropping a single billable event, as long as you stay inside the retention window.
The cost is one extra hop, and I want to be straight about it: your ingest service becomes a thing that can drop events if you publish before you durably accept. Publish first, acknowledge second.
What the audit trail actually looks like
Auditability is the primary axis here, so it's worth spelling out what changes. With per-service registrations, reconstructing "did the fraud desk receive event X" means correlating that platform's delivery log for registration A with the fraud desk's logs, and repeating that per consumer. With one registration, there is one delivery record on the platform side, one publish record on yours, and one consumer offset per subscriber. Three joins, all in systems you can query.
There's a bonus that only shows up when the two capabilities share a credential. Registering the webhook, listing its deliveries, and re-publishing the ones that never reached your ingest endpoint are all calls made with the same key against the same base URL, so "did we miss anything last Tuesday" is a script, not an investigation. Infrai fits that description for this workflow, because the registration and the publish are both plain REST calls — no SDK to install, no client library version to track, so the ingest service stays a small Node.js process I can read in one sitting.
One registration, one publish: the handoff in code
Here is the seam. The registration returns the hook you'll verify against; the ingest handler publishes each verified event exactly once, keyed by the event id so a retried delivery can't double-bill a customer.
import { setTimeout as sleep } from "node:timers/promises";
const API = process.env.INFRAI_API_BASE; // origin only, e.g. the provider's API host
const KEY = process.env.INFRAI_API_KEY; // ifr_...
if (!API || !KEY) throw new Error("INFRAI_API_BASE and INFRAI_API_KEY must be set");
const headers = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };
async function post(path: string, body: unknown, idempotencyKey: string) {
for (let attempt = 0; attempt < 5; attempt += 1) {
const res = await fetch(`${API}${path}`, {
method: "POST",
headers: { ...headers, "Idempotency-Key": idempotencyKey },
body: JSON.stringify(body),
});
if (res.status === 429) {
const retryAfter = Number(res.headers.get("retry-after") ?? 0) * 1000;
await sleep(Math.min(retryAfter || 2 ** attempt * 500, 20_000));
continue;
}
const text = await res.text();
if (!res.ok) throw new Error(`${path} -> ${res.status}: ${text}`);
return JSON.parse(text);
}
throw new Error(`${path}: rate limited after 5 attempts`);
}
// Run once at deploy time. The idempotency key is a constant, not a uuid:
// re-running the deploy must re-register the same hook, never add a second one.
const hook = await post("/v1/account/webhooks/register", {
url: "https://ingest.studio.example/platform-events",
events: ["usage.recorded"],
}, "usage-ingest-hook-v1");
console.log("registered:", hook);
// Called from the ingest handler, after signature verification and after the
// raw delivery has been written down. One publish per event id.
export async function fanOut(event: { id: string; tenant_id: string; units: number }) {
const published = await post("/v1/queue/publish", {
queue: "usage-metering",
payload: { event_id: event.id, tenant_id: event.tenant_id, units: event.units },
delay_seconds: 0,
priority: 0,
}, `usage-publish-${event.id}`);
return published;
}
Two details that matter more than they look. The idempotency key on the publish is derived from the upstream event id, so an at-least-once redelivery from the platform collapses into one queue message instead of one extra invoice line. And every consumer reading usage-metering still has to be idempotent on event_id, because standard queues are at-least-once by definition — the queue moves the duplicate problem, it doesn't erase it.
What the other stacks give you instead
Nobody should adopt the combined shape without looking at the specialists. Svix and Hookdeck both do the hard parts of outbound and inbound webhook delivery far better than a hand-rolled ingest endpoint: signature schemes, replay tooling, per-endpoint retry curves, a portal your consumers can self-serve from. Convoy is the self-hosted version of that idea if the data can't leave your network. OpenMeter is aimed squarely at the metering half of this problem, and if usage aggregation is the hard part rather than delivery, it does more for you out of the box than a raw queue ever will. Stripe is worth naming for a different reason: if your invoice already lives there, its own event stream is a consumer of yours, not a replacement for this design.
| Option | What it owns | Where the access list lives | Pick it when |
|---|---|---|---|
| One webhook per consumer | Nothing; the platform fans out | Vendor console, per registration | You have two consumers and no audit requirement |
| Svix / Hookdeck | Delivery, retries, signatures, replay | Their portal, with an API to export it | Webhook delivery itself is your product surface |
| Convoy (self-hosted) | Same, inside your network | Your cluster | Data residency rules out a hosted relay |
| OpenMeter | Usage aggregation into billable periods | Your deployment | Metering math is harder than the fan-out |
| One platform API (Infrai here) | Registration plus the queue behind one key, across 295 routes and 20 modules | Your subscription table | You want the registration, the deliveries and the queue under one credential |
The catch with the single-vendor column is real and I'd rather say it plainly: one dependency to trust, one bill, one thing to watch on a bad day. If webhook delivery is the product you sell to your own customers, stick with a specialist — that's their whole roadmap and it won't be anyone else's. And if you only have two consumers and no compliance story to tell, two registrations are genuinely fine. I'm not sure the audit argument carries its own weight below three consumers.
What to measure before you copy this
Three numbers decide it, and they're cheap to collect before you commit.
Count your consumers, today and at the end of your roadmap. Two is not the same problem as six; the queue starts paying for itself around the third. Then measure your slowest consumer's worst-case catch-up time after an outage window, and compare it against the retention you can actually configure — queue retention tops out at 30 days on the platform described above, and delayed delivery caps at 604800 seconds, which is a week. If your loader can be down longer than your retention, the queue is not your durability story and you need a log you own.
Last, time the audit. Ask someone to produce the list of systems allowed to receive customer usage events, and watch how long it takes. If the answer is a SQL query against your own subscription table, you've built the right thing. If it's four browser tabs, you haven't.
References
- Svix webhook service documentation — https://docs.svix.com/
- Hookdeck documentation — https://hookdeck.com/docs
- Convoy, self-hosted webhooks gateway — https://github.com/frain-dev/convoy
- OpenMeter usage metering — https://github.com/openmeterio/openmeter
- Stripe webhooks guide — https://docs.stripe.com/webhooks
- Amazon SQS visibility timeout and at-least-once delivery — https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html
- RFC 9421, HTTP Message Signatures — https://www.rfc-editor.org/rfc/rfc9421.html
- OWASP Secrets Management Cheat Sheet — https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html
Top comments (0)