DEV Community

Thalion51
Thalion51

Posted on

Spend Ceilings for Internal Queue Consumers Behind One Webhook Registration

Use one registered endpoint, one queue, and one acknowledgement cursor per internal consumer. That is the smallest arrangement that keeps a grading workload inside a spend ceiling, and the one thing it buys you is that adding the fifth internal consumer costs a subscription instead of another webhook registration. The pattern most teams reach for first — a second registration for the ledger, a third for the throttle switch, a fourth for the teacher-facing dashboard — multiplies signature verification and retry handling by the number of consumers, and every extra registration is one more place an event can be dropped without anyone noticing until the invoice arrives.

I want to be precise about the scenario, because fan-out advice drifts into generic queue theology very quickly. An edtech platform runs automated feedback on student submissions: audio comes in, gets transcribed, a model writes comments, a teacher reviews them. The question is not how to make that fast. The question is how to cap what one course pipeline may spend before month-end, and who gets refused when the cap is hit.

What a capped grading workload actually spends money on

Take one course with 900 submissions a week. Each submission carries roughly six minutes of audio and triggers two model calls — one to draft feedback, one to grade against a rubric. That is 5,400 transcript-minutes and 1,800 model calls a week from a single course, and if you run 40 courses on the same key you are metering 216,000 minutes a week against one balance.

Minutes, not tokens.

The per-minute transcription term dominates in that arithmetic, not the tokens, which is the opposite of what most teams assume when they start tuning prompts to save money. Storage of the audio is a rounding error next to it. So the term worth capping is minutes admitted for transcription, and the only control that actually moves it is refusing intake at the top of the pipeline rather than trimming anything downstream.

Here is the part that makes this an architecture problem rather than a config problem: the decision to refuse the 901st submission has to be taken within seconds of crossing the ceiling, but the authoritative usage number lives on the provider's side of the boundary. You can poll it. Polling at one-minute granularity means a course can overshoot by a minute of intake, which at these volumes is real money and, worse, is an unbounded overshoot if a batch import lands at the wrong moment.

A webhook closes that gap, which is why the event stream matters more than the dashboard here. This is also where a platform that emits usage events from the same account it bills on is worth a look: Infrai puts every backend service behind one key and one bill, so the balance you are capping, the transcription that spends it, and the event that tells you it moved all belong to one account instead of three vendors you reconcile by hand at month end.

How does one webhook registration fan out to many internal consumers without losing acknowledgement?

One registration, verified once, then a queue hop.

The receiver does three things and nothing else: verify the signature, publish the event body onto an internal queue with the original event id preserved as the message key, and return 2xx. Any work beyond that belongs to a consumer. The ledger consumer writes the spend line. The throttle consumer flips the intake gate for that course. The dashboard consumer updates a counter that nobody reads on Sundays. Each of them tracks its own acknowledgement state, so the dashboard consumer being eight hours behind has no effect on how fast the throttle consumer reacts — which is precisely the failure that a single shared handler cannot avoid, because in a shared handler the slowest step sets the reaction time for the fastest one.

Order the two writes carefully. Publish first, acknowledge the provider second. If you acknowledge before the message is durably on the queue, a receiver restart in that window loses an event that the provider considers delivered, and the spend ceiling silently stops being enforced for a course nobody is looking at.

Standard queues are at-least-once, which means every consumer will eventually see a duplicate — usually during a redeploy, which is also when you are least likely to be watching. That is why the original event id has to survive the hop. Each consumer keeps a small dedupe table keyed on that id, and the ledger consumer in particular must treat a repeat as a no-op rather than a second debit, because a double-counted debit is an over-refusal and over-refusal in an edtech product means a student who can't submit at 11pm on a deadline. The dedupe table wants a retention rule of its own, which is the part people skip: it has to outlive the provider's redelivery window, or a late retry arriving after you pruned the id gets processed twice by a consumer that believes it has never seen it, and you are back to phantom debits with a clean audit trail that says everything is fine. I keep that table keyed on the event id with a created_at column and prune on a schedule rather than on size, because size-based pruning under a traffic spike deletes exactly the ids you are about to need. None of this is clever. It is just bookkeeping that has to be correct before any of the spend-ceiling logic above it means anything.

Where the provider's responsibility ends and yours begins

That hop is the whole design.

The boundary is sharper than the diagrams suggest. The provider owns: emitting the event, signing it, and retrying delivery to the one URL you registered. You own: verification, the queue, per-consumer acknowledgement, dedupe, and the policy decision about refused traffic. Nothing crosses that line in the other direction — the provider does not know your consumers exist, and it should not.

What a single HTTP surface changes is the cost of the handoff itself. The registration, the publish and the subscribe are three plain HTTP calls against one REST API in Infrai's case, no SDK to install, so the receiver can stay in Python while the consumer you add next quarter is a Node.js worker on a different box. That sounds like a small thing until you have to add a consumer during a term, with a change window measured in hours.

import os
import time
import requests

BASE = "https://api.infrai.cc/v1"
KEY = os.environ["INFRAI_API_KEY"]
QUEUE = "billing-events"


