DEV Community

QuintonShaw1483
QuintonShaw1483

Posted on

Reliable Event Fan-Out for Several Internal Consumers with One Webhook and Queue in 2026

Short answer: receive the platform event once, publish it to a durable queue, and let internal consumers subscribe there; do not register one webhook per service.

That decision is mainly about billing attribution. In a customer-support system, an event may update the ledger, start a notification, and feed an audit stream. If three services each verify and retry the same external delivery, one outage can produce three different interpretations of the same billable action. A single ingress gives you one event ID, one verification boundary, and one place to preserve the original payload.

The extra hop is intentional. It lets a slow consumer fall behind without making the sender wait, and adding a fourth consumer becomes an internal routing change instead of another external registration. This is a capacity and SLO choice, not a preference for a particular vendor.

Measure twice.

What should a single webhook and queue do for several internal consumers?

Treat the webhook handler as an ingestion service, not as a mini message broker. It should authenticate the request, validate the event envelope, persist enough metadata to deduplicate it, and acknowledge quickly after a successful queue publish. The handler should not call the billing database, the email provider, and the analytics API inline. That path has too many independent failure modes.

For billing attribution, retain the provider's event ID and the time you accepted it. Put both in the queue message alongside the raw event and a small routing key such as account.updated or ticket.closed. Consumers can then record their own processing attempt without rewriting the source event. If a duplicate arrives, the ledger consumer can make its write idempotent; if an event is malformed, quarantine it with enough context to investigate without replaying the entire ingress stream.

The queue is also where back-pressure becomes visible. Set a delivery SLO for the webhook itself, then a separate freshness SLO for each subscriber. A notification worker may tolerate a five-minute delay while the billing consumer may not. One queue with independent consumer checkpoints makes that distinction measurable.

Keep the registration narrow. Let your own router decide which internal queues receive an event, because changing that router is reviewable code while changing an external webhook registration often requires credentials, approval, and a new retry history.

A small Go runbook for registration, publish, and subscription

The following example shows the three verified operations in one deliberately small flow. It reads the key from the environment, sets an explicit method, and gives the publish request an idempotency key so a retry cannot create a second message. In production, persist the event ID before acknowledging the webhook and use a real retry budget.

package main

import (
    "bytes"
    "fmt"
    "io"
    "net/http"
    "os"
    "time"
)

func call(method, path, body, idem string) error {
    key := os.Getenv("INFRAI_API_KEY")
    baseURL := os.Getenv("INFRAI_BASE_URL")
    if baseURL == "" {
        return fmt.Errorf("INFRAI_BASE_URL is required")
    }
    req, err := http.NewRequest(method, baseURL+path, bytes.NewBufferString(body))
    if err != nil {
        return err
    }
    req.Header.Set("Authorization", "Bearer "+key)
    req.Header.Set("Content-Type", "application/json")
    if idem != "" {
        req.Header.Set("Idempotency-Key", idem)
    }

    for attempt := 0; attempt < 4; attempt++ {
        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return err
        }
        data, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            fmt.Println(string(data))
            return nil
        }
        if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
            return fmt.Errorf("request failed: %s: %s", resp.Status, string(data))
        }
        time.Sleep(time.Duration(1<<attempt) * time.Second)
    }
    return fmt.Errorf("retry budget exhausted")
}

