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#
| Event | Fires when |
|---|---|
credits.low | The 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.depleted | The 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.completed | A 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#
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.
| Route | What |
|---|---|
POST /v1/billing/webhooks | Create (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}/test | Send 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}/rotate | New signing secret, shown once. |
GET /v1/billing/webhooks/{id}/deliveries | The 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:
{"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#
| Status | error.type | When |
|---|---|---|
| 404 | BILLING_WEBHOOK_NOT_FOUND | Unknown id or another account's endpoint. |
| 409 | BILLING_WEBHOOK_LIMIT | 5 endpoints already. |
| 409 | WEBHOOK_DISABLED | Test send to a switched-off endpoint. |
| 422 | INVALID_BILLING_WEBHOOK | Unknown field, bad events, low_threshold_credits or description. |
| 422 | WEBHOOK_URL_REJECTED | The 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.
Related#
- Monitors and Background jobs: the other signed webhooks.
- Credits & pricing and Per-key credit limits.
- Production checklist.