# blockwallets native REST API

> The blockwallets native REST API: Bearer keys, invoices in US cents with idempotent refs, deposit accounts, HMAC-SHA256 signed webhooks, events and redelivery.
>
> HTML: https://blockwallets.io/docs/api?lang=en · 中文: https://blockwallets.io/docs/api.md

## Native REST API

The native API is blockwallets’ own REST interface: invoices priced in US cents, an idempotent `ref` per order, static deposit accounts covering every network, and webhooks signed with HMAC-SHA256. It creates the same bills on the same hosted checkout as the [Cryptomus-compatible API](https://blockwallets.io/docs/cryptomus?lang=en), with the same fees and balance.

Generate your keys yourself in the dashboard under “Developers › Integration & keys”: a Live and a Test key plus their webhook signing secrets. Each is shown once; the list only shows its prefix. If your account has two-factor authentication on, generating or rotating a Live key asks for a code. No merchant account yet? [Request access](https://blockwallets.io/?lang=en#join). Already on Cryptomus? See the [Cryptomus-compatible API](https://blockwallets.io/docs/cryptomus?lang=en) ([how to choose](https://blockwallets.io/docs?lang=en#which)).

### Keys and requests

Base URL `https://blockwallets.io`. Requests and responses are JSON. Every request carries:

- `Authorization: Bearer bw_live_…` or `Bearer bw_test_…`: the prefix decides the mode, so a Test key only sees Test invoices and runs on testnets

- `Content-Type: application/json` on `POST` requests

- Keep keys on your server only, never in a web page or app; we store only their hash

Errors return an HTTP status and `{"error": {"code": "…", "message": "…"}}`: 400 `invalid_request` (a missing or malformed field), 401 `unauthorized` (missing, unknown or disabled key), 404 `not_found`, 409 `conflict` (the request clashes with the invoice’s current state), 422 `currency_unsupported`.

### Endpoints

| Endpoint | What it does |
| --- | --- |
| `GET /v1/networks` | Networks you can accept right now in this key’s mode: minimum, confirmations, typical arrival time and fee estimate; priced coins include the current rate |
| `POST /v1/invoices` | Create an invoice (201), or return the existing one for the same `ref` (200) |
| `GET /v1/invoices?ref=…` | Look up an invoice by your `ref` |
| `GET /v1/invoices/:id` | Look up an invoice by id |
| `POST /v1/invoices/:id/network` | Pick the network for an invoice yourself (white-label checkout): returns the `address` and `pay_amount` to show |
| `POST /v1/invoices/:id/close` | Close a pending invoice that has not seen any transfer |
| `POST /v1/deposit-accounts` | Open (201) or fetch (200) the deposit account of one customer: a permanent address on every network |
| `GET /v1/deposit-accounts?customer_ref=…` | Look up a deposit account by `customer_ref` |
| `GET /v1/deposit-accounts/:id` | Look up a deposit account by id |
| `GET /v1/deposits` | List the deposits of one account (`account_id` or `customer_ref`, `limit` 1–200, default 50) |
| `GET /v1/deposits/:id` | Look up one deposit |
| `GET /v1/events` | Webhook deliveries for one invoice or deposit (`invoice_id` or `deposit_id`) |
| `POST /v1/events/:id/redeliver` | Send a webhook again (202) |

Add `?probe=1` to an invoice or deposit-account lookup to check the chain on the way (at most once every 10 seconds); otherwise you get the latest state from our scanner, which checks every minute.

### Create an invoice POST /v1/invoices

Call `POST /v1/invoices` when the customer checks out and redirect them to `pay_url` in the response.

```curl
curl -X POST https://blockwallets.io/v1/invoices \
  -H "Authorization: Bearer $BW_KEY" -H "Content-Type: application/json" \
  -d '{"ref":"order_1042","amount":4200,"title":"Order #1042",
       "return_url":"https://shop.example/cart","success_url":"https://shop.example/thanks?order=1042",
       "meta":{"user_id":42}}'
```

| Field | Required | Description |
| --- | --- | --- |
| `ref` | Yes | Your order number: 1–64 characters of letters, digits and `_ . : -`. Unique per mode; sending the same `ref` with the same fields again returns the same invoice, with different fields it returns 409 |
| `amount` | Yes | Amount in US cents as an integer: `4200` = $42.00. Not below the network minimum once a network is chosen |
| `title` | Yes | What the customer sees on the checkout, up to 64 characters |
| `return_url` | Yes | Where “Back” on the checkout goes; must be on one of your website domains |
| `success_url` | No | Where the customer goes after paying; defaults to `return_url` |
| `network` | No | Preselect a network id from the [network table](https://blockwallets.io/docs/api?lang=en#networks); omit it to let the customer choose on the checkout |
| `customer_ref` | No | Your customer id, up to 64 characters, returned as is |
| `lifetime` | No | Lifetime in seconds, 600 – 43200, default 3600 |
| `amount_rule` | No | `"any"` = credit whatever arrives (top-ups): `paid` as soon as it confirms. Omit it to use the “Amount rule” under “Settings › Payment rules & fees” in the dashboard |
| `accuracy_percent` | No | How much underpayment still counts as paid, 0 – 5 (percent), default 0 |
| `subtract_percent` | No | Share of the platform fee the customer pays, 0 – 100 (percent), added to `pay_amount`; omit it to use the dashboard default |
| `payment_multiple` | No | Whether a short payment can be topped up to the same address, default from the dashboard (on unless you changed it); `false`: the first short transfer ends as `wrong_amount` |
| `meta` | No | Any JSON object up to 2 KB, returned as is in lookups and webhooks |
| `currency` | No | Only `USD` (the default); anything else returns 422 |

Every URL must be on one of your website domains (dashboard → “Developers › Integration & keys”). The response is the invoice object below; `network`, `address` and `pay_amount` are filled in once a network is picked.

### The invoice object

Ids start with `inv_`. `amount` is in US cents; `pay_amount` and `paid_amount` are coin amounts as decimal strings on the chosen network.

```json
{
  "id": "inv_01J8…",
  "ref": "order_1042",
  "mode": "live",
  "status": "pending",
  "amount": 4200,
  "currency": "USD",
  "title": "Order #1042",
  "meta": { "user_id": 42 },
  "network": null,
  "address": null,
  "pay_amount": null,
  "paid_amount": null,
  "payments": [],
  "pay_url": "https://blockwallets.io/pay/inv_01J8…",
  "return_url": "https://shop.example/cart",
  "success_url": "https://shop.example/thanks?order=1042",
  "created_at": "2026-11-03T08:00:00.000Z",
  "expires_at": "2026-11-03T09:00:00.000Z",
  "paid_at": null,
  "closed_at": null
}
```

| status | Meaning | What you do |
| --- | --- | --- |
| `pending` | Awaiting payment | — |
| `confirming` | A transfer was seen and is confirming, or it arrived short and can still be topped up | — |
| `paid` | Paid | Ship / credit the order |
| `paid_over` | Paid, and overpaid | Same; the extra is in your balance |
| `wrong_amount` | Ended with less than the full amount (at expiry, or at the first short transfer when `payment_multiple` is false) | Top-up businesses can credit what arrived (`paid_amount`; priced coins also give `paid_cents`); shops ask the customer to pay the rest |
| `closed` | Expired with nothing received, or you closed it | — (money that arrives later is still credited and notified) |

Other fields: `pay_url` (the hosted checkout), `payments` (every transfer with `txid`, `amount`, `confirmations` and `confirmed`), `expires_at`, `paid_at`, `closed_at`; BTC, LTC, DOGE, BCH and native coins also carry `rate` (US dollars per coin, locked for 30 minutes), `rate_locked_until` and `paid_cents` (what has arrived so far, in US cents).

### Networks

Pass the id as `network`. `GET /v1/networks` returns the ones open right now in your mode; a network can be paused, so prefer reading it over hard-coding.

| network | Coin | Chain | Minimum |
| --- | --- | --- | --- |
| `USDT_TRC20` | USDT | TRON | $1 |
| `USDT_BEP20` | USDT | BNB Chain | $1 |
| `USDC_BEP20` | USDC | BNB Chain | $1 |
| `USDT_ERC20` | USDT | Ethereum | $5 |
| `USDC_ERC20` | USDC | Ethereum | $5 |
| `USDC_BASE` | USDC | Base | $1 |
| `USDT_POLYGON` | USDT | Polygon | $1 |
| `USDC_POLYGON` | USDC | Polygon | $1 |
| `USDT_ARBITRUM` | USDT | Arbitrum | $1 |
| `USDC_ARBITRUM` | USDC | Arbitrum | $1 |
| `USDT_OPTIMISM` | USDT | Optimism | $1 |
| `USDC_OPTIMISM` | USDC | Optimism | $1 |
| `USDT_AVALANCHE` | USDT | Avalanche | $1 |
| `USDC_AVALANCHE` | USDC | Avalanche | $1 |
| `USDT_XLAYER` | USDT | X Layer | $1 |
| `USDC_XLAYER` | USDC | X Layer | $1 |
| `USD1_BEP20` | USD1 | BNB Chain | $1 |
| `FDUSD_BEP20` | FDUSD | BNB Chain | $1 |
| `DAI_ERC20` | DAI | Ethereum | $5 |
| `DAI_ARBITRUM` | DAI | Arbitrum | $1 |
| `DAI_BASE` | DAI | Base | $1 |
| `BTC` | BTC | Bitcoin | $5 |
| `LTC` | LTC | Litecoin | $1 |
| `DOGE` | DOGE | Dogecoin | $1 |
| `BCH` | BCH | Bitcoin Cash | $1 |
| `TRX` | TRX | TRON | $1 |
| `BNB` | BNB | BNB Chain | $1 |
| `ETH` | ETH | Ethereum | $5 |
| `ETH_BASE` | ETH | Base | $1 |
| `ETH_ARBITRUM` | ETH | Arbitrum | $1 |
| `ETH_OPTIMISM` | ETH | Optimism | $1 |
| `POL` | POL | Polygon | $1 |
| `AVAX` | AVAX | Avalanche | $1 |
| `OKB` | OKB | X Layer | $1 |
| `USDT_SOL` | USDT | Solana | $1 |
| `USDC_SOL` | USDC | Solana | $1 |
| `SOL` | SOL | Solana | $1 |
| `USDT_TON` | USDT | TON | $1 |
| `TON` | TON | TON | $1 |

### Deposit accounts (static addresses) POST /v1/deposit-accounts

`POST /v1/deposit-accounts` with `{"customer_ref": "user_42"}` gives that customer a permanent address on every network (all EVM chains share one address). Whatever they send is credited in US dollars, with one `deposit.credited` webhook per transfer.

```curl
curl -X POST https://blockwallets.io/v1/deposit-accounts \
  -H "Authorization: Bearer $BW_KEY" -H "Content-Type: application/json" \
  -d '{"customer_ref":"user_42"}'
```

```json
{
  "id": "dac_01J8…",
  "mode": "live",
  "customer_ref": "user_42",
  "deposit_url": "https://blockwallets.io/deposit/dac_01J8…",
  "min_amount": 100,
  "currency": "USD",
  "networks": [
    { "id": "USDT_TRC20", "chain": "tron", "token": "USDT", "address": "TXy4…8kQ2", "min_amount": 100, "confirmations": 1, … },
    { "id": "USDC_BASE", "chain": "base", "token": "USDC", "address": "0x5aE1…", "min_amount": 100, "confirmations": 20, … }
  ]
}
```

- `customer_ref`: 1–128 characters of letters, digits and `_ . : @ -`; the same value always returns the same account. Optional `return_url` on your domains

- The response has `deposit_url` (a hosted page with the addresses, QR codes and recent deposits) and `networks` (`id`, `address`, `min_amount`, `confirmations` for each)

- Deposit ids start with `dep_`; `amount` is US cents, `amount_token` the coin amount, and priced coins carry the `rate` used

Transfers below the minimum (usually $1, BTC $5) are recorded as `below_min` and not credited automatically; contact us to have them credited.

### Webhooks

We POST JSON to the webhook URL in the dashboard (“Developers › Integration & keys”; Live and Test each have their own URL and secret). The body is the event:

```http
POST /your/webhook
X-BW-Event: ev_01J8…
X-BW-Delivery: 1
X-BW-Signature: t=1793693052,v1=5f0c9b…

{"id":"ev_01J8…","type":"invoice.paid","created_at":"2026-11-03T08:04:12.000Z","invoice":{"id":"inv_01J8…","ref":"order_1042","status":"paid","amount":4200,…}}
```

- `X-BW-Signature`: `t=<unix seconds>,v1=<hex>`, where `v1` = HMAC-SHA256(webhook secret of that mode, `t` + `.` + raw body)

- `X-BW-Event`: the event id (`ev_…`); deduplicate by it

- `X-BW-Delivery`: the attempt number, starting at 1

| Event | When |
| --- | --- |
| `invoice.paid` | The invoice became `paid` or `paid_over` (including money that arrived after expiry) |
| `invoice.wrong_amount` | The invoice ended as `wrong_amount` |
| `invoice.closed` | The invoice expired with nothing received, or you closed it |
| `deposit.credited` | A transfer to a deposit account was credited |

Verify against the raw body before parsing it, reject a `t` more than 300 seconds from your clock, and compare in constant time. `invoice` / `deposit` in the body is the same object a lookup returns.

```nodejs
import crypto from "node:crypto";

// rawBody: the request body exactly as received (a string), header: X-BW-Signature
function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=")));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return typeof parts.v1 === "string" && parts.v1.length === expected.length
    && crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
}
```

A 2xx response counts as delivered; otherwise we retry after 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours, 24 hours and 24 hours (8 attempts in total).

### Events and redelivery

`GET /v1/events?invoice_id=inv_…` (or `deposit_id=dep_…`) lists the webhooks for one invoice or deposit with their `state`, `attempts`, `last_status` and `last_error`. `POST /v1/events/ev_…/redeliver` queues it again right away, for example after you fixed your endpoint. The dashboard’s “Developers › Webhook log” shows every attempt with request and response.

### Test mode

Invoices created with a `bw_test_` key run on testnets and never touch real money. The hosted checkout shows a “Simulate payment” button (full / half / overpay), so you can watch statuses and webhooks without testnet coins.
