Docs
View as Markdown

Billing webhooks

Signed webhooks for your credit balance, so your own systems can react before a call fails with INSUFFICIENT_CREDITS: top up, page someone, or degrade gracefully. They share the signing scheme of monitors and of job webhooks, so one verification routine covers all three.

Events#

EventFires when
credits.lowThe balance goes below your threshold (low_threshold_credits; 100 credits when you set none). Once per crossing: it fires again only after the balance has been back at or above the threshold.
credits.depletedThe balance goes below 1 credit, the minimum a request needs; from there every call is a 402. Once per crossing, re-armed the same way.
purchase.completedA credit pack payment was fulfilled. One event per payment: a payment provider retrying its own webhook does not send you a second one.

A balance that drops from well above the threshold to nothing in one step fires both credits.low and credits.depleted. A hold for a background job counts as a balance change. The balance that is compared is the one after a call settles, not the worst-case amount held while it runs, so a call that is refunded never causes an alert. An endpoint created while the balance is already below the threshold does not alert about a crossing that happened before it existed.

Create an endpoint#

shell
curl -X POST "https://api.onekey.dev/v1/billing/webhooks" \
  -H "x-api-key: YOUR_API_KEY" -H "content-type: application/json" \
  -d '{"url": "https://hooks.example.com/billing", "low_threshold_credits": 500,
       "events": ["credits.low", "credits.depleted", "purchase.completed"], "description": "prod"}'

201 with the endpoint and its signing secret, shown once (secret: store it now; rotate it to get a new one). events defaults to all three. At most 5 endpoints per account. url must be https on port 443 to a public host: private, loopback, link-local and cloud-metadata addresses, internal names, IP literals and URLs with credentials are refused with WEBHOOK_URL_REJECTED, at creation and again at delivery, and redirects are never followed.

RouteWhat
POST /v1/billing/webhooksCreate (secret shown once).
GET /v1/billing/webhooks, GET /v1/billing/webhooks/{id}List, read.
PATCH /v1/billing/webhooks/{id}Change url (also re-enables a switched-off endpoint), events, low_threshold_credits, description.
DELETE /v1/billing/webhooks/{id}Delete; pending deliveries are dropped.
POST /v1/billing/webhooks/{id}/testSend a signed test event now (one attempt, no retries). A body of {"event": "credits.low"} previews that event's shape, marked "test": true.
POST /v1/billing/webhooks/{id}/rotateNew signing secret, shown once.
GET /v1/billing/webhooks/{id}/deliveriesThe recent deliveries with every attempt.

All of these are free. The dashboard uses the same service under /v1/dashboard/billing-webhooks.

Payloads#

Every event has id (the delivery id), type, created_at and api_version, plus data:

json
{"id": "dlv_...", "type": "credits.low", "created_at": "2026-10-02T10:00:00+00:00", "api_version": "v1",
 "data": {"balance_credits": 480.0, "threshold_credits": 500.0}}

{"id": "dlv_...", "type": "credits.depleted", "created_at": "...", "api_version": "v1",
 "data": {"balance_credits": 0.5, "minimum_credits": 1.0}}

{"id": "dlv_...", "type": "purchase.completed", "created_at": "...", "api_version": "v1",
 "data": {"credits_granted": 5000.0, "balance_credits": 5998.0, "payment_id": "pay_..."}}

Headers: Content-Type: application/json, User-Agent: Onekey-Monitors/1.0, Onekey-Event, Onekey-Webhook-Id (the endpoint), Onekey-Delivery-Id (the same on every retry) and Onekey-Signature.

Verify every delivery#

Onekey-Signature: t=<unix seconds>,v1=<hex> with v1 = HMAC-SHA256(secret, "<t>." + raw body). Recompute over the raw bytes, compare in constant time, and refuse a t more than 300 seconds from your clock. Each retry is signed afresh. Verifiers in Python, Node and shell are on the monitors page.

Delivery, retries and switching off#

Any 2xx answer within 5 seconds is a success; a 3xx is a failure (redirects are not followed). A failed attempt is retried after 1 m, 5 m, 30 m, 2 h and 12 h (each ±20 %): at most 6 attempts per event. After 10 consecutive failed attempts on an endpoint it is switched off (disabled: true), pending deliveries are dropped, and the test route answers WEBHOOK_DISABLED. Saving a url with PATCH (the same one is fine) switches it back on. Ordering across events is not guaranteed; use created_at and de-duplicate on Onekey-Delivery-Id.

Errors#

Statuserror.typeWhen
404BILLING_WEBHOOK_NOT_FOUNDUnknown id or another account's endpoint.
409BILLING_WEBHOOK_LIMIT5 endpoints already.
409WEBHOOK_DISABLEDTest send to a switched-off endpoint.
422INVALID_BILLING_WEBHOOKUnknown field, bad events, low_threshold_credits or description.
422WEBHOOK_URL_REJECTEDThe URL failed the egress rules.

Not built: an email alert, automatic top-up events and a per-account default threshold that scales with your last pack. credits.low uses the threshold you set on the endpoint.