Skip to content

Rate-Limit and Quota Webhook Notifications

The notifications: section pushes the rate-limit and quota refusals the router already makes to application-owned webhooks. The audience is the application behind an API key, not the operator; operator alerting is the separate alerting: section (issue #2356). The feature is opt-in and disabled by default (issue #2416).

Scope

When enabled, the router sends a signed, bounded JSON event to each configured target for these events:

  • rate_limit_exceeded: a local rate-limit or budget refusal, including the Hub tier minute limits.
  • quota_low: the first crossing of quota_low_fraction on a Hub-synced monthly budget.
  • quota_exhausted: a spent Hub monthly budget or a spent local key token budget.

Delivery runs entirely off the request path. Producers gate on an atomic enable mask, coalesce repeats, and perform one non-blocking try_send per matching target. No producer awaits and no producer touches the network, so a slow or failing receiver can never delay or fail a request.

With no notifications: section, or with enabled: false, no task is spawned, no event is queued, and no request is made. Enabling the section with no target is allowed and inert, and startup says so once.

Configuration

notifications:
  enabled: true
  targets:
    - name: "app-owner"
      url: "https://hooks.example.com/continuum"
      signing_secret: "${WEBHOOK_SIGNING_SECRET}"
      events: [rate_limit_exceeded, quota_low, quota_exhausted]
      keys: []
      timeout: "5s"
      allow_private_network: false
  quota_low_fraction: 0.9
  cooldown: "1m"
  max_queue: 256
  retry:
    max_attempts: 3
    initial_delay: "500ms"
    max_delay: "30s"

The section is validated whether or not it is enabled, so a malformed value fails at load time instead of at enable time. Reload class: immediate for the section and its targets.

Field Type Default Notes
enabled bool false Master switch.
targets[].name string absent Metrics label, 1 to 64 printable ASCII characters. Duplicates are allowed because the name is only a label.
targets[].url string required https only, no embedded credentials, SSRF-checked. Masked in configuration output, because the path may carry a receiver token.
targets[].signing_secret string absent HMAC-SHA256 key. Masked in configuration output and never logged. ${ENV_VAR} is interpolated by the loader.
targets[].events array of enum all three rate_limit_exceeded, quota_low, quota_exhausted. An unknown name is a configuration error.
targets[].keys array of string [] Key-id allow-list, each 1 to 128 bytes. Empty matches every key; a non-empty list receives only attributed events for a listed key.
targets[].timeout duration "5s" Bound on one delivery attempt.
targets[].allow_private_network bool false Lifts the loopback and private-address blocklist for an in-cluster receiver. https is still required.
quota_low_fraction float, 0 to 1 0.9 0 disables quota_low.
cooldown duration "1m" Per event and subject window; repeats are counted, not sent.
max_queue integer, 1 to 65536 256 Bounded queue per target. A full queue drops the event for that target and counts it.
retry.max_attempts integer, 1 to 10 3 Attempts per event and target, including the first.
retry.initial_delay duration "500ms" First backoff delay.
retry.max_delay duration "30s" Cap on backoff growth; must be at least initial_delay.

The quota_low_fraction value is a fraction, not a percentage: 0.9 means 90 percent of the monthly budget.

Event sources

Event Source Notes
rate_limit_exceeded Local rate limiter (rate_limit_v2) generic 429 Covers request-rate, token-budget, in-flight, and capacity refusals. The payload names the limit type and Retry-After. A shared bucket's level is withheld exactly as the response withholds it.
rate_limit_exceeded Hub tier minute limits (control_plane::enforcement) rpm, input-tpm, and output-tpm.
quota_exhausted Local scoped budget engine Emitted for an exhausted budgets: scope/window.
quota_exhausted Legacy api_keys[].max_tokens_budget Emitted alongside the scoped engine when both observe the key; the cooldown coalesces them.
quota_exhausted Hub tier monthly limits monthly-tokens and monthly-requests.
quota_exhausted Hub cost-center and model-budget refusals Emitted with the scope and the dimension or model name, without numbers.
quota_low Hub monthly budget check Emitted once per key and calendar month at the first crossing of quota_low_fraction. The notifier cooldown is a second layer, not the primary guard.

A StoreUnavailable or TrackerFull refusal is an internal failure, not the caller's quota, so it is not a notification event. A 429 proxied from a backend is the provider's quota, not a router event, and is never emitted.

Delivery contract

Each target owns a bounded queue (max_queue) and one delivery task. A dead endpoint therefore cannot head-of-line-block another target.

  • A producer computes one deduplication key per event and subject, "{event}:{key_id-or-unattributed}". Inside the cooldown window the occurrence is counted in notification_coalesced_total and not sent; the first event after the window carries the suppressed count. The cooldown map holds one entry per event and subject; expired entries are pruned once the map passes a soft size, but there is no hard cap, so its memory follows the number of distinct keys seen inside one window.
  • One event is offered to every target that subscribes to its class and whose keys filter matches. An empty filter matches every key; an event with no identified key reaches only targets with an empty filter.
  • A full queue drops the event for that target and counts notification_dropped_total{target}. It never blocks the producer.
  • Each attempt is bounded by the target timeout. A non-2xx status or a transport error retries with exponential backoff capped by retry.max_delay, up to retry.max_attempts. A 2xx ends the event.
  • Settling a reload replaces the target list. Events already queued keep the target that accepted them; the previous tasks drain and exit. On shutdown a task stops starting new deliveries after five seconds; a delivery already in flight may finish after that, bounded by its own timeout and retry budget.
  • The event id is generated once and is stable across retries, so a receiver can deduplicate.

Payload

{
  "id": "router-1-67c2f0a1-3",
  "event": "rate_limit_exceeded",
  "occurred_at": "2026-11-03T10:00:00Z",
  "router_id": "router-1",
  "subject": {"key_id": "key-42"},
  "detail": {
    "limit_type": "rpm",
    "retry_after_seconds": 30,
    "limit": 600,
    "remaining": 0,
    "window": "minute"
  },
  "suppressed": 0
}

subject.key_id is the key identifier and is omitted when the key is unknown. It is never the key value. detail carries only static event and limit names and bounded numbers; every number is capped at half of u64::MAX, every free-form string is capped at 64 bytes (or replaced with unknown), and the key id is capped at 128 bytes. No API key material, prompt or completion text, hub token, or request header is ever included.

Inside detail, dimension names the local token or in-flight dimension that refused (api_key, global, user, team, ...) when the producer knows it, and window names the budget period (minute, day, monthly). They are separate fields, so window never carries a dimension name.

Signature

With signing_secret set, every delivery carries these headers:

Content-Type: application/json
X-Continuum-Event: rate_limit_exceeded
X-Continuum-Delivery: router-1-67c2f0a1-3
X-Continuum-Timestamp: 1793709600
X-Continuum-Signature: sha256=<lowercase hex HMAC-SHA256(secret, "{timestamp}.{body}")>

The body is serialized once to bytes, signed, and sent unchanged, so a receiver verifies exactly what was transmitted. A receiver should:

  1. Read X-Continuum-Timestamp and reject a value older than its tolerance window.
  2. Recompute HMAC-SHA256 over "{timestamp}.{raw_body}" with the shared secret.
  3. Compare the hex digest with the sha256= value in constant time.
import hashlib
import hmac
import time


def verify(secret: bytes, timestamp: str, raw_body: bytes, header: str, tolerance: int) -> bool:
    if abs(int(time.time()) - int(timestamp)) > tolerance:
        return False
    expected = "sha256=" + hmac.new(
        secret, timestamp.encode() + b"." + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, header)

Without signing_secret, no timestamp or signature header is sent; the payload is still the bounded envelope above.

Security

Targets are validated at configuration load and again when they are applied, including on reload.

  • url must use https and must not embed credentials.
  • The strict validator rejects loopback, private, link-local, cloud metadata, and other non-public addresses. A host name that does not resolve is a configuration error.
  • allow_private_network: true lifts the address blocklist for an in-cluster receiver; the scheme and shape checks still apply.
  • The addresses resolved during validation are pinned for the connection, closing the gap between resolution and connection. Redirects are disabled, and each attempt has a timeout.
  • The signing secret is a credential. It is masked in configuration export and diff, never logged, and may be written as ${ENV_VAR}. The target URL is masked too, because the path may carry a receiver token.

Metrics

Metric Labels Meaning
notification_deliveries_total event, target, result One row per delivery attempt. result is delivered or failed.
notification_retries_total event, target Attempts after the first.
notification_queue_depth target Current queued events per target.
notification_dropped_total target Events dropped because a target queue was full.
notification_coalesced_total event Occurrences suppressed by the cooldown.

Labels are configuration-bounded: event names are a closed set, and target is the validated name, else the URL host, else default.

Exclusions

  • Per-key self-registration of endpoints through the Admin API or the Hub is not offered. Targets come from the static configuration file, applied at startup or on hot reload.
  • The payload does not share the Hub usage-record schema; it is a separate, lighter envelope.
  • The local budgets: scope engine has no low-water warning. quota_low is emitted for Hub-synced monthly budgets only, because that is the only place a low-water mark is computed once per window.
  • Backend and provider 429s and 5xx responses are not notification events.