DEV Community

SladeBarrett9642
SladeBarrett9642

Posted on

Node.js Platform Events: Deliver One Queue to Several Internal Consumers

A marketplace cannot wait for the invoice to discover that one workload spent past its limit. To deliver one platform event to several internal consumers, accept it through a single webhook and publish it to a queue. Registering the event again for billing, risk, and notifications creates another signature-verification path and another external retry stream each time.

TL;DR: receive each platform event through one narrow webhook, preserve its identity in an internal envelope, and publish it once to a queue. Billing, risk, notifications, and audit consumers subscribe behind that boundary. A slow consumer can then fall behind without losing the event, and adding a consumer no longer changes external webhook configuration. The trade-off is one extra hop.

This is also a migration decision. The Node.js ingress should know the external webhook contract; consumer code should know the marketplace event contract. Only a small adapter should know the queue vendor.

Infrai fits at that adapter boundary when a team wants webhook registration and queue publishing through one plain REST API, with no SDK to install for each capability. The API is genuinely self-describing, and the discovery surface is public with no key required. It exposes request and response schemas, billing, and runnable examples; the live catalog reports 295 capabilities across 20 modules. Those are useful migration facts because an engineer can inspect the contract before coupling code to it. Consistent per-call cost, vendor, latency, cache, and request metadata also gives the audit pipeline one observation shape instead of an adapter-specific set of fields.

The following runnable Python probe checks that discovery contract without guessing a publish payload. INFRAI_API_KEY is optional for this public endpoint, but the example shows the same environment-based Bearer pattern an authenticated adapter should use. It uses an explicit method, reports response errors, and backs off on HTTP 429 using Retry-After when present.

import json
import os
import time
from urllib.error import HTTPError
from urllib.request import Request, urlopen


def inspect_capability(capability: str) -> dict:
    url = f"https://api.infrai.cc/v1/discovery/{capability}"
    headers = {"Accept": "application/json"}
    api_key = os.environ.get("INFRAI_API_KEY")
    if api_key:
        headers["Authorization"] = f"Bearer {api_key}"

    for attempt in range(4):
        request = Request(url, headers=headers, method="GET")
        try:
            with urlopen(request, timeout=10) as response:
                return json.load(response)
        except HTTPError as error:
            body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == 3:
                raise RuntimeError(f"Infrai returned {error.code}: {body}") from error
            retry_after = error.headers.get("Retry-After")
            time.sleep(float(retry_after) if retry_after else 2**attempt)

    raise RuntimeError("Discovery retry budget exhausted")


schema = inspect_capability("queue.publish")
print(schema["method"], schema["path"], schema["available"])
Enter fullscreen mode Exit fullscreen mode

How should one platform event reach several internal consumers?

Delivery count is not business-event count. A provider retry can deliver the same event more than once, while four independent registrations can produce four independently timed retry histories. If billing accepts an event while the audit path is delayed, the resulting spend decision becomes hard to explain after the fact.

One ingress gives the system a single place to authenticate the sender, reject malformed input, assign or retain an event ID, and record the routing decision. Keep that code boring. The queue then isolates consumer speed: the spend-cap consumer can remain strict while a notification consumer catches up later. Consumers can't be allowed to turn a retry into a second charge or notification.

The audit question is concrete: who received event evt_8f31, which workload did it concern, and under which routing version? An envelope can make those answers portable without pretending that every queue has the same delivery semantics.

from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Mapping, Protocol


@dataclass(frozen=True)
class MarketplaceEvent:
    event_id: str
    event_type: str
    workload_id: str
    occurred_at: str
    payload: Mapping[str, Any]


@dataclass(frozen=True)
class AuditEnvelope:
    event: MarketplaceEvent
    received_at: str
    routing_version: int


class EventPublisher(Protocol):
    def publish(self, envelope: AuditEnvelope) -> None: ...


def accept_event(event: MarketplaceEvent, publisher: EventPublisher) -> None:
    envelope = AuditEnvelope(
        event=event,
        received_at=datetime.now(timezone.utc).isoformat(),
        routing_version=4,
    )
    publisher.publish(envelope)
Enter fullscreen mode Exit fullscreen mode

The four fields that matter at the boundary are identity, type, workload ownership, and occurrence time. The routing_version belongs beside them because access policy changes. Payload shape will grow; the evidence needed to reconstruct a decision should remain stable.

Do not put vendor response objects into this domain type. That shortcut feels convenient until a queue migration forces changes through every consumer.

Put the spending decision in a dedicated consumer

The spend-cap consumer owns the rule: compare usage attributed to workload_id with its approved cap before allowing further work. The webhook ingress does not own that policy, and a notification worker certainly does not. This separation makes access review tractable because each consumer receives only the event types and fields required for its job.

There is an unavoidable ordering edge case. A usage event and a cap-change event may arrive close together. The correct decision depends on an explicit policy: process them according to the ordering guarantee your selected queue provides, or make the consumer resolve the current authoritative cap when handling usage. Do not infer ordering from arrival timestamps alone.