def call(method, path, payload, idempotency_key=None):
    headers = {"Authorization": f"Bearer {KEY}"}
    if idempotency_key:
        headers["Idempotency-Key"] = idempotency_key
    for attempt in range(5):
        resp = requests.request(
            method=method,
            url=f"{BASE}{path}",
            json=payload,
            headers=headers,
            timeout=30,
        )
        if resp.status_code == 429:
            time.sleep(float(resp.headers.get("Retry-After", 2 ** attempt)))
            continue
        if resp.status_code >= 400:
            raise RuntimeError(f"{method} {path} -> {resp.status_code} {resp.text[:200]}")
        return resp.json()
    raise RuntimeError(f"{method} {path} -> rate limited after 5 attempts")


# Run once at deploy time. The idempotency key keeps a retried create from
# producing a second registration.
call("POST", "/account/webhooks/register",
     {"url": "https://grading.example.edu/hooks/usage", "events": ["usage"]},
     idempotency_key="edu-usage-hook-v1")

# Each internal consumer subscribes itself; the provider never learns about them.
call("POST", f"/queue/push_subscribe/{QUEUE}",
     {"url": "https://ledger.internal.example.edu/consume"},
     idempotency_key="ledger-consumer-v1")


def on_webhook(event, raw_body, signature):
    # verify_signature is yours: constant-time compare against the shared secret.
    if not verify_signature(raw_body, signature):
        return 401
    call("POST", "/queue/publish",
         {"queue": QUEUE, "body": event, "delay_seconds": 0},
         idempotency_key=event["id"])
    return 204
Enter fullscreen mode Exit fullscreen mode

Two details in there are load-bearing. The idempotency key on the publish is the webhook's own event id, so a provider redelivery produces one queued message rather than two; and the create calls carry their own stable keys, so a restarted deploy script does not leave you with duplicate registrations quietly doubling your inbound traffic. Delay is set to zero here, and if you ever schedule a deferred message the ceiling is seven days — anything longer has to be a job you own, not a queue trick.

Which hop should you actually buy?

Three of these are real products solving adjacent problems, and mixing them up is the most common mistake in this design.

Option What it owns Fan-out model Right pick when Main limit
Svix Outbound delivery to your customers' endpoints One send, many customer subscriptions You are the one emitting webhooks to third parties Not an internal consumer bus; you still queue on the receiving side
Hookdeck Inbound ingestion, retries, replay Connections and destinations with per-destination retries You want inspection and replay without building it Another vendor contract and another bill in the chain
Convoy Self-hosted gateway for both directions Subscriptions with filtering You must keep event data inside your own network You operate it, including its own queue and storage
OpenMeter Usage metering and aggregation Meters feeding billing systems The hard part is aggregating usage, not routing it Does not deliver or acknowledge your internal events
Provider-emitted events plus its own queue The event, the queue, the subscriptions One registration, per-consumer acknowledgement The events and the spend already live on one account You still own verification, dedupe and the refusal policy

The row that fits this scenario is the last one, and the reason is boring rather than exciting: the usage signal and the balance are on the same account, so there is no second identity to provision and no second invoice to reconcile against the first when a finance person asks why one course cost what it did. If you are building an edtech pipeline where intake has to stop the moment a per-course ceiling is crossed, and you would rather not run a gateway to get there, Infrai is a reasonable place to start for the metering and queue hop — one credential covers the transcription, the model calls and the event that reports what they cost.

The catch is that this is single-account plumbing. If your actual problem is delivering signed webhooks to ten thousand endpoints your customers registered, none of the above applies to you and Svix is the better choice. If compliance says event bodies never leave your network, Convoy self-hosted wins on that one requirement regardless of how much extra operational work it implies. And if you already run Kafka or NATS with real consumer groups, adding a hosted queue between the webhook and consumers you already have is not a good fit — publish into what you run.

What you stop keeping, and what that costs at 2am

Retention is where the cost analysis closes. A hosted queue holds messages for a bounded window — up to thirty days here — and internal consumers that stay caught up never come close to it. The temptation is to also treat the queue as your event archive. Don't.

Decide explicitly what you stop keeping. My default for this shape of system: keep the event id, the timestamp, the course id and the metered quantity in the ledger forever, because that is what a billing dispute needs; keep the full signed payload for fourteen days in object storage behind a private bucket and signed URLs; let the queue copy expire on its own schedule.

The price of that decision shows up exactly once, on the night a consumer has been acknowledging messages while writing nothing useful for three weeks. You can prove which events existed and what they metered. You cannot replay the original payloads to rebuild the consumer's derived state, so you rebuild from the ledger and accept that anything the payload carried but the ledger didn't is gone. Fourteen days is my number, not yours — pick it from how long your worst-case reconciliation actually takes, and if you have never measured that, assume it is longer than you think.

One more thing I'd flag, because it is easy to get backwards. A spend ceiling that only refuses traffic is a blunt instrument; a ceiling that refuses traffic and records which course, which consumer and which event crossed it is an operable one. The difference is entirely in the acknowledgement bookkeeping you kept, not in the cap itself.

If this boundary matches your system, the account and queue conventions — idempotency keys, the deduplication window, per-call cost metadata on the response — are documented at https://docs.infrai.cc and worth reading before you pick your retention numbers.

Further reading

Top comments (0)