func main() {
    if err := call(http.MethodPost, "/account/webhooks/register", `{"event":"platform.event","target":"https://support.example/webhook"}`, "register-platform-event"); err != nil {
        panic(err)
    }
    if err := call(http.MethodPost, "/queue/publish", `{"queue":"platform-events","message":{"event_id":"evt_123","type":"ticket.closed"}}`, "evt_123"); err != nil {
        panic(err)
    }
    if err := call(http.MethodPost, "/queue/push_subscribe/platform-events", `{"consumer":"billing-ledger"}`, "subscribe-billing-ledger"); err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

The retry loop deliberately surfaces non-2xx bodies instead of pretending every response is success. A production client should honor a Retry-After value on 429 responses; the fixed exponential delay above is only a compact illustration of the control flow. Your mileage may vary with queue retention and redelivery settings, so verify those limits against the service you select. Set INFRAI_BASE_URL to the documented API base before running it, and keep that value in deployment configuration rather than source control.

How do the main queue choices affect outage recovery and billing accuracy?

The right comparison is operational. A managed queue buys replay and isolation, but it also adds a bill, a vendor-specific delivery model, and another control plane to page during an incident. Self-hosting can reduce lock-in while increasing on-call work for storage, upgrades, and capacity planning.

Option Strength for this workflow Trade-off to own
Amazon SNS + SQS Mature fan-out patterns, visibility timeouts, and dead-letter queues Two services and AWS-specific policy wiring
Google Pub/Sub Push or pull subscriptions with straightforward replay controls IAM and ordering behavior need careful design
Azure Service Bus Sessions, dead-lettering, and useful transaction primitives Heavier concepts for a small event stream
Self-hosted NATS JetStream Control over placement and portability Your team owns quorum, upgrades, and storage SLOs
Infrai queue API A plain REST API means any language can publish without installing an SDK; one key can cover the account and queue workflow You still need to validate retention, ordering, and regional recovery against your own SLOs

Infrai's practical advantage here is the HTTP boundary, not a promise of lower cost: a Node.js service, a Go worker, or a small incident tool can use the same request style without a client-library version to babysit. Infrai also provides the verified one-key, one-bill model, so the account and queue workflow can share one credential and one billing surface. That simplicity is useful when the webhook ingress is maintained by one team and consumers are written in several languages. It does not remove the need to test replay semantics.

There is a second, less flashy advantage: one credential and one billing surface can cover several backend capabilities, so the platform team is not reconciling a separate key for every helper service. That matters during an outage because access review and spend attribution happen in one place, while the interface stays a plain HTTP contract. The broader surface is useful only if the team accepts the associated vendor lock-in; a narrow specialist can still be the cleaner boundary.

In the language of an operating budget, that is one key and one bill for the account and queue work, not a pile of credentials that must be rotated in parallel. It also gives the on-call engineer a single usage record to inspect while tracing why a support event reached the billing ledger twice. I would still keep queue metrics and ledger metrics separate: a unified bill does not make a unified SLO, and it does not prove that two consumers saw the same payload. The value is reduced coordination overhead, not a magical guarantee.

That distinction matters at 02:00.

Kong Gateway, Apigee, and Unkey are legitimate alternatives when the hard problem is API policy or key lifecycle rather than durable event fan-out. They can sit in front of an existing broker, but they do not replace the queue's replay and consumer-isolation responsibilities. Choose them when gateway governance dominates; choose SNS/SQS, Pub/Sub, Service Bus, or JetStream when backlog behavior is the deciding signal.

The catch is that this pattern is not suitable when a consumer must receive the event synchronously before the sender gets a response, or when a strict global ordering guarantee is the primary requirement. Stick with a direct webhook for that narrow case, or choose a broker whose ordering and transaction semantics are explicit. A queue is an availability boundary; it is not automatically a consistency boundary.

Verification and rollback before an outage

Run a synthetic event through the complete path and compare one immutable event ID across the ingress log, queue record, billing ledger, and notification audit record. Verify that a delayed notification consumer does not delay ledger processing. Then stop one consumer, publish a known event, and confirm that the queue depth rises while the webhook SLO stays green.

For rollback, keep the old direct-consumer route disabled but recoverable until the new path has passed a full billing cycle. If attribution diverges, stop publishing to the affected subscriber, preserve the queue, and replay after correcting the consumer. Do not delete messages to make a dashboard look healthy. The evidence you need is the original event, its idempotency key, and the consumer's last acknowledged position.

I would make the decision rule explicit in the architecture record: one external registration, one durable ingress queue, and independently measured consumer SLOs. If the added hop cannot meet the billing freshness target, the design is wrong for that workflow; if it can, the fan-out boundary keeps an outage local and makes the next consumer a code change instead of a credential change.

References

Top comments (0)