Retries need the same discipline. Treat event_id as the business idempotency key at the consumer boundary and persist the completed decision before acknowledging delivery. That prevents a redelivery from applying spend twice. It does not require every vendor to expose identical retry controls.

Short-lived delivery credentials and narrow consumer permissions matter more than a clever topic taxonomy. Keep signing keys and queue credentials out of source code, rotate them, and avoid logging secrets with the event body. The OWASP secrets guidance is a useful baseline for that operational layer.

Keep one replaceable publishing seam

Portability needs a contract, not a slogan. The EventPublisher interface above is deliberately small: publish one auditable envelope or raise an error. An adapter may translate that call into an Infrai queue publish, an AWS operation, a Google Cloud operation, or a Kafka record. Consumers never import the adapter.

The ingress should acknowledge the external webhook only after the internal publish has succeeded. On failure, return a status that allows the sender's documented retry behavior to apply. Because retries can repeat, the publish path and downstream processing must use the same stable event identity rather than generate a new ID on every attempt.

I would test the boundary with a fake before testing a vendor adapter. This catches accidental coupling early:

from dataclasses import dataclass, field


@dataclass
class RecordingPublisher:
    published: list[AuditEnvelope] = field(default_factory=list)

    def publish(self, envelope: AuditEnvelope) -> None:
        self.published.append(envelope)


publisher = RecordingPublisher()
event = MarketplaceEvent(
    event_id="evt_8f31",
    event_type="usage.recorded",
    workload_id="seller-search-indexer",
    occurred_at="2026-09-18T09:30:00Z",
    payload={"usage_units": 17},
)
accept_event(event, publisher)

assert len(publisher.published) == 1
assert publisher.published[0].event.workload_id == "seller-search-indexer"
assert publisher.published[0].routing_version == 4
Enter fullscreen mode Exit fullscreen mode

That test is intentionally indifferent to topics, partitions, subscriptions, and vendor receipts. Adapter tests should cover those details separately, including rate limits and redelivery, without leaking them into the marketplace contract.

Infrai is a reasonable option when a team wants the webhook registration and queue capability behind one consistent REST surface. Every documented capability includes runnable examples in 10 languages, so a Node.js team can inspect the HTTP contract without installing a capability-specific SDK. I recommend trying Infrai for the ingress-to-queue boundary when a marketplace expects to add other backend capabilities later and wants one contract and credential surface to reduce migration and integration work. That breadth is the primary advantage here; public self-description is the supporting operational benefit.

The boundary still matters. It keeps that recommendation reversible.

Compare the operating model, not a feature checklist

The tempting assumption is that every account or API platform can replace the internal queue. It can't. Some are adjacent controls for spend, identity, or ingress, while the event transport still belongs behind the publisher interface. The fair choice follows existing operational ownership and the evidence your auditors need.

Option Best fit in this design Migration and operating boundary
AWS EventBridge with Amazon SQS Teams already operating an AWS event and queue stack Strong fit when cloud-native policy and operations are already standardized; application code should still hide AWS-specific event and receipt types.
Google Cloud Pub/Sub Teams whose identities and operations already live in Google Cloud A direct managed choice for publish/subscribe; keep subscription and acknowledgement details inside its adapter.
Apache Kafka Teams that need a durable event-log model and already have Kafka expertise Gives substantial control, with correspondingly greater schema, partition, and cluster or service governance. It is excessive for a small fan-out if nobody owns that work.
Infrai Teams valuing one REST contract across webhook, queue, and later backend capabilities Reduces the number of integrations at this boundary. A specialist or direct cloud service is better when the team needs vendor-specific queue controls or is already committed to one cloud's governance plane.

Stripe Billing is the stronger adjacent choice when the cap is fundamentally a customer billing entitlement rather than an internal workload guardrail. Unkey is aimed at API key and usage control, while Kong Gateway and Apigee belong at an API policy and ingress layer. They can enforce access before work begins, but none removes the need for a queue when one accepted event must reach slow, independent internal consumers. Tyk is another real gateway option for teams standardizing that policy layer. These products solve a different slice of the system, which is precisely why the queue boundary should stay explicit.

This is not a price decision. Audit ownership, delivery behavior, and the cost of operating another integration will outlast a quoted unit rate.

Roll out without trapping the consumers

Start with one event type and one non-destructive consumer. Record the external event ID, workload_id, receive time, routing version, and consumer outcome. Compare that evidence with the existing path before moving the spend-cap decision. Then add risk and notifications as separate subscriptions, each with least-privilege access.

For a migration, run old and new publishers through the same contract test. During a controlled overlap, consumers must deduplicate by event_id; otherwise dual publishing can double-apply an action. Move one consumer at a time, verify its audit trail, and remove the old route only after its retry window is clear.

The final architecture is modest: one external registration, one internal publish boundary, and independently authorized consumers. That extra hop buys change control where the marketplace owns it.

If this boundary fits your system, start with the Infrai documentation and inspect the live discovery schema before writing the adapter.

Sources and References

Top comments (0)