<!-- Source: https://www.wolvpay.com/docs | OpenAPI: https://www.wolvpay.com/openapi.json | Updated: 27/10/2026 -->

# WolvPay API Documentation

Accept cryptocurrency with a REST API: create an invoice, send the customer to pay, and get a signed webhook when the payment is confirmed. Funds go straight to your own wallet.

- **Base URL:** `https://wolvpay.com/api/v1`
- **Request & Response Format:** `application/json`
- **Authentication:** `Authorization: Bearer <api_key>`

> **Using an AI coding agent?** Paste [/docs.md](https://www.wolvpay.com/docs.md) or [/openapi.json](https://www.wolvpay.com/openapi.json) into your assistant, or press **Copy for AI** in the header. See [AI & Coding Agents](#agents).

## Quickstart

The full integration is three steps. Everything below is copy-paste runnable once you set two environment variables.

| Variable | Where to get it | Used for |
| --- | --- | --- |
| `WOLVPAY_API_KEY` | Dashboard → API Keys | Calling the API (create / read invoices). |
| `WOLVPAY_WEBHOOK_SECRET` | Dashboard → Webhooks (shown when you create the endpoint) | Verifying the `X-WolvPay-Signature` header on incoming webhooks. |

#### 1. Create an invoice

Store the returned `invoice_id` next to your order, then send the customer to `data.url`.

**cURL**

```bash
curl -X POST https://wolvpay.com/api/v1/invoices \
  -H "Authorization: Bearer $WOLVPAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 25.00,
    "currency": "USD",
    "description": "Order #1234",
    "white_label": false,
    "redirect_url": "https://example.com/thanks"
  }'
```

**Node.js**

```javascript
const res = await fetch('https://wolvpay.com/api/v1/invoices', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.WOLVPAY_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    amount: 25.0,
    currency: 'USD',
    description: 'Order #1234',
    white_label: false,
    redirect_url: 'https://example.com/thanks',
  }),
});

const body = await res.json();
if (!body.success) throw new Error(`${body.error}: ${body.message}`);

const { invoice_id, url } = body.data; // save invoice_id with your order, redirect the customer to url
```

**Python**

```python
import os, requests

res = requests.post(
    "https://wolvpay.com/api/v1/invoices",
    headers={"Authorization": f"Bearer {os.environ['WOLVPAY_API_KEY']}"},
    json={
        "amount": 25.00,
        "currency": "USD",
        "description": "Order #1234",
        "white_label": False,
        "redirect_url": "https://example.com/thanks",
    },
    timeout=15,
)
body = res.json()
if not body.get("success"):
    raise RuntimeError(f"{body.get('error')}: {body.get('message')}")

invoice_id = body["data"]["invoice_id"]  # save with your order
pay_url = body["data"]["url"]            # redirect the customer here
```

**PHP**

```php
$ch = curl_init('https://wolvpay.com/api/v1/invoices');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_TIMEOUT        => 15,
    CURLOPT_HTTPHEADER     => [
        'Content-Type: application/json',
        'Authorization: Bearer ' . getenv('WOLVPAY_API_KEY'),
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'amount'       => 25.00,
        'currency'     => 'USD',
        'description'  => 'Order #1234',
        'white_label'  => false,
        'redirect_url' => 'https://example.com/thanks',
    ]),
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);

if (empty($body['success'])) {
    throw new RuntimeException(($body['error'] ?? 'Error') . ': ' . ($body['message'] ?? ''));
}

$invoiceId = $body['data']['invoice_id']; // save with your order
$payUrl    = $body['data']['url'];        // redirect the customer here
```

#### 2. Receive the webhook

Create a webhook endpoint in the dashboard. When the customer pays, WolvPay sends a signed `POST` to it. [Verify the signature](#webhook-security), then act on the `status` field. A complete handler is in [Recommended Handler](#webhook-handler).

#### 3. Fulfill on `PAID` only

Mark the order paid only when the status is `PAID`, and make that step idempotent — the same event can be delivered more than once.

> **No sandbox:** WolvPay has no separate test environment or test API keys. Your API key always talks to the live service. See [Testing & Sandbox](#testing) for how to test safely without spending money.

## Authentication

The WolvPay API uses API keys to authenticate requests. Create and manage keys in the [dashboard](https://www.wolvpay.com/api_keys). Keys carry full access to your account’s invoices, so keep them secret: never commit them to Git, ship them in client-side code, or paste them into chat logs.

Send the key as a Bearer token on every request:

**Header**

```http
Authorization: Bearer <your-api-key>
```

**cURL**

```bash
curl https://wolvpay.com/api/v1/coins \
  -H "Authorization: Bearer $WOLVPAY_API_KEY"
```

A missing or invalid key returns `401` with `{"success": false, "error": "Unauthorized", ...}`.

## Coins

The Coins API returns the cryptocurrencies WolvPay supports, their minimum payable amounts and live prices.

### Get Supported Coins

Returns every supported cryptocurrency with its current exchange rate in each supported fiat currency.

`GET /api/v1/coins`

#### Response

**Response**

```json
{
  "success": true,
  "result": "Coins retrieved successfully",
  "code": 200,
  "data": [
    {
      "coin": "btc",
      "name": "Bitcoin",
      "logo": "assets/images/coins/btc.png",
      "minimum_transaction_coin": "0.000080000000000000",
      "prices": {
        "EUR": "93057.5144720163",
        "USD": "105649.8096322775"
        // ... one entry per supported fiat currency
      }
    }
    // ... one object per coin
  ]
}
```

**Error Response**

```json
{
  "success": false,
  "error": "Not Found",
  "code": 404,
  "message": "No coins found."
}
```

#### Response Parameters

| Parameter | Type | Description |
| --- | --- | --- |
| success | boolean | Whether the request succeeded. |
| result | string | A short human-readable message. |
| code | number | HTTP status code of the response. |
| data | array | Array of supported cryptocurrency objects. |
| data[].coin | string | Coin code used in API calls (e.g. `btc`, `erc20_usdc`). See Supported Coins. |
| data[].name | string | Display name. |
| data[].logo | string | Path to the logo image. |
| data[].minimum_transaction_coin | string | Minimum payable amount, in that coin. Payments below this are not processed. |
| data[].prices | object | Map of fiat currency code to the price of one coin in that currency. |

## Invoices

An invoice is a payment request. It expires **24 hours** after creation. Only invoices created by your API key’s account can be read or updated — anything else returns `404`.

#### Hosted vs. white-label

- **Hosted** (`white_label: false`): WolvPay returns a checkout `url`. The customer picks a coin and pays there. The simplest option.
- **White-label** (`white_label: true`, the default): you build the checkout. Pass `coin` to get the deposit `coin_address` and `coin_amount` immediately, or omit it and call [Update Invoice](#update-payment) once the customer chooses.

> **Amounts:** you send `amount` in the fiat `currency` you choose. WolvPay converts it to **USD**, rounds to 2 decimals, and that USD value is the `amount` in every response and webhook. The minimum invoice is **$1 USD**, and each coin has its own minimum (see `minimum_transaction_coin` in [Get Coins](#get-coins)).

### Create an Invoice

Creates a new invoice a customer can pay with cryptocurrency.

> **Note:** Send the body as JSON with `Content-Type: application/json`, not as form data. This endpoint has no idempotency key — if a create request times out, call [List Invoices](#list-invoice) before retrying, or you may create a duplicate.

`POST /api/v1/invoices`

#### Request Parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| amount | number | Yes | Amount to charge, in `currency`. Must be a positive number. |
| currency | string | No | Fiat currency code (e.g. `USD`, `EUR`). Defaults to `USD`. See Supported Currencies. |
| coin | string | No | Coin code to pay with (e.g. `btc`, `ltc`, `erc20_usdc`). White-label only: **ignored when `white_label` is false**. If omitted, status is `AWAITING_SELECTION` and `available_coins` is returned. |
| description | string | No | Free-text description shown on the invoice. |
| white_label | boolean | No | `true` (default) = you build the checkout; `false` = use the hosted checkout page and receive a `url`. |
| redirect_url | string | No | Where to send the customer after payment completes. |

#### Examples

**Hosted**

```json
{
  "amount": 100.00,
  "currency": "USD",
  "description": "Invoice for Order #1234",
  "white_label": false,
  "redirect_url": "https://example.com/thank-you"
}
```

**White-label (coin chosen)**

```json
{
  "amount": 100.00,
  "currency": "USD",
  "coin": "ltc",
  "description": "Invoice for Order #1234",
  "white_label": true
}
```

**White-label (customer picks)**

```json
{
  "amount": 100.00,
  "currency": "EUR",
  "white_label": true
}
```

#### Response Parameters

| Parameter | Type | Description |
| --- | --- | --- |
| success | boolean | Whether the request succeeded. |
| result | string | A short human-readable message. |
| code | number | HTTP status code (`201` on success). |
| data.invoice_id | string | Unique invoice identifier. Store this with your order. |
| data.url | string | Hosted checkout URL. **Hosted invoices only.** |
| data.amount | number | Invoice amount in USD. White-label only. |
| data.description | string | Description, or `null`. White-label only. |
| data.status | string | `AWAITING_PAYMENT` (coin chosen) or `AWAITING_SELECTION`. White-label only. See Invoice Status. |
| data.coin | string | Selected coin code. White-label with `coin` only. |
| data.coin_amount | number | Amount the customer must send, in that coin. White-label with `coin` only. |
| data.coin_address | string | Deposit address. White-label with `coin` only. |
| data.minimum_transaction_coin | string | Smallest payable amount in that coin. White-label with `coin` only. |
| data.available_coins | array | Coin codes the customer may choose from. White-label without `coin` only. |
| data.redirect_url | string | The redirect URL, or `null`. White-label only. |
| data.created_at | string | Creation time, `YYYY-MM-DD HH:MM:SS` (server time). White-label only. |
| data.expires_at | string | Expiry time, same format. White-label only. |

**Hosted Response**

```json
{
  "success": true,
  "result": "Invoice created successfully",
  "code": 201,
  "data": {
    "invoice_id": "a7d82ddf206ad975f5b3d2d3",
    "url": "https://invoices.wolvpay.com/a7d82ddf206ad975f5b3d2d3"
  }
}
```

**White-label (coin chosen)**

```json
{
  "success": true,
  "result": "Invoice created successfully with selected cryptocurrency",
  "code": 201,
  "data": {
    "invoice_id": "a7d82ddf206ad975f5b3d2d3",
    "amount": 100,
    "description": "Invoice for Order #1234",
    "status": "AWAITING_PAYMENT",
    "coin": "ltc",
    "coin_amount": 1.13455866,
    "coin_address": "MQvWK1vphyfV1F6...",
    "minimum_transaction_coin": "0.002",
    "redirect_url": null,
    "created_at": "2026-06-01 15:24:48",
    "expires_at": "2026-06-02 15:24:48"
  }
}
```

**White-label (no coin)**

```json
{
  "success": true,
  "result": "Invoice created successfully",
  "code": 201,
  "data": {
    "invoice_id": "a7d82ddf206ad975f5b3d2d3",
    "amount": 100,
    "description": null,
    "status": "AWAITING_SELECTION",
    "redirect_url": null,
    "created_at": "2026-06-01 15:24:48",
    "expires_at": "2026-06-02 15:24:48",
    "available_coins": ["btc", "ltc", "eth", "trx", "erc20_usdc"]
  }
}
```

**Error Response**

```json
{
  "success": false,
  "error": "Bad Request",
  "code": 500,
  "message": "The provided currency 'XYZ' is not supported. Refer to the documentation for supported currencies: https://wolvpay.com/docs"
}
```

> **Heads-up:** some business-rule failures on this endpoint (unsupported currency, amount under $1, no enabled wallet, invoice rate limit) currently come back with `"code": 500` even though they are client errors. Always branch on `success`, `error` and `message`, not on the status code alone. See [Errors](#errors).

### Retrieve an Invoice

Retrieves an invoice. This is the **authoritative source of truth** for status — use it to confirm a webhook or to poll if a webhook never arrived.

`GET /api/v1/invoices/{invoice_id}`

#### Path Parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| invoice_id | string | Yes | The invoice ID returned when it was created. |

**Awaiting selection**

```json
{
  "success": true,
  "result": "Invoice retrieved successfully",
  "code": 200,
  "data": {
    "invoice_id": "a7d82ddf206ad975f5b3d2d3",
    "amount": 100,
    "description": null,
    "status": "AWAITING_SELECTION",
    "coin": null,
    "coin_amount": null,
    "coin_received": 0,
    "minimum_transaction_coin": null,
    "coin_address": null,
    "redirect_url": null,
    "created_at": "2026-06-01 17:51:12",
    "expires_at": "2026-06-02 17:51:12",
    "available_coins": ["btc", "ltc", "eth", "doge", "trx", "erc20_usdc"]
  }
}
```

**Paid**

```json
{
  "success": true,
  "result": "Invoice retrieved successfully",
  "code": 200,
  "data": {
    "invoice_id": "a7d82ddf206ad975f5b3d2d3",
    "amount": 100,
    "description": "Invoice for Order #1234",
    "status": "PAID",
    "coin": "btc",
    "coin_amount": 0.00234567,
    "coin_received": "0.00234567",
    "minimum_transaction_coin": "0.00008",
    "coin_address": "bc1q...",
    "redirect_url": "https://example.com/thank-you",
    "created_at": "2026-06-01 10:30:00",
    "expires_at": "2026-06-02 10:30:00"
  }
}
```

**Hosted**

```json
{
  "success": true,
  "result": "Invoice retrieved successfully",
  "code": 200,
  "data": {
    "invoice_id": "a7d82ddf206ad975f5b3d2d3",
    "url": "https://invoices.wolvpay.com/a7d82ddf206ad975f5b3d2d3",
    "status": "AWAITING_PAYMENT"
  }
}
```

**Not found**

```json
{
  "success": false,
  "error": "Not Found",
  "code": 404,
  "message": "Invoice not found."
}
```

`coin_received` is the total confirmed amount received so far, in the invoice’s coin. Hosted invoices return only `invoice_id`, `url` and `status`. Numeric fields may arrive as numbers or numeric strings — parse them, don’t compare types.

### Update an Invoice

Selects the cryptocurrency for an invoice that was created without a `coin` and returns the deposit address and amount.

> **Note:** Only available for **white-label** invoices still in `AWAITING_SELECTION`. Once a coin is selected it cannot be changed. For hosted invoices the customer picks the coin on the hosted page.

`POST /api/v1/invoices/{invoice_id}`

#### Request Parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| invoice_id (path) | string | Yes | The invoice to update. |
| coin | string | Yes | Coin code (e.g. `btc`, `ltc`, `erc20_usdc`). Must be one of the invoice’s `available_coins`. |

**Request**

```json
{ "coin": "btc" }
```

**Response**

```json
{
  "success": true,
  "result": "Coin selected successfully for invoice",
  "code": 200,
  "data": {
    "invoice_id": "a7d82ddf206ad975f5b3d2d3",
    "amount": 100,
    "description": null,
    "status": "AWAITING_PAYMENT",
    "coin": "ltc",
    "coin_amount": 1.1345586566825505,
    "coin_address": "MQvWK1vphyfV1F6...",
    "minimum_transaction_coin": "0.002",
    "redirect_url": null,
    "updated_at": "2026-06-01 18:19:56"
  }
}
```

**Error Response**

```json
{
  "success": false,
  "error": "Bad Request",
  "code": 400,
  "message": "Missing required parameter: coin"
}
```

### List All Invoices

Returns your invoices, newest first, with pagination.

`GET /api/v1/invoices`

#### Query Parameters

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| page | integer | No | Page number. Defaults to `1`. |
| limit | integer | No | Invoices per page. Default and maximum is `25`. |

**cURL**

```bash
curl "https://wolvpay.com/api/v1/invoices?page=1&limit=25" \
  -H "Authorization: Bearer $WOLVPAY_API_KEY"
```

**Response**

```json
{
  "success": true,
  "result": "Transactions retrieved successfully",
  "code": 200,
  "data": {
    "invoices": [
      {
        "invoice_id": "a7d82ddf206ad975f5b3d2d3",
        "amount": "100.00",
        "description": null,
        "status": "AWAITING_PAYMENT",
        "coin": "ltc",
        "coin_amount": "1.1345586566826",
        "coin_address": "MQvWK1vphyfV1F6...",
        "fee_amount_coin": "0.02269117",
        "fee_amount_usd": "2.00",
        "apply_fee": 0,
        "white_label": 1,
        "redirect_url": null,
        "created_at": "2026-06-01 18:18:56",
        "updated_at": "2026-06-01 18:19:56"
      }
    ],
    "pagination": {
      "current_page": 1,
      "per_page": 25,
      "total_items": 59,
      "total_pages": 3,
      "has_next": true,
      "has_previous": false,
      "next_page": 2,
      "previous_page": null
    }
  }
}
```

## Webhooks

WolvPay sends an HTTPS `POST` to your server when a payment event happens, such as a payment being confirmed or an invoice expiring. Use it to update your database, unlock access or send a receipt. Every request is signed so you can prove it came from WolvPay.

### Setup & Requirements

Create endpoints in the dashboard under [Webhooks](https://www.wolvpay.com/webhooks). Each endpoint gets its own signing secret.

| Requirement | Detail |
| --- | --- |
| Protocol | The URL must be `https://`. Plain `http://` is rejected. |
| Hostname | A real domain name. IP addresses, `localhost`, and reserved names (`.local`, `.test`, `.internal`, `.lan`, `.localhost`, `.example`, `.invalid`, `.intranet`) are rejected. To develop locally, use a tunnel — see Local Development. |
| Limit | Up to **3** webhook endpoints per account. |
| Success | Any `2xx` response counts as delivered. Anything else (or no response) is recorded as failed. |
| Origin IP | Requests come from `128.140.76.192`. Allow-list it if your firewall is strict. |
| Headers | `Content-Type: application/json` and `X-WolvPay-Signature: <hex hmac>`. |
| Discord | If the URL is a Discord webhook URL, WolvPay posts a formatted embed instead of raw JSON (and sends no signature). |

**When is a webhook sent?** After WolvPay processes a blockchain payment event for one of your invoices (for example a payment detected, confirmed, or short), and when an unpaid invoice expires. A webhook is *not* sent just because an invoice was created. The invoice’s current `status` is in the payload — **use that field to decide what to do**.

### Verify Signatures

Each request carries `X-WolvPay-Signature`: the **hex-encoded HMAC-SHA256** of the **raw request body**, keyed with your webhook secret. Recompute it and compare in constant time. Reject the request with `401` if it doesn’t match.

> **Common mistakes:**
> - Hashing the *parsed* JSON re-serialized by your framework. Whitespace and escaping change, and the signature won’t match. Hash the exact bytes you received (use a raw-body parser such as `express.raw()`).
> - Using `==` instead of a constant-time compare (`hash_equals`, `timingSafeEqual`, `compare_digest`).
> - Reading the header with the wrong case. HTTP headers are case-insensitive; in PHP use `$_SERVER['HTTP_X_WOLVPAY_SIGNATURE']`.
> - Using your *API key* as the secret. The signing secret is the per-endpoint **webhook secret**.

**PHP**

```php
$raw       = file_get_contents('php://input');          // raw bytes, not json_decode'd
$signature = $_SERVER['HTTP_X_WOLVPAY_SIGNATURE'] ?? '';
$expected  = hash_hmac('sha256', $raw, getenv('WOLVPAY_WEBHOOK_SECRET'));

if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit('Invalid signature');
}

$event = json_decode($raw, true);
// ... handle $event
```

**Node.js**

```javascript
import crypto from 'node:crypto';

export function isValidSignature(rawBody, header) {
  const expected = crypto
    .createHmac('sha256', process.env.WOLVPAY_WEBHOOK_SECRET)
    .update(rawBody)            // Buffer from express.raw(), NOT a parsed object
    .digest('hex');

  const a = Buffer.from(header ?? '');
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b); // timingSafeEqual throws on length mismatch
}
```

**Python**

```python
import hashlib, hmac, os

def is_valid_signature(raw_body: bytes, header: str | None) -> bool:
    expected = hmac.new(
        os.environ["WOLVPAY_WEBHOOK_SECRET"].encode(),
        raw_body,                       # request.get_data(), NOT request.json
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest((header or "").encode(), expected.encode())
```

### Payload & Statuses

The body is a JSON object describing the invoice at the moment the webhook was sent:

```json
{
  "invoice_id": "a7d82ddf206ad975f5b3d2d3",
  "amount": "100.00",
  "description": "Invoice for Order #1234",
  "status": "PAID",
  "coin": "ltc",
  "coin_amount": "1.13455866",
  "coin_received": "1.13455866",
  "coin_address": "MQvWK1vphyfV1F6...",
  "redirect_url": "https://example.com/thank-you",
  "created_at": "2026-06-01 15:24:48"
}
```

| Field | Type | Description |
| --- | --- | --- |
| invoice_id | string | The invoice this event is about. Match it to your order. |
| amount | string\|number | Invoice amount in **USD**. |
| description | string\|null | Description supplied at creation. |
| status | string | Current invoice status. This is the field to branch on. See Invoice Status. |
| coin | string | Coin code, lowercase (e.g. `ltc`). |
| coin_amount | string\|number | Amount requested, in that coin. |
| coin_received | string\|number | Total confirmed so far, in that coin (`0` if nothing confirmed). |
| coin_address | string | The deposit address. |
| redirect_url | string\|null | Redirect URL supplied at creation. |
| created_at | string | Invoice creation time, `YYYY-MM-DD HH:MM:SS` (server time, not ISO-8601). |

Statuses you should expect to receive and how to handle them:

| status | What to do |
| --- | --- |
| PAID | Fulfill the order (once). |
| CONFIRMING_PAYMENT | Payment seen, not final. Show “processing”; do not fulfill. |
| UNDERPAID | Customer sent too little. Flag for manual review or ask them to top up. |
| EXPIRED | Cancel the order or release reserved stock. |
| AWAITING_PAYMENT | No action needed. |

The same status can be delivered more than once, and deliveries are not guaranteed to arrive in order. Treat every webhook as a *hint to re-check the invoice* (see the handler below), not as the final word.

### Recommended Handler

A production-shaped handler does five things, in this order:

1. Read the **raw** body and verify the signature → `401` if invalid.
2. Look the invoice up with `GET /invoices/{invoice_id}` to get the authoritative status. A `404` means it isn’t one of your invoices (for example a Webhook Tester delivery) — acknowledge with `200` and ignore it.
3. If the status is `PAID`, fulfill the order **idempotently** (keyed on `invoice_id`).
4. Return `2xx` quickly.
5. If something on your side fails (database down), return `5xx` so the delivery shows as failed and you can resend it.

**Node.js (Express)**

```javascript
import express from 'express';
import crypto from 'node:crypto';

const app = express();
const API = process.env.WOLVPAY_API_BASE ?? 'https://wolvpay.com/api/v1';

function isValidSignature(rawBody, header) {
  const expected = crypto.createHmac('sha256', process.env.WOLVPAY_WEBHOOK_SECRET)
    .update(rawBody).digest('hex');
  const a = Buffer.from(header ?? '');
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Raw body is required for the signature. Do NOT put express.json() before this route.
app.post('/webhooks/wolvpay', express.raw({ type: '*/*' }), async (req, res) => {
  if (!isValidSignature(req.body, req.get('X-WolvPay-Signature'))) {
    return res.sendStatus(401);
  }
  const event = JSON.parse(req.body.toString('utf8'));

  // Confirm with the API: authoritative status, and unknown invoices (test deliveries) are ignored.
  const r = await fetch(`${API}/invoices/${encodeURIComponent(event.invoice_id)}`, {
    headers: { Authorization: `Bearer ${process.env.WOLVPAY_API_KEY}` },
  });
  if (r.status === 404) return res.sendStatus(200); // not ours / test delivery
  if (!r.ok) return res.sendStatus(502);            // shows as failed, can be resent
  const { data: invoice } = await r.json();

  try {
    if (invoice.status === 'PAID') {
      await fulfillOrderOnce(invoice.invoice_id);   // your code - MUST be idempotent
    } else if (invoice.status === 'EXPIRED') {
      await cancelOrder(invoice.invoice_id);
    }
  } catch (err) {
    console.error(err);
    return res.sendStatus(500);
  }
  res.sendStatus(200);
});

app.listen(3000);
```

**Python (Flask)**

```python
import hashlib, hmac, os
import requests
from flask import Flask, request, abort

app = Flask(__name__)
API = os.environ.get("WOLVPAY_API_BASE", "https://wolvpay.com/api/v1")

def is_valid_signature(raw: bytes, header: str | None) -> bool:
    expected = hmac.new(os.environ["WOLVPAY_WEBHOOK_SECRET"].encode(), raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest((header or "").encode(), expected.encode())

@app.post("/webhooks/wolvpay")
def wolvpay_webhook():
    raw = request.get_data()                       # raw bytes
    if not is_valid_signature(raw, request.headers.get("X-WolvPay-Signature")):
        abort(401)
    event = request.get_json(force=True)

    r = requests.get(
        f"{API}/invoices/{event['invoice_id']}",
        headers={"Authorization": f"Bearer {os.environ['WOLVPAY_API_KEY']}"},
        timeout=10,
    )
    if r.status_code == 404:
        return "", 200                             # not ours / test delivery
    if not r.ok:
        abort(502)                                 # shows as failed, can be resent
    invoice = r.json()["data"]

    try:
        if invoice["status"] == "PAID":
            fulfill_order_once(invoice["invoice_id"])   # your code - MUST be idempotent
        elif invoice["status"] == "EXPIRED":
            cancel_order(invoice["invoice_id"])
    except Exception:
        app.logger.exception("webhook handling failed")
        abort(500)
    return "", 200
```

**PHP**

```php
<?php
$raw       = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WOLVPAY_SIGNATURE'] ?? '';
$expected  = hash_hmac('sha256', $raw, getenv('WOLVPAY_WEBHOOK_SECRET'));

if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit;
}

$event = json_decode($raw, true);
$api   = getenv('WOLVPAY_API_BASE') ?: 'https://wolvpay.com/api/v1';

// Confirm with the API: authoritative status, and unknown invoices (test deliveries) are ignored.
$ch = curl_init($api . '/invoices/' . rawurlencode($event['invoice_id']));
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 10,
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . getenv('WOLVPAY_API_KEY')],
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($code === 404) { http_response_code(200); exit; }   // not ours / test delivery
if ($code !== 200) { http_response_code(502); exit; }   // shows as failed, can be resent

$invoice = json_decode($body, true)['data'];

try {
    if ($invoice['status'] === 'PAID') {
        fulfill_order_once($invoice['invoice_id']);     // your code - MUST be idempotent
    } elseif ($invoice['status'] === 'EXPIRED') {
        cancel_order($invoice['invoice_id']);
    }
} catch (Throwable $e) {
    error_log($e->getMessage());
    http_response_code(500);
    exit;
}

http_response_code(200);
```

`WOLVPAY_API_BASE` is optional. It exists so you can point the handler at a local mock during tests (see [Simulate Locally](#local-signing)).

### Delivery & Retries

- **At-least-once, unordered.** Make fulfillment idempotent on `invoice_id`, and trust the invoice status from the API over the order webhooks arrive in.
- **Respond fast.** Return `2xx` within a few seconds. Do slow work (emails, third-party calls) in a background job after acknowledging.
- **Every attempt is logged.** Open [Dashboard → Webhooks](https://www.wolvpay.com/webhooks) to see each delivery’s request body, your HTTP status and your response body.
- **Resend from the dashboard.** A failed delivery can be re-sent. The resend uses the *original* payload (the status at the time), signed again with your secret, and is logged as a new delivery.
- **Don’t rely on retries alone.** If your server was down, call `GET /invoices/{id}` for any order still pending — a periodic reconciliation job is cheap insurance.

## Testing & Sandbox

Test your integration end to end without risking money. WolvPay does not have a separate sandbox environment, so this section shows exactly what you can test and how.

> **There is no test mode and there are no test API keys.** Every API key works against the live service and every invoice is a real invoice. Two things keep this safe: creating an invoice is free (it only costs money if someone actually sends crypto to it), and unpaid invoices simply expire after 24 hours.

### How to Test

| What you are testing | Use | Costs real crypto? |
| --- | --- | --- |
| Your webhook endpoint (signature check, parsing, idempotency) | **Webhook Tester** — sends a real, signed webhook with fake data | No |
| Your webhook endpoint with no network at all | **Simulate Locally** — sign a payload yourself and POST it to localhost | No |
| Creating, reading, updating and listing invoices | The live API with your real key. Unpaid invoices expire on their own | No |
| The hosted checkout page and your redirect flow | Create a hosted invoice and open its `url` | No (until you pay) |
| The full payment path (payment → `PAID` webhook → fulfillment) | Pay one small real invoice | Yes — a minimum-size payment plus network fee |

Recommended order: **(1)** Simulate Locally while writing the handler, **(2)** Webhook Tester against your deployed URL, **(3)** one real minimum-size payment before going live. For step 3, check the smallest allowed amount for your coin with [Get Coins](#get-coins) (`minimum_transaction_coin`); the minimum invoice is $1 USD.

### Webhook Tester

The Webhook Tester ([Dashboard → Developers → Webhook Tester](https://www.wolvpay.com/webhook_test)) sends a sample payload to one of your webhook endpoints, **signed with that endpoint’s real secret**, using the same delivery path and headers as production. It tests your URL, TLS, firewall, signature code and response handling.

| Setting | Detail |
| --- | --- |
| Requirements | A verified account and at least one **active** webhook endpoint. |
| Statuses | Pick one: `PAID`, `AWAITING_PAYMENT`, `CONFIRMING_PAYMENT`, `UNDERPAID`, `EXPIRED`. |
| Invoice ID | Optional. Letters, numbers, `-` and `_`, up to 64 characters. If blank, one is generated as `test_` + 12 hex characters. |
| Limit | **10 test webhooks per hour** per account. The remaining count is shown on the page. |
| Result | The page shows the HTTP status your server returned, your response body, and any connection error. `2xx` = success. |
| Logging | Test deliveries appear in your webhook delivery log with the event type `webhook.test`. |
| Discord URLs | If the endpoint is a Discord webhook, the test posts the formatted embed instead of JSON. |

#### The test payload

```json
{
  "invoice_id": "test_9f2c4a1b7d3e",
  "amount": "10.00",
  "description": "Test invoice from WolvPay Webhook Tester",
  "status": "PAID",
  "coin": "BTC",
  "coin_amount": "0.00015000",
  "coin_received": "0.00015000",
  "coin_address": "1A1zP1eP5QGefi2DMPTfTL5SLmv7Divf2N",
  "redirect_url": "",
  "created_at": "2026-06-01 12:00:00"
}
```

`coin_received` is `"0.00000000"` for every status except `PAID`.

#### How tests differ from real events

- The invoice **does not exist** in WolvPay, so `GET /invoices/{invoice_id}` returns `404`. A handler that confirms via the API (as recommended above) will correctly ignore test deliveries and never fulfill a fake order.
- `coin` is the uppercase `BTC` in tests; real events use lowercase codes like `btc`. Normalize before comparing.
- `redirect_url` is an empty string in tests; real events send the URL or `null`.
- The amount is always `10.00` and the address is a well-known sample address.

> **If you don’t verify with the API** and instead trust the webhook body, a test `PAID` event is indistinguishable from a real one except for the invoice ID. Never hard-code logic that fulfills orders from an unrecognized `invoice_id`.

### Simulate Locally

You can test your handler with no network and no WolvPay account activity: build a payload, sign it with your webhook secret exactly as WolvPay does, and POST it to your local server. It also lets you test the cases the Webhook Tester can’t, such as a tampered body or a duplicate delivery.

**cURL + openssl**

```bash
export WOLVPAY_WEBHOOK_SECRET=local_test_secret

BODY='{"invoice_id":"local_test_001","amount":"10.00","description":"Local test","status":"PAID","coin":"btc","coin_amount":"0.00015000","coin_received":"0.00015000","coin_address":"1A1zP1eP5QGefi2DMPTfTL5SLmv7Divf2N","redirect_url":"","created_at":"2026-06-01 12:00:00"}'

SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WOLVPAY_WEBHOOK_SECRET" | awk '{print $NF}')

curl -i -X POST http://localhost:3000/webhooks/wolvpay \
  -H "Content-Type: application/json" \
  -H "X-WolvPay-Signature: $SIG" \
  --data-binary "$BODY"          # --data-binary keeps the bytes exactly as signed
```

**Node.js**

```javascript
import crypto from 'node:crypto';

const secret = process.env.WOLVPAY_WEBHOOK_SECRET ?? 'local_test_secret';
const body = JSON.stringify({
  invoice_id: 'local_test_001',
  amount: '10.00',
  description: 'Local test',
  status: 'PAID',
  coin: 'btc',
  coin_amount: '0.00015000',
  coin_received: '0.00015000',
  coin_address: '1A1zP1eP5QGefi2DMPTfTL5SLmv7Divf2N',
  redirect_url: '',
  created_at: '2026-06-01 12:00:00',
});

const signature = crypto.createHmac('sha256', secret).update(body).digest('hex');

const res = await fetch('http://localhost:3000/webhooks/wolvpay', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'X-WolvPay-Signature': signature },
  body, // send the exact string that was signed
});
console.log(res.status, await res.text());
```

**Python**

```python
import hashlib, hmac, json, os, requests

secret = os.environ.get("WOLVPAY_WEBHOOK_SECRET", "local_test_secret")
body = json.dumps({
    "invoice_id": "local_test_001",
    "amount": "10.00",
    "description": "Local test",
    "status": "PAID",
    "coin": "btc",
    "coin_amount": "0.00015000",
    "coin_received": "0.00015000",
    "coin_address": "1A1zP1eP5QGefi2DMPTfTL5SLmv7Divf2N",
    "redirect_url": "",
    "created_at": "2026-06-01 12:00:00",
}).encode()

signature = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()

res = requests.post(
    "http://localhost:3000/webhooks/wolvpay",
    data=body,  # send the exact bytes that were signed
    headers={"Content-Type": "application/json", "X-WolvPay-Signature": signature},
)
print(res.status_code, res.text)
```

> **Your handler will ignore these by design.** `local_test_001` isn’t a real invoice, so the confirm-with-API step returns `404`. To exercise your fulfillment code locally, set `WOLVPAY_API_BASE` to a small mock server (or stub the lookup in your tests) that returns `{"success": true, "data": {"invoice_id": "local_test_001", "status": "PAID"}}`.

### Local Development

WolvPay can only deliver to a public `https://` domain — `localhost` and IP addresses are rejected when you save the endpoint. To receive real (or Webhook Tester) deliveries on your laptop, expose your local server through an HTTPS tunnel:

**Cloudflare Tunnel**

```bash
cloudflared tunnel --url http://localhost:3000
```

**ngrok**

```bash
ngrok http 3000
```

1. Start your app (e.g. on port 3000) and the tunnel; copy the `https://…` URL it prints.
2. In [Dashboard → Webhooks](https://www.wolvpay.com/webhooks), create an endpoint with `https://<tunnel-host>/webhooks/wolvpay`. Copy its secret into `WOLVPAY_WEBHOOK_SECRET`.
3. Send a test from the [Webhook Tester](#webhook-tester) and watch your logs.
4. Deactivate or delete the endpoint when you finish — tunnel URLs are temporary.

Just want to inspect what WolvPay sends? Point an endpoint at a request-inspection URL such as [webhook.site](https://webhook.site). You’ll see the exact headers and raw body (but you won’t be testing your own code).

### Test Checklist

Run each of these before going live. Most can be done with [Simulate Locally](#local-signing).

| Scenario | How | Expected result |
| --- | --- | --- |
| Valid signature, `PAID` | Webhook Tester, or a signed local request | `200`. Order fulfilled exactly once. |
| Same event delivered twice | Send the identical request twice | `200` both times. Order still fulfilled once. |
| Tampered body | Change one character after signing | `401`. Nothing happens. |
| Missing or empty signature | Omit the `X-WolvPay-Signature` header | `401`. |
| Wrong secret | Sign with a different secret | `401`. |
| `CONFIRMING_PAYMENT` | Webhook Tester | `200`. Order shown as processing, **not** fulfilled. |
| `UNDERPAID` | Webhook Tester | `200`. Flagged for review, **not** fulfilled. |
| `EXPIRED` | Webhook Tester | `200`. Order cancelled / stock released. |
| Unknown `invoice_id` | Any `test_` or local invoice | `200`, ignored (the API lookup returns `404`). |
| Your dependency is down | Stop your database, send a `PAID` event | `5xx`, so the delivery is recorded as failed and can be resent. |
| Raw-body handling | Send JSON with extra spaces / unicode | Signature still verifies (you hash the raw bytes). |
| One real payment | Pay a minimum-size live invoice | `PAID` webhook arrives, order fulfilled, delivery shows `2xx` in the dashboard. |

## AI & Coding Agents

These docs are built to be consumed by tools such as Claude Code, Cursor, Copilot and ChatGPT as well as humans. Give your assistant one of the machine-readable sources below instead of letting it guess from memory, and have it follow the integration rules.

### Machine-readable Docs

| Resource | Format | Use it for |
| --- | --- | --- |
| [/openapi.json](https://www.wolvpay.com/openapi.json) | OpenAPI 3.1 | Generating a typed client, importing into Postman / Insomnia, or giving an agent exact request and response schemas. |
| [/docs.md](https://www.wolvpay.com/docs.md) | Markdown | Pasting the whole guide into an assistant, including the webhook handler and testing sections. Same content as this page. |
| [/llms.txt](https://www.wolvpay.com/llms.txt) | Plain text | A short product summary for LLM crawlers, with links to the above. |
| **Copy for AI** button | Markdown | Copies the Markdown version of this page to your clipboard (top-right of the header). |

```bash
curl -s https://www.wolvpay.com/openapi.json -o wolvpay-openapi.json
curl -s https://www.wolvpay.com/docs.md      -o wolvpay-docs.md
```

### Integration Rules

The facts an agent (or a new teammate) most often gets wrong. Treat these as requirements:

1. **Base URL** is `https://wolvpay.com/api/v1`. Auth is `Authorization: Bearer $WOLVPAY_API_KEY`. Bodies are JSON (`Content-Type: application/json`), never form-encoded.
2. **Secrets live in environment variables** (`WOLVPAY_API_KEY`, `WOLVPAY_WEBHOOK_SECRET`). Never hard-code, log or send them to the browser.
3. **There is no sandbox.** Do not invent a test base URL, test keys or a `test_mode` parameter. Test with the Webhook Tester and locally signed requests.
4. **Check `success` first.** Errors look like `{"success": false, "error": "...", "code": 400, "message": "..."}`. Some business-rule errors carry `"code": 500`; read `message`.
5. **`amount` in responses and webhooks is USD**, regardless of the `currency` you sent.
6. **Save `invoice_id`** with your order at creation time. It is the join key for everything else.
7. **Verify the webhook signature** on the *raw* body with HMAC-SHA256 (hex) and a constant-time compare, using the per-endpoint webhook secret.
8. **Then confirm with `GET /invoices/{invoice_id}`** and fulfill only when `status === "PAID"`. A `404` there means ignore the webhook.
9. **Be idempotent.** Webhooks can repeat and arrive out of order. Key fulfillment on `invoice_id`.
10. **Return `2xx` fast** once handled; return `5xx` if your side failed so it can be resent.
11. **`POST /invoices` is not idempotent.** On a timeout, list invoices before retrying.
12. **Respect rate limits:** 100 requests/minute per key (`429` + `X-RateLimit-Reset`); 40 invoices/hour and 800/day per account.
13. **Webhook URLs must be public HTTPS domains** (no IPs, no localhost). Use a tunnel for local development.

### Starter Prompt

Paste this into your coding agent, filling in the bracketed parts. It points the agent at the right sources and encodes the rules above.

```text
Integrate WolvPay crypto payments into this project.

Sources of truth (read these first, do not rely on memory):
- https://www.wolvpay.com/openapi.json
- https://www.wolvpay.com/docs.md

Stack: [e.g. Next.js 15 + Postgres]

Do this:
1. Add a server-side function createCheckout(order) that POSTs to /api/v1/invoices
   with white_label=false and redirect_url=[our thank-you URL], and stores the
   returned invoice_id on the order. Redirect the customer to data.url.
2. Add a POST /webhooks/wolvpay route that:
   - reads the RAW body and verifies X-WolvPay-Signature (hex HMAC-SHA256 using
     WOLVPAY_WEBHOOK_SECRET, constant-time compare) -> 401 on mismatch
   - confirms via GET /api/v1/invoices/{invoice_id}; a 404 means ignore (200)
   - fulfills the order ONLY when status === "PAID", idempotently on invoice_id
   - returns 2xx quickly, 5xx if our own processing fails
3. Read WOLVPAY_API_KEY and WOLVPAY_WEBHOOK_SECRET from environment variables.
   Never log or expose them.
4. WolvPay has NO sandbox or test keys. Write tests that sign a payload locally
   (see the "Simulate Locally" section of the docs) and cover: valid PAID,
   duplicate delivery, tampered body, missing signature, EXPIRED, unknown invoice.
   Make the API base URL configurable (WOLVPAY_API_BASE) so tests can mock it.
5. Do not invent endpoints or fields that are not in the OpenAPI spec.
```

## Reference

Supported coins, fiat currencies and invoice statuses.

### Supported Coins

Use the **code** in the `coin` parameter. A coin is only usable if you have enabled a wallet for it in the dashboard. For live minimum amounts and prices, call [Get Coins](#get-coins).

| Code | Asset | Network |
| --- | --- | --- |
| `btc` | Bitcoin | Bitcoin |
| `bch` | Bitcoin Cash | Bitcoin Cash |
| `ltc` | Litecoin | Litecoin |
| `doge` | Dogecoin | Dogecoin |
| `eth` | Ethereum | Ethereum |
| `trx` | TRX | Tron |
| `erc20_usdc` | USDC | Ethereum (ERC-20) |
| `erc20_usdt` | USDT | Ethereum (ERC-20) |
| `bep20_usdc` | USDC | BNB Smart Chain (BEP-20) |
| `bep20_usdt` | USDT | BNB Smart Chain (BEP-20) |
| `polygon_pol` | POL | Polygon |
| `polygon_usdc` | USDC | Polygon |
| `polygon_usdt` | USDT | Polygon |
| `sol_sol` | Solana | Solana |
| `sol_usdc` | USDC | Solana |
| `sol_usdt` | USDT | Solana |
| `trc20_usdt` | USDT | Tron (TRC-20) |

### Supported Fiat Currencies

Invoice amounts can be given in any of these currencies and are converted at the time of creation. The stored and returned `amount` is always in USD.

| Currency Code | Currency Name |
| --- | --- |
| `AED` | United Arab Emirates Dirham |
| `AUD` | Australian Dollar |
| `BGN` | Bulgarian Lev |
| `BRL` | Brazilian Real |
| `CAD` | Canadian Dollar |
| `CHF` | Swiss Franc |
| `CNY` | Chinese Yuan |
| `COP` | Colombian Peso |
| `CZK` | Czech Koruna |
| `DKK` | Danish Krone |
| `EUR` | Euro |
| `GBP` | British Pound Sterling |
| `HKD` | Hong Kong Dollar |
| `HUF` | Hungarian Forint |
| `IDR` | Indonesian Rupiah |
| `INR` | Indian Rupee |
| `JPY` | Japanese Yen |
| `LKR` | Sri Lankan Rupee |
| `MXN` | Mexican Peso |
| `MYR` | Malaysian Ringgit |
| `NGN` | Nigerian Naira |
| `NOK` | Norwegian Krone |
| `PHP` | Philippine Peso |
| `PLN` | Polish Zloty |
| `RON` | Romanian Leu |
| `RUB` | Russian Ruble |
| `SEK` | Swedish Krona |
| `SGD` | Singapore Dollar |
| `THB` | Thai Baht |
| `TRY` | Turkish Lira |
| `TWD` | Taiwan Dollar |
| `UAH` | Ukrainian Hryvnia |
| `UGX` | Ugandan Shilling |
| `USD` | US Dollar |
| `VND` | Vietnamese Dong |
| `ZAR` | South African Rand |

### Invoice Status Values

Invoices move through these statuses during their lifecycle:

| Status | Description |
| --- | --- |
| `AWAITING_SELECTION` | Invoice created, but no cryptocurrency has been selected yet (white-label invoices created without `coin`). |
| `AWAITING_PAYMENT` | Cryptocurrency selected. Waiting for the customer to pay. |
| `CONFIRMING_PAYMENT` | A payment was detected and is waiting for blockchain confirmations. Do not fulfill yet. |
| `PAID` | Payment confirmed. **This is the only status on which you should fulfill the order.** |
| `UNDERPAID` | Payment received, but the amount is less than required. Review manually. |
| `EXPIRED` | The invoice expired (24 hours after creation) without payment. |

## Errors

WolvPay uses standard HTTP status codes plus a consistent JSON error body.

| Code | Description |
| --- | --- |
| 200 - OK | Everything worked as expected. |
| 201 - Created | An invoice was created. |
| 400 - Bad Request | Missing or invalid parameter. |
| 401 - Unauthorized | Missing, malformed or invalid API key. |
| 404 - Not Found | The invoice (or endpoint) does not exist, or belongs to another account. |
| 429 - Too Many Requests | You exceeded the per-key rate limit. See Rate Limits. |
| 500 - Server Error | Something went wrong on WolvPay's side **or** a business-rule check failed (see the note below). |

#### Error Response Format

```json
{
  "success": false,
  "error": "Bad Request",
  "code": 400,
  "message": "Missing required parameter: amount"
}
```

Branch on `success`; show or log `message`. `error` is a short category (`Bad Request`, `Unauthorized`, `Not Found`, `Too Many Requests`, `Internal Server Error`, `Rate Limit Exceeded`).

#### Common messages

| HTTP | message | What to do |
| --- | --- | --- |
| 401 | Authorization header is missing. | Send `Authorization: Bearer <key>`. |
| 401 | Invalid authorization format. Use Authorization: Bearer <your-api-key>. | Use the `Bearer` scheme. |
| 401 | Invalid API key. | Check the key in Dashboard → API Keys. |
| 400 | Missing required parameter: amount | Add the field to the JSON body. |
| 400 | Parameter 'amount' must be a positive number | Send a number greater than 0. |
| 404 | Invoice not found. | Wrong ID, or the invoice belongs to another account. |
| 404 | The requested endpoint does not exist. Please check the URL and try again. | Check the path and HTTP method. |
| 429 | Rate limit exceeded. Please try again later. | Wait until `X-RateLimit-Reset`. |
| 500* | The provided currency 'XYZ' is not supported... | Use a code from Supported Currencies. |
| 500* | The amount must be at least $1. | Increase the amount (USD equivalent). |
| 500* | No enabled wallets found for the user. | Add and enable a wallet in the dashboard. |
| 500* | No enabled wallets found for the user with sufficient balance. Minimum required USD amount is N. | Raise the amount, or enable a coin with a lower minimum. |
| 500* | The provided cryptocurrency code is not supported or not enabled for the user or the amount is below the minimum required for this wallet. | Pick a coin from `available_coins`. |
| 500* | The amount is below the minimum transaction amount for the selected cryptocurrency. | Raise the amount or choose another coin. |
| 500* | Invoice is not awaiting coin selection. Current status: ... | A coin is already set; the invoice can’t be changed. |
| 500* | This endpoint is only available for white-label invoices. | Hosted invoices pick the coin on the hosted page. |
| 500* | You are creating invoices too quickly. Maximum 40 invoices per hour... | Back off and retry later. |
| 500* | You have reached the daily invoice creation limit (800 per day)... | Wait, or contact support for a higher limit. |

***** These are client-side problems that currently return HTTP `500` with a descriptive `message`. Don’t blindly retry a `500` on `POST /invoices` — read the message first, and remember the endpoint is not idempotent.

## Rate Limits

| Limit | Scope | On exceed |
| --- | --- | --- |
| 100 requests / minute | Per API key | `429 Too Many Requests` with the headers below. |
| 40 invoices / hour | Per account | Invoice creation fails with a `Rate Limit Exceeded` error. |
| 800 invoices / day | Per account | Invoice creation fails with a `Rate Limit Exceeded` error. Contact support for more. |
| 10 test webhooks / hour | Per account (Webhook Tester) | The tester refuses until the hour rolls over. |

These headers help you track your per-key usage:

| Header | Description |
| --- | --- |
| `X-RateLimit-Limit` | The maximum number of requests permitted per minute. |
| `X-RateLimit-Remaining` | Requests remaining in the current rate limit window. |
| `X-RateLimit-Reset` | Time at which the current window resets (UTC epoch seconds). |

```javascript
async function wolvpay(path, init = {}) {
  const res = await fetch(`https://wolvpay.com/api/v1${path}`, {
    ...init,
    headers: {
      Authorization: `Bearer ${process.env.WOLVPAY_API_KEY}`,
      'Content-Type': 'application/json',
      ...init.headers,
    },
  });

  if (res.status === 429) {
    const resetAt = Number(res.headers.get('X-RateLimit-Reset')) * 1000;
    await new Promise((r) => setTimeout(r, Math.max(resetAt - Date.now(), 1000)));
    return wolvpay(path, init); // safe for GETs; do not auto-retry POST /invoices on timeouts
  }

  const body = await res.json();
  if (!body.success) throw new Error(`${body.error}: ${body.message}`);
  return body.data;
}
```
