Webhooks
Webhooks let Meterry notify your platform when billing state changes, such as an account wallet becoming insufficient or a usage limit being exhausted or recovered.
Signing secret
Section titled “Signing secret”Each webhook endpoint has a signing secret. Meterry does not send the secret itself in the webhook request. Instead, it uses the secret to calculate an HMAC signature over the exact request body and a timestamp, then sends the result in request headers.
When Meterry delivers a webhook, the request includes these headers:
Content-Type: application/jsonUser-Agent: edgefn-billing-webhooks/1.0X-Billing-Webhook-ID: wh_evt_...X-Billing-Webhook-Delivery-ID: whd_...X-Billing-Webhook-Timestamp: 1783562400X-Billing-Webhook-Signature: v1=<hex-encoded-hmac-sha256>The signature payload is:
<timestamp>.<raw request body>The signature algorithm is HMAC-SHA256 using the endpoint signing secret as the key. The header value is prefixed with v1=.
Verify a request
Section titled “Verify a request”Your receiver should verify the signature before processing the webhook:
- Read
X-Billing-Webhook-Timestamp. - Read the raw request body before JSON parsing.
- Build the signing payload as
<timestamp>.<raw request body>. - Compute
HMAC-SHA256(payload, signing_secret). - Compare it to the
v1=value inX-Billing-Webhook-Signaturewith a constant-time comparison. - Reject stale timestamps to reduce replay risk.
Example in Node.js:
import { createHmac, timingSafeEqual } from "node:crypto"
function verifyBillingWebhook({ secret, timestamp, signature, rawBody,}: { secret: string timestamp: string signature: string rawBody: Buffer}) { const received = signature.startsWith("v1=") ? signature.slice("v1=".length) : "" const expected = createHmac("sha256", secret) .update(`${timestamp}.${rawBody.toString("utf8")}`) .digest("hex")
return ( received.length === expected.length && timingSafeEqual(Buffer.from(received), Buffer.from(expected)) )}Store the signing secret in your backend environment. Do not put it in browser code or client applications.
Payload shape
Section titled “Payload shape”Webhook requests are sent as POST JSON:
{ "id": "wh_evt_...", "event_type": "usage_limit.exhausted", "created_at": 1783562400, "data": { "event_type": "usage_limit.exhausted", "tenant_id": "tenant_...", "project_id": "proj_...", "account": { "id": "acct_..." }, "subject": { "type": "user", "id": "user_123" }, "usage": { "usage_version": 12345, "usage_event_id": "ue_...", "raw_event_id": "raw_..." } }}Respond with any 2xx status after accepting the event. Meterry retries timeout, conflict, too-early, rate-limit, and server-error responses.
For usage_limit.exhausted and usage_limit.recovered, the limit object mirrors the realtime usage-limit snapshot and uses next_reset_at for the current window end.
Task budget exhaustion
Section titled “Task budget exhaustion”Subscribe to budget_lease.exhausted to receive every new rated usage version that leaves a Budget Lease at or above its amount cap. This is separate from usage_limit.exhausted, which remains for durable subject-route range limits; a replay of the same version is deduplicated.
The event data includes budget_lease.id, budget_lease.run_id, and an amount limit with period and window_id both set to lifetime. Use the run ID to durably block and cancel the task.
Usage limit recovery
Section titled “Usage limit recovery”Subscribe to usage_limit.recovered when your system needs to reopen access after a limit window resets.
The payload mirrors the original limit snapshot and adds a recovery object:
{ "id": "wh_evt_...", "event_type": "usage_limit.recovered", "created_at": 1783566000, "data": { "event_type": "usage_limit.recovered", "tenant_id": "tenant_...", "project_id": "proj_...", "recovery": { "reason": "window_reset", "recovered_at": 1783566000 } }}Use usage_limit.exhausted to close or throttle access, and usage_limit.recovered to restore it when the limit window naturally reopens.
Available balance thresholds
Section titled “Available balance thresholds”Subscribe an endpoint to wallet.available_balance.threshold_crossed and configure one or more account and currency thresholds in event_configs:
{ "url": "https://example.com/billing-webhooks", "event_types": ["wallet.available_balance.threshold_crossed"], "event_configs": { "wallet.available_balance.threshold_crossed": [ { "account_id": "acct_...", "currency": "USD", "operator": "lt", "threshold": "100.00" } ] }}Operators are edge-triggered: lt fires when the balance moves from >= threshold to < threshold; lte from > threshold to <= threshold; gt from <= threshold to > threshold; and gte from < threshold to >= threshold. Remaining on the same side of the threshold does not emit another event.
The event data includes operation, direction, the threshold config, and the wallet’s before and after balance snapshots. Each threshold event is delivered only to the endpoint that owns its configuration.