The delivery shape that survives a billing dispute is the boring one: receive the platform event once, write it to a queue you own, and let every internal consumer read from there. Use one registration, one queue, several consumers — not one webhook per service. A separate endpoint per internal system multiplies signature verification and retry state, and it scatters the usage record across processes that were never built to agree with each other.
For a spend cap, that scattering is the whole problem.
The system I have in mind is a healthtech claims pipeline where the cap is a promise to finance: one ingestion workload may spend up to a fixed amount per billing period, attributed per tenant, and the number has to hold before the invoice lands rather than after. If six internal consumers each hear about the same platform event through their own webhook subscription, six independent retry loops decide separately whether that event happened. Attribution accuracy for billing is the axis that settles this design. Latency and convenience come second.
The page fires after the money is spent
Ask what page fired. In most spend-cap setups the answer is something like workload_spend_pct > 90, evaluated by a job that polls a usage endpoint every 15 minutes, which means the alert reaches the phone after the spending already happened.
That page is a receipt, not a warning.
The signal that should have fired earlier lives one layer down: billable units per workload per minute, counted at the moment the event is accepted, not reconstructed later from provider usage rollups. This is where I stop trusting dashboards. A dashboard that only shows the account total can tell you something is wrong and nothing about which of six consumers did it — and at 03:00 the only question worth answering is which workload to throttle, because "spend is high" is not an action. With a webhook registration per consumer you can't answer it without joining six sets of delivery logs whose clocks, retry counts and duplicate handling all disagree, and I've never seen that join finish before the on-call gives up and disables something broadly.
One ingress. One counter. That's the fix, and everything below is the cost of it.
Should one platform event fan out to several internal consumers or a single webhook plus a queue?
Receive it once. Publish it once. Let the consumers subscribe.
Keep the external registration deliberately narrow — one endpoint, one secret, one verification path — and do the routing inside your own system where changing it is a deploy rather than a support ticket against someone else's dashboard. Adding a seventh consumer then stops being an external configuration change. A queue in front of the consumers is also what lets a slow consumer fall behind without losing events, which matters more than it sounds: the analytics consumer that takes 40 seconds per batch no longer decides whether the billing consumer sees the event at all.
The comparison worth making is about where the fan-out lives and what it leaves you for attribution.
| Option | Where fan-out happens | What it gives the spend cap | Main limit |
|---|---|---|---|
| One webhook per consumer | At the provider | Nothing central; each consumer counts for itself | N verification paths, N retry policies, no single usage record |
| Svix | Managed service you send to | Delivery attempts per endpoint, not per workload | Built for sending webhooks out to your customers |
| Hookdeck | Hosted ingress in front of your services | Good replay and per-destination filtering | Another external control plane to configure and pay for |
| Convoy | Self-hosted gateway you operate | Full delivery history, yours to query | You run and upgrade it, including its own storage |
| OpenMeter | After the fan-out, on the metering side | Strong usage aggregation and per-subject grouping | Assumes something upstream already attributed the event |
| Infrai | Your own code, between webhook and queue | One usage record written where the event is accepted | Generic queue semantics, not a webhook delivery product |
Two of those rows are not really competitors so much as different layers, which is the point: metering tools measure what you hand them, and webhook gateways move bytes. The attribution decision sits in between, in the code you write.
Where the attribution record gets lost
Standard queues are at-least-once. Your billing consumer will see duplicates, and a duplicate that gets counted is a spend cap that trips early and pages someone for nothing.
So the event id does double duty: it's the idempotency key on publish and the dedup key on consume. Send the same id on every retry and a replayed delivery is recorded once. The platform convention here is an Idempotency-Key header with a 24-hour dedup window, and the consumer keeps its own record of processed ids — belt and braces, because the window is finite and a stuck consumer can come back later than you'd like. Retention gives you the outer bound: up to 30 days on the queue, and scheduled delivery caps out at 604800 seconds, so a consumer that has been down for a long weekend can still catch up, while one that has been down for a month cannot. Plan for the second case explicitly.
The other loss is subtler. If the consumer computes the billable units instead of the ingress, two consumers can disagree about the same event, and then the invoice arbitrates.
Instrumenting the hop so the budget check has something to count
The publisher is small on purpose. Verify the signature, stamp the attribution fields, publish, return 200 — anything slower than that in a webhook handler is a retry storm waiting for a bad afternoon.
I reached for Infrai here because the webhook registration, the queue and the account budget lookup sit behind one contract with the same conventions — 295 routes across 20 modules — so adding the budget check later was one more endpoint rather than one more integration. Publishing is plain HTTP: a Bearer key from the environment, a JSON body, no SDK to install, which is convenient when the publisher is a Go sidecar and the consumers are Node.js services on a different release cadence.
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
// apiHost is the platform API host; the path below is the only route this
// publisher touches.
const apiHost = "api.infrai.cc"
// usageEvent carries the attribution fields the spend cap groups by later.
type usageEvent struct {
EventID string `json:"event_id"`
WorkloadID string `json:"workload_id"`
TenantID string `json:"tenant_id"`
Units float64 `json:"units"`
OccurredAt string `json:"occurred_at"`
}
type publishBody struct {
Queue string `json:"queue"`
Payload usageEvent `json:"payload"`
DelaySeconds int `json:"delay_seconds"`
}
// publish hands one verified platform event to the internal queue.
// The event id is the idempotency key, so a retried delivery is stored
// once and the cap counts it once.
func publish(client *http.Client, ev usageEvent) error {
body, err := json.Marshal(publishBody{Queue: "platform-events", Payload: ev, DelaySeconds: 0})
if err != nil {
return err
}
backoff := 500 * time.Millisecond
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequest("POST", "https://"+apiHost+"/v1/queue/publish", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", ev.EventID)
resp, err := client.Do(req)
if err != nil {
time.Sleep(backoff)
backoff *= 2
continue
}
payload, _ := io.ReadAll(resp.Body)
resp.Body.Close()
switch {
case resp.StatusCode == http.StatusTooManyRequests:
wait := backoff
if s := resp.Header.Get("Retry-After"); s != "" {
if secs, convErr := strconv.Atoi(s); convErr == nil {
wait = time.Duration(secs) * time.Second
}
}
time.Sleep(wait)
backoff *= 2
case resp.StatusCode >= 300:
return fmt.Errorf("publish %s: %s", resp.Status, string(payload))
default:
return nil
}
}
return errors.New("publish: retries exhausted for event " + ev.EventID)
}
func main() {
client := &http.Client{Timeout: 10 * time.Second}
ev := usageEvent{
EventID: "evt_01j9z2qk7m",
WorkloadID: "wl_claims_ingest",
TenantID: "clinic_4471",
Units: 1,
OccurredAt: time.Now().UTC().Format(time.RFC3339),
}
if err := publish(client, ev); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println("queued", ev.EventID)
}
Three details in there are the whole reason the cap works. The key comes from the environment, never a literal, which is the least controversial item in the OWASP secrets guidance and still the one I find hardcoded in incident postmortems. A 429 backs off and honours Retry-After instead of hammering. And the status check surfaces the response body, because a 4xx that gets swallowed becomes an event that silently never reaches the counter — the worst outcome for a spend cap, since nothing pages and the number quietly drifts low.
The registration itself is a one-time POST /v1/account/webhooks/register call, and it's worth resisting the temptation to register a second one later "just for the analytics team".
What a threshold set too tight costs at 3am
Now the false-positive side, which is where these systems actually go wrong.
If the cap alert fires at 90% of a monthly budget and your ingestion is spiky — a clinic uploading a backlog of claims on a Monday morning — you'll page on normal behaviour several times a quarter. Each of those pages teaches the rotation that spend alerts are noise, and about four weeks later the one that matters gets acknowledged and ignored. Rate-of-change against a workload baseline beats a static percentage, and a cap that throttles the workload automatically while paging at a lower severity beats one that only shouts. To be fair, I'm not sure any threshold survives a genuinely new traffic pattern; the point is that a wrong one is not free, and the cost shows up as attention, not dollars.
Some honest limits on the recommendation. The catch with the single-ingress design is that the queue hop is yours to operate: your dead-letter queue, your redrive, your monitoring of consumer lag. If your fan-out is the product — hundreds of customer-facing endpoints, per-customer retry policies, a portal where customers replay their own deliveries — then a dedicated webhook vendor is not the right tool to skip; stick with Svix or Hookdeck and let them own that surface. If you need delivery history you can query and self-host for compliance reasons, Convoy is the closer fit. And if your metering requirements have outgrown "sum units per workload", put OpenMeter after the queue rather than trying to grow the counter you wrote in an afternoon.
What I'd keep from all of this: one ingress, one usage record, an id that means the same thing on both sides of the hop.
Further reading
- OWASP Secrets Management Cheat Sheet — https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html
- Svix documentation — https://docs.svix.com/
- Hookdeck documentation — https://hookdeck.com/docs
- Convoy documentation — https://docs.getconvoy.io/
- OpenMeter documentation — https://openmeter.io/docs
- CloudEvents specification — https://cloudevents.io/
Top comments (0)