# Billing webhooks

Signed webhooks for your credit balance, so your own systems can react before a call fails with [`INSUFFICIENT_CREDITS`](https://onekey.dev/docs/errors.md#insufficient-credits): top up, page someone, or degrade gracefully. They share the signing scheme of [monitors](https://onekey.dev/docs/monitors.md#verify-the-signature) and of [job webhooks](https://onekey.dev/docs/jobs.md#completion-webhook), 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](https://onekey.dev/docs/jobs.md) 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

```bash
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`](https://onekey.dev/docs/errors.md#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`:

```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](https://onekey.dev/docs/monitors.md#verify-the-signature).

## 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`](https://onekey.dev/docs/errors.md#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`](https://onekey.dev/docs/errors.md#billing-webhook-not-found) | Unknown id or another account's endpoint. |
| 409 | [`BILLING_WEBHOOK_LIMIT`](https://onekey.dev/docs/errors.md#billing-webhook-limit) | 5 endpoints already. |
| 409 | [`WEBHOOK_DISABLED`](https://onekey.dev/docs/errors.md#webhook-disabled) | Test send to a switched-off endpoint. |
| 422 | [`INVALID_BILLING_WEBHOOK`](https://onekey.dev/docs/errors.md#invalid-billing-webhook) | Unknown field, bad `events`, `low_threshold_credits` or `description`. |
| 422 | [`WEBHOOK_URL_REJECTED`](https://onekey.dev/docs/errors.md#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](https://onekey.dev/docs/monitors.md) and [Background jobs](https://onekey.dev/docs/jobs.md): the other signed webhooks.
- [Credits & pricing](https://onekey.dev/docs/credits.md) and [Per-key credit limits](https://onekey.dev/docs/key-limits.md).
- [Production checklist](https://onekey.dev/docs/production-checklist.md).
