# zerofive API

zerofive is a crypto payment processor for invoices, prepaid subscriptions, customer deposits, tips, payouts, refunds, and withdrawals. The REST base URL is `https://z5.gg/api/v1`; `sk_test_` keys use isolated simulated data and `sk_live_` keys use real funds. The standard processing fee is 0.5% on incoming payments; payouts, refunds, and withdrawals have no network fee, while fee-free top-up funds incur the processing fee when they leave the platform.

## Quickstart

1. Create a test key in Dashboard → Developers and, still in test mode, configure a public webhook URL there. Store the API key and the test signing secret (`whsec_test_…`) on your server. Going live later means a live key plus the live URL and live secret, set the same way in live mode.
2. Create an invoice with a unique `order_id` and the USD order amount.
3. Redirect the buyer to `hosted_url`, or display that URL using `https://z5.gg/js/z5.js` (see Embedding).
4. In test checkout, choose a coin and use **Simulate the payment**.
5. Receive the `invoice.paid` webhook, verify its signature against the raw request body, then fulfil once. A redirect or browser callback alone is not proof of payment.

Create an invoice with curl:

```bash
curl --fail-with-body https://z5.gg/api/v1/invoices \
  -H "Authorization: Bearer $ZEROFIVE_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"amount":"49.00","order_id":"A-1042","description":"Order A-1042"}'
```

The same request with Node.js built-in `fetch`:

```javascript
const response = await fetch("https://z5.gg/api/v1/invoices", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.ZEROFIVE_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    amount: "49.00",
    order_id: "A-1042",
    description: "Order A-1042",
  }),
});
const invoice = await response.json();
if (!response.ok) {
  throw new Error(`${response.status}: ${invoice.error?.message ?? "API error"}`);
}
// Return this URL to your browser or issue your application's HTTP redirect.
console.log(invoice.hosted_url);
```

Do not run both examples with the same `order_id` expecting two invoices: the second creation is refused. For a custom payment screen, supply both `network` and `asset`, then display the returned `payment` instructions. Complete Node and Python signature-verification examples appear under Webhooks.

## Authentication and conventions

Send `Authorization: Bearer sk_test_...` or `Authorization: Bearer sk_live_...`. Keys belong to a store and their creator; revocation, removal from the team, or a disabled creator invalidates access. Only an email-confirmed owner can create live keys. Never expose secret keys in browsers, public source, or mobile bundles.

All `/api/v1` endpoints require a key except `GET /api/v1/networks`. Use JSON request bodies with `Content-Type: application/json`. Successful v1 handlers return JSON with HTTP 200, including creation; objects are returned directly, without a `data` wrapper. Lists use the list envelope. IDs and example addresses below are illustrative; obtain real instructions from the API and never send money to example addresses.

Amounts are decimal strings in dollars or coins as stated per field, never integer base units. Prefer strings even when a USD input also accepts a number. USD inputs round to cents; crypto payout inputs must not exceed the asset's decimal precision. Timestamps ending `_at`, `paid_through`, and quote expiries are ISO 8601 UTC strings or null. Webhook `created` is Unix seconds; public tip-feed `since` is Unix milliseconds.

### Scopes

Every valid key can read its store's objects in its mode. New keys default to `invoices` only. Scopes govern writes:

| Scope | Allowed writes |
| --- | --- |
| `invoices` | Invoices (create, quote, cancel), customers, plans, subscriptions, tips. |
| `payouts` | Payouts, invoice refunds, and approval or rejection of store-held payouts/refunds. |
| `withdrawals` | Store withdrawals to saved payout addresses. |
| `rules` | Payout-rule changes; also requires a live key created by the store owner. |

A key with no scopes is read-only: it can read everything in its mode and create exports (`POST /api/v1/exports`), but nothing else. Use it for accountants and reporting tools.

An IP allowlist can contain up to 50 IPv4 addresses, IPv4 CIDR ranges, or IPv6 addresses. An unmatched request receives HTTP 403 `permission`. The key's Access settings are managed in Dashboard → Developers.

Per-key accepted coins narrow the store's accepted set, which in turn narrows the currently enabled platform set. Invoice, tip, and customer creation copy the creating key's coin restriction, so later key changes do not rewrite those objects. Platform and store restrictions still apply dynamically. This controls offered payment instructions, not a promise that a blockchain transfer can be prevented. Subscription-generated invoices currently use the store/platform set rather than copying the creating key's coin restriction. Payout permissions and payout rules control outgoing money separately.

### Errors

```json
{
  "error": {
    "type": "invalid_request",
    "message": "amount must be between 0.50 and 1,000,000.00 USD."
  }
}
```

| Type | HTTP status | Meaning and handling |
| --- | --- | --- |
| `invalid_request` | 400 | Invalid/missing field, invalid cursor, unavailable coin, insufficient balance, or rejected business rule. Correct the request. |
| `authentication` | 401 | Missing, invalid, revoked, or inaccessible key. |
| `permission` | 403 | Missing scope or IP not on the key allowlist. |
| `store_suspended` | 403 | Store cannot collect or send funds. Read existing records; contact support if needed. |
| `not_found` | 404 | Object unavailable to this store/mode. Some write helpers instead return 400 `invalid_request` for a missing related object. |
| `rate_limited` | 429 | Request budget exceeded; back off. |
| `region_restricted` | 451 | Request rejected by the geographic access layer. |
| `server` | 500 | Server error. Reconcile uncertain writes and apply the retry rules below. |

Branch on HTTP status and `error.type`, not the human-readable message. Public tip endpoints outside `/api/v1` normally use `{ "error": "message" }`; the geographic access layer can still return the structured 451 envelope.

Suspension permits ordinary reads and webhooks, invoice/subscription cancellation, and rejection of store-held payouts. It blocks other writes, customer address reads, and top-up instructions. Invoice/tip payment instructions have `address`, `uri`, and `qr_url` suppressed to null. Incoming payments are held without crediting, including simulated payments while suspended; signed outgoing transfers may still finish. Read access does not imply that collection is available.

### Rate limits and retries

Authenticated v1 endpoints allow 300 requests per minute per key. The wrapper does not supply rate-limit or `Retry-After` headers. Use exponential backoff with jitter for 429, transient 5xx, and network failures; prefer webhooks to frequent polling. Public tip limits are documented separately.

There is no general `Idempotency-Key` header support. A timeout does not prove that creation failed.

| Operation | Duplicate protection | Safe recovery |
| --- | --- | --- |
| Create payout, withdrawal, or refund | `external_id`, up to 120 characters, unique across all three outgoing kinds per store and mode. | Retry with the same reference and original fields. Returns the existing object; never creates another transfer. Changing kind, amount, coin, network, address, customer, or invoice is refused. Metadata is not updated by a retry. |
| Create invoice | `order_id`, up to 120 characters, unique per store and mode. | Duplicate returns 400, not the existing invoice. Save its ID; if the response was lost, paginate invoices and match `order_id` locally. There is no `order_id` list filter. |
| Create customer | `external_id`, up to 120 characters, unique per store and mode. | Repeating returns the existing customer unchanged. Use the customer list's `external_id` filter to look it up. |
| Create plan, subscription, or tip | No creation idempotency field. | Reconcile existing records before retrying an uncertain write; an automatic retry can create duplicates. |

For outgoing `amount: "max"`, retry with `"max"` to recover the original transfer even if the balance changed. Refunds with omitted `amount` return the original refund on a matching `external_id`; an explicitly different amount is refused. Retrying a failed or rejected outgoing object with the same reference returns that object, not a new attempt. Do not issue a fresh reference merely because a transfer is `failed`: a signed transaction may still land.

### Pagination

Paginated list requests accept `limit` (1–100, default 20) and `starting_after` (last object's ID from the prior page). Invalid limits outside the range are clamped. Use returned IDs as opaque cursors; an unknown cursor produces 400. Results are newest first, with ID as a tie-breaker; payments/deposits use detection time, other paginated objects use creation time.

```json
{"object":"list","data":[],"has_more":false}
```

Every paginated list also accepts a date range: `created[gte]`, `created[gt]`, `created[lte]`, `created[lt]`, each an ISO 8601 date/time (`2026-10-01`, `2026-10-01T12:00:00Z`) or Unix seconds; invalid values return 400. `GET /api/v1/invoices` also filters by `status`, `customer`, `order_id`; `GET /api/v1/payments` by `status`, `invoice`, `customer`, `network`, `asset`.

Continue with the last returned ID while `has_more` is true. Balances, networks, and customer addresses return all entries at once with `has_more: false`; top-up addresses are nested in a top-up object. Public tip feeds have their own shape and `since` filter.

### Test mode and simulation

Test and live records/balances are separate. Use the same API URL and choose mode with the key. Test invoices and customer addresses resemble real addresses but are not watched on chain: never send real coins to them. The hosted checkout simulates exact, partial (half the remainder), or overpayments (120% of the remainder), confirming over roughly 20 seconds; simulated payments have `simulated: true` and a `sim_` transaction hash.

Customer deposits can be simulated from the dashboard or hosted deposit page. Test outgoing transfers normally reach `sent` within seconds with a simulated hash, but store payout rules still apply. Live screening, saved-address checks, and platform amount review do not apply to normal test transfers. Test balances still model fees; no real funds are charged. Test subscriptions can be fast-forwarded in the dashboard. There is no public `/api/v1` simulation or fast-forward endpoint.

Test-mode emails are delivered only to the store's team and to addresses it added and confirmed under Settings → Emails → Recipients. Emails for any other address (such as a test customer) are not sent; they are kept as previews in the store's email log. Test emails are marked `[TEST]` and limited to 100 per store per 24 hours.

Payout rules are shared across test and live modes and can only be changed using an owner-created live key with `rules`. Both modes' events go to the same configured webhook URL: always check `livemode`.

## Invoices

Invoice amounts are USD prices. All quoted coin amounts are coin units. Tips and subscription invoices also appear in invoice lists.

### POST /api/v1/invoices

Create an invoice, optionally with an immediate coin quote.

Required scope: `invoices`.

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| amount | string or number | Yes | USD, 0.50–1,000,000.00 before tax. |
| description | string | No | Checkout/receipt description; truncated to 300 characters. |
| order_id | string | No | Your order reference; truncated to 120 characters; unique per store/mode. |
| customer | string | No | Existing customer ID in the same store and mode. |
| customer_email | string | No | Receipt and partial-payment email; defaults to the linked customer email. |
| customer_country | string | No | Two-letter country code for configured taxes; normalized to uppercase. |
| customer_tax_id | string | No | Buyer tax identifier; trimmed to 40 characters. |
| redirect_url | string | No | HTTP(S) return URL after payment; up to 500 characters. |
| network | string | With asset | Enabled network key returned by /networks; also allowed by store/key. |
| asset | string | With network | Uppercase symbol on the selected network. |
| metadata | object | No | Custom JSON object, at most 4,000 serialized characters; arrays are not accepted. Default {}. |
| expires_in_minutes | number | No | Default platform lifetime (normally 60); clamped to 10–10,080 minutes. |

Response example:

```json
{
  "id": "inv_Example123",
  "object": "invoice",
  "livemode": false,
  "status": "open",
  "amount": "49.00",
  "currency": "USD",
  "subtotal": "49.00",
  "tax": null,
  "customer_country": null,
  "customer_tax_id": null,
  "amount_paid_usd": "0.00",
  "description": "Order A-1042",
  "order_id": "A-1042",
  "customer": null,
  "customer_email": null,
  "subscription": null,
  "metadata": {},
  "payment": null,
  "overpaid": false,
  "hosted_url": "https://z5.gg/pay/inv_Example123",
  "receipt_url": null,
  "redirect_url": null,
  "merchant": {
    "id": "mer_Example123",
    "name": "Example store"
  },
  "expires_at": "2026-10-22T11:00:00.000Z",
  "paid_at": null,
  "created_at": "2026-10-22T10:00:00.000Z"
}
```

`payment` starts null unless both `network` and `asset` are supplied. If tax needs a customer country, supply it before requesting an immediate quote or use hosted checkout. Creation and optional quoting are separate steps: a quote failure can leave a created invoice behind. Reconcile by `order_id` before retrying. Tax may change the final `amount`.

### GET /api/v1/invoices

List invoices, including tips and subscription invoices.

Required scope: Read (any valid key).

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| limit | integer (query) | No | Page size 1–100; default 20. |
| starting_after | string (query) | No | ID of the last object on the preceding page. |
| status | string (query) | No | `open`, `pending`, `confirming`, `partially_paid`, `paid`, `expired`, `canceled`. |

Response example:

```json
{
  "object": "list",
  "data": [],
  "has_more": false
}
```

Each `data` entry is an invoice object. `order_id`, customer, and subscription are not supported list filters.

### GET /api/v1/invoices/:id

Retrieve an invoice.

Required scope: Read (any valid key).

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| id | string (path) | Yes | Invoice ID belonging to this store and key mode. |

Response example:

```json
{
  "id": "inv_Example123",
  "object": "invoice",
  "livemode": false,
  "status": "open",
  "amount": "49.00",
  "currency": "USD",
  "subtotal": "49.00",
  "tax": null,
  "customer_country": null,
  "customer_tax_id": null,
  "amount_paid_usd": "0.00",
  "description": "Order A-1042",
  "order_id": "A-1042",
  "customer": null,
  "customer_email": null,
  "subscription": null,
  "metadata": {},
  "payment": null,
  "overpaid": false,
  "hosted_url": "https://z5.gg/pay/inv_Example123",
  "receipt_url": null,
  "redirect_url": null,
  "merchant": {
    "id": "mer_Example123",
    "name": "Example store"
  },
  "expires_at": "2026-10-22T11:00:00.000Z",
  "paid_at": null,
  "created_at": "2026-10-22T10:00:00.000Z"
}
```

Use this endpoint for reconciliation or moderate polling. Fulfil only after server-side confirmation of `paid`.

### POST /api/v1/invoices/:id/quote

Choose a coin or renew a quote for the unpaid remainder.

Required scope: `invoices`.

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| id | string (path) | Yes | Invoice ID belonging to this store and key mode. |
| network | string | Yes | Enabled and accepted network key. |
| asset | string | Yes | Accepted uppercase coin symbol. |

Response example:

```json
{
  "id": "inv_Example123",
  "object": "invoice",
  "livemode": false,
  "status": "pending",
  "amount": "49.00",
  "currency": "USD",
  "subtotal": "49.00",
  "tax": null,
  "customer_country": null,
  "customer_tax_id": null,
  "amount_paid_usd": "0.00",
  "description": "Order A-1042",
  "order_id": "A-1042",
  "customer": null,
  "customer_email": null,
  "subscription": null,
  "metadata": {},
  "payment": {
    "network": "tron",
    "asset": "USDT",
    "address": "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7",
    "rate_usd": 1,
    "amount_due": "49",
    "amount_paid": "0",
    "quote_expires_at": "2026-10-22T10:30:00.000Z",
    "uri": "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7",
    "qr_url": "https://z5.gg/pay/inv_Example123/qr"
  },
  "overpaid": false,
  "hosted_url": "https://z5.gg/pay/inv_Example123",
  "receipt_url": null,
  "redirect_url": null,
  "merchant": {
    "id": "mer_Example123",
    "name": "Example store"
  },
  "expires_at": "2026-10-22T11:00:00.000Z",
  "paid_at": null,
  "created_at": "2026-10-22T10:00:00.000Z"
}
```

Allowed only for `open`, `pending`, or `partially_paid` before `expires_at`, with resolved tax and no non-rejected refund. Repeating the same unexpired network/asset quote returns it unchanged. A different coin or expired quote resets `payment.amount_paid` for the new quote; `amount_paid_usd` retains credited history. A partially paid invoice remains recorded after expiry but cannot be newly quoted then. Use `payment.quote_expires_at` as returned; see Payment lifecycle rules.

### POST /api/v1/invoices/:id/cancel

Cancel an open or pending invoice.

Required scope: `invoices`.

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| id | string (path) | Yes | Invoice ID belonging to this store and key mode. |

Response example:

```json
{
  "id": "inv_Example123",
  "object": "invoice",
  "livemode": false,
  "status": "canceled",
  "amount": "49.00",
  "currency": "USD",
  "subtotal": "49.00",
  "tax": null,
  "customer_country": null,
  "customer_tax_id": null,
  "amount_paid_usd": "0.00",
  "description": "Order A-1042",
  "order_id": "A-1042",
  "customer": null,
  "customer_email": null,
  "subscription": null,
  "metadata": {},
  "payment": null,
  "overpaid": false,
  "hosted_url": "https://z5.gg/pay/inv_Example123",
  "receipt_url": null,
  "redirect_url": null,
  "merchant": {
    "id": "mer_Example123",
    "name": "Example store"
  },
  "expires_at": "2026-10-22T11:00:00.000Z",
  "paid_at": null,
  "created_at": "2026-10-22T10:00:00.000Z"
}
```

No request body. Only `open` or `pending` is accepted; repeated cancellation gets 400. Allowed while suspended. Cancellation prevents new quotes but cannot stop an already-sent blockchain transfer; late credited funds can still change an unrefunded invoice.

## Payments and deposits

### GET /api/v1/payments

List incoming transfers to invoices, customer addresses, and treasury top-up addresses.

Required scope: Read (any valid key).

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| limit | integer (query) | No | Page size 1–100; default 20. |
| starting_after | string (query) | No | ID of the last object on the preceding page. |
| invoice | string (query) | No | Invoice ID. |
| customer | string (query) | No | Customer/address-owner ID. |
| status | string (query) | No | `confirming`, `credited`, `held`, `declined`, `ignored`. |

Response example:

```json
{
  "object": "list",
  "data": [],
  "has_more": false
}
```

Each entry is a payment object. An invoice linked to a customer still has payments with `invoice` set and `customer: null`; the payment customer filter selects permanent-address deposits, not the invoice customer relationship.

### GET /api/v1/payments/:id

Retrieve one incoming payment.

Required scope: Read (any valid key).

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| id | string (path) | Yes | Payment ID belonging to this store and key mode. |

Response example:

```json
{
  "id": "pay_Example123",
  "object": "payment",
  "livemode": false,
  "status": "credited",
  "invoice": "inv_Example123",
  "customer": null,
  "network": "tron",
  "asset": "USDT",
  "amount": "49",
  "fee": "0.245",
  "net_amount": "48.755",
  "amount_usd": "49.00",
  "address": "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7",
  "from": null,
  "tx_hash": "sim_ExampleTransaction123",
  "explorer_url": null,
  "confirmations": 19,
  "confirmations_required": 19,
  "simulated": true,
  "detected_at": "2026-10-22T10:00:00.000Z",
  "credited_at": "2026-10-22T10:00:20.000Z"
}
```

The example is simulated and uses the normal Tron confirmation count; actual live thresholds come from each payment. `fee` and `net_amount` remain null until credited.

### GET /api/v1/deposits

List customer permanent-address deposits, excluding invoices and treasury top-ups.

Required scope: Read (any valid key).

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| limit | integer (query) | No | Page size 1–100; default 20. |
| starting_after | string (query) | No | ID of the last object on the preceding page. |
| customer | string (query) | No | Customer ID. |
| status | string (query) | No | `confirming`, `credited`, `held`, `declined`, `ignored`. |

Response example:

```json
{
  "object": "list",
  "data": [],
  "has_more": false
}
```

Each entry is a payment object. Credit your user once per payment ID on `deposit.confirmed`, normally using `net_amount` in the matching coin/network.

## Customers and deposit addresses

### POST /api/v1/customers

Create a customer, or return the customer already using your external ID.

Required scope: `invoices`.

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| email | string | No | Valid deposit-confirmation email; normalized to lowercase. |
| name | string | No | Name/reference; truncated to 120 characters. |
| external_id | string | No | Your user ID, truncated to 120 characters; idempotent per store/mode. |
| metadata | object | No | Custom JSON object, at most 4,000 serialized characters; arrays are not accepted. Default {}. |

Response example:

```json
{
  "id": "cus_Example123",
  "object": "customer",
  "livemode": false,
  "email": "buyer@example.com",
  "name": null,
  "external_id": "user_812",
  "metadata": {},
  "deposit_page_url": "https://z5.gg/d/ExampleCustomerToken",
  "deposits_count": 0,
  "deposits_usd": "0.00",
  "created_at": "2026-10-22T10:00:00.000Z"
}
```

An empty object is valid. Repeating an existing `external_id` does not update email, metadata, name, or coin restrictions. No public customer-update endpoint exists. Deposit addresses are allocated on demand.

### GET /api/v1/customers

List customers (excluding the treasury top-up customer).

Required scope: Read (any valid key).

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| limit | integer (query) | No | Page size 1–100; default 20. |
| starting_after | string (query) | No | ID of the last object on the preceding page. |
| external_id | string (query) | No | Exact external-ID match. |

Response example:

```json
{
  "object": "list",
  "data": [],
  "has_more": false
}
```

Each entry is a customer object.

### GET /api/v1/customers/:id

Retrieve a customer.

Required scope: Read (any valid key).

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| id | string (path) | Yes | Customer ID belonging to this store and key mode. |

Response example:

```json
{
  "id": "cus_Example123",
  "object": "customer",
  "livemode": false,
  "email": "buyer@example.com",
  "name": null,
  "external_id": "user_812",
  "metadata": {},
  "deposit_page_url": "https://z5.gg/d/ExampleCustomerToken",
  "deposits_count": 0,
  "deposits_usd": "0.00",
  "created_at": "2026-10-22T10:00:00.000Z"
}
```

### GET /api/v1/customers/:id/addresses

Return all currently accepted network deposit instructions for a customer.

Required scope: Read (any valid key).

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| id | string (path) | Yes | Customer ID belonging to this store and key mode. |

Response example:

```json
{
  "object": "list",
  "data": [
    {
      "network": "tron",
      "name": "Tron",
      "assets": [
        "USDT"
      ],
      "address": "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7",
      "uri": "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7",
      "qr_url": "https://z5.gg/d/ExampleCustomerToken/qr/tron"
    }
  ],
  "has_more": false
}
```

The example shows one network; the actual list depends on platform, store, and stored customer coin restrictions. Addresses are permanent per address family; EVM networks share an address. This GET can allocate missing addresses and is unavailable while suspended.

### GET /api/v1/customers/:id/addresses/:network

Return one customer deposit address with confirmation thresholds and a network warning.

Required scope: Read (any valid key).

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| id | string (path) | Yes | Customer ID belonging to this store and key mode. |
| network | string (path) | Yes | Network accepted for this customer. |

Response example:

```json
{
  "network": "tron",
  "name": "Tron",
  "assets": [
    "USDT"
  ],
  "address": "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7",
  "uri": "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7",
  "qr_url": "https://z5.gg/d/ExampleCustomerToken/qr/tron",
  "confirmations_required": 19,
  "confirmations_required_small": 6,
  "warning": "TRC-20 only. Not ERC-20 or BEP-20."
}
```

Unavailable/unaccepted network returns 400. Show the actual `warning` returned by the API. Unavailable while suspended. No `object` discriminator is returned for an address.

### GET /api/v1/customers/:id/deposits

List incoming payments to this customer address-owner ID.

Required scope: Read (any valid key).

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| id | string (path) | Yes | Customer ID belonging to this store and key mode. |
| limit | integer (query) | No | Page size 1–100; default 20. |
| starting_after | string (query) | No | ID of the last object on the preceding page. |

Response example:

```json
{
  "object": "list",
  "data": [],
  "has_more": false
}
```

Each entry is a payment object. Only pagination is supported here; use `/deposits` for `status` filtering. The handler filters by `customer_id`; unlike the global `/deposits` endpoint, it does not explicitly exclude treasury customers.

### GET /api/v1/customers/:id/payouts

List outgoing transfers associated with this customer, including invoice refunds.

Required scope: Read (any valid key).

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| id | string (path) | Yes | Customer ID belonging to this store and key mode. |
| limit | integer (query) | No | Page size 1–100; default 20. |
| starting_after | string (query) | No | ID of the last object on the preceding page. |

Response example:

```json
{
  "object": "list",
  "data": [],
  "has_more": false
}
```

Each entry uses the outgoing-transfer object shape. Only pagination is supported; use `/payouts` for additional filters.

## Plans and subscriptions

Subscriptions are prepaid, not automatic wallet debits. Create a plan, create a subscription, and send the customer to `pay_url`. Each paid invoice adds billing periods. Renewal invoices normally arrive three days before paid time ends (half a period for shorter plans); the customer pays again.

A subscription has one or more `items`, each a plan with a quantity (for example Pro × 1 and Extra seat × 3), billed together on one invoice. All items must renew on the same period (same `interval` and `interval_count`). Items can be changed later with `POST /api/v1/subscriptions/:id/change`.

### POST /api/v1/plans

Create a fixed-price billing plan.

Required scope: `invoices`.

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| name | string | Yes | Nonempty trimmed name; truncated to 120 characters. |
| amount | string or number | Yes | USD per period, 0.50–1,000,000.00. |
| interval | string | Yes | `day`, `week`, `month`, or `year`. |
| interval_count | integer | No | Default 1; maximum 365 days, 52 weeks, 36 months, or 3 years. |
| description | string | No | Trimmed description, truncated to 300 characters. |
| prepay | array of objects | No | Up to 5 distinct {periods, percent_off} options. periods: integer 2–36; percent_off: number 0–50, rounded to 2 decimals. Default []. |
| grace_days | integer | No | 0–30; default 7. Days allowed after paid time expires. |
| switchable | boolean | No | Default false. Customers on another plan can switch to this plan themselves from their subscription page (one-item subscriptions only). |

Response example:

```json
{
  "id": "plan_Example123",
  "object": "plan",
  "livemode": false,
  "name": "Pro",
  "description": null,
  "amount": "20.00",
  "currency": "USD",
  "interval": "month",
  "interval_count": 1,
  "prepay": [
    {
      "periods": 6,
      "percent_off": 10,
      "amount": "108.00"
    }
  ],
  "grace_days": 7,
  "active": true,
  "custom": false,
  "switchable": false,
  "created_at": "2026-10-22T10:00:00.000Z"
}
```

One billing period is `interval_count` intervals. A prepay response includes the total discounted USD `amount`. The price, interval, and interval count cannot be changed later; create another plan for new pricing.

### GET /api/v1/plans

List plans.

Required scope: Read (any valid key).

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| limit | integer (query) | No | Page size 1–100; default 20. |
| starting_after | string (query) | No | ID of the last object on the preceding page. |
| active | boolean (query) | No | Use `true` or `false`; omitted returns both active and archived plans. |
| custom | boolean (query) | No | `true` lists only custom plans (made from a `price` on a subscription item). Omitted or `false` leaves them out. |

Response example:

```json
{
  "object": "list",
  "data": [],
  "has_more": false
}
```

Each entry is a plan object.

### GET /api/v1/plans/:id

Retrieve a plan.

Required scope: Read (any valid key).

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| id | string (path) | Yes | Plan ID belonging to this store and key mode. |

Response example:

```json
{
  "id": "plan_Example123",
  "object": "plan",
  "livemode": false,
  "name": "Pro",
  "description": null,
  "amount": "20.00",
  "currency": "USD",
  "interval": "month",
  "interval_count": 1,
  "prepay": [
    {
      "periods": 6,
      "percent_off": 10,
      "amount": "108.00"
    }
  ],
  "grace_days": 7,
  "active": true,
  "custom": false,
  "switchable": false,
  "created_at": "2026-10-22T10:00:00.000Z"
}
```

### POST /api/v1/plans/:id

Update a plan or archive/reactivate it.

Required scope: `invoices`.

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| id | string (path) | Yes | Plan ID belonging to this store and key mode. |
| name | string | No | Nonempty trimmed name; truncated to 120 characters. |
| description | string | No | Trimmed description, truncated to 300 characters. |
| prepay | array of objects | No | Up to 5 distinct {periods, percent_off} options. periods: integer 2–36; percent_off: number 0–50, rounded to 2 decimals. Default []. |
| grace_days | integer | No | 0–30; default 7. Days allowed after paid time expires. |
| active | boolean | No | false archives; true reactivates. |
| switchable | boolean | No | Whether customers can switch to this plan themselves. Ignored for custom plans. |

Response example:

```json
{
  "id": "plan_Example123",
  "object": "plan",
  "livemode": false,
  "name": "Pro",
  "description": null,
  "amount": "20.00",
  "currency": "USD",
  "interval": "month",
  "interval_count": 1,
  "prepay": [
    {
      "periods": 6,
      "percent_off": 10,
      "amount": "108.00"
    }
  ],
  "grace_days": 7,
  "active": false,
  "custom": false,
  "switchable": false,
  "created_at": "2026-10-22T10:00:00.000Z"
}
```

Omitted fields are preserved. Archived plans reject new subscriptions; existing ones still renew. Updates use POST, not PATCH. Amount/interval fields are not update fields.

### POST /api/v1/subscriptions

Create an incomplete subscription and its first invoice.

Required scope: `invoices`.

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| plan | string | Conditional | Active plan ID in this store and mode. Required unless `items` is sent. |
| quantity | integer | No | With `plan`: 1–1,000, default 1. |
| items | array of objects | Conditional | Instead of `plan`: up to 20 items, each `{ "plan": "plan_…", "quantity": 2 }` or `{ "price": { "name", "amount", "interval", "interval_count" }, "quantity": 1 }`. Quantity 1–1,000, default 1. Repeated plans are merged. All items must share the same interval and interval_count. |
| customer | string | No | Customer ID in this store and mode. |
| customer_email | string | Conditional | Required unless the linked customer has an email; used for renewal notices. |
| periods | integer | No | Default 1, or a prepay length that every item's plan offers. |
| redirect_url | string | No | HTTP(S) destination after first payment; renewals return to manage_url. |
| metadata | object | No | Custom JSON object, at most 4,000 serialized characters; arrays are not accepted. Default {}. |

Response example:

```json
{
  "id": "sub_Example123",
  "object": "subscription",
  "livemode": false,
  "status": "incomplete",
  "plan": {
    "id": "plan_Example123",
    "object": "plan",
    "livemode": false,
    "name": "Pro",
    "description": null,
    "amount": "20.00",
    "currency": "USD",
    "interval": "month",
    "interval_count": 1,
    "prepay": [
      {
        "periods": 6,
        "percent_off": 10,
        "amount": "108.00"
      }
    ],
    "grace_days": 7,
    "active": true,
    "custom": false,
    "switchable": false,
    "created_at": "2026-10-22T10:00:00.000Z"
  },
  "items": [
    {
      "plan": { "id": "plan_Example123", "object": "plan", "name": "Pro", "amount": "20.00", "...": "full plan object" },
      "quantity": 1
    }
  ],
  "amount": "20.00",
  "interval": "month",
  "interval_count": 1,
  "pending_change": null,
  "customer": "cus_Example123",
  "customer_email": "buyer@example.com",
  "paid_through": null,
  "periods_paid": 0,
  "started_at": null,
  "cancel_at_period_end": false,
  "canceled_at": null,
  "cancel_reason": null,
  "latest_invoice": "inv_Subscription123",
  "pay_url": "https://z5.gg/pay/inv_Subscription123",
  "manage_url": "https://z5.gg/s/ExampleSubscriptionToken",
  "metadata": {},
  "created_at": "2026-10-22T10:00:00.000Z"
}
```

Example with several items and a custom price:

```json
{
  "items": [
    { "plan": "plan_Example123", "quantity": 1 },
    { "plan": "plan_Seat123", "quantity": 3 },
    { "price": { "name": "Acme deal", "amount": "49.00", "interval": "month", "interval_count": 1 }, "quantity": 1 }
  ],
  "customer_email": "buyer@example.com"
}
```

An item with `price` creates a custom plan just for this subscription (`custom: true`), left out of `GET /api/v1/plans` unless `?custom=true`. Archived plans are refused. The prepay options offered are the lengths every item's plan offers.

The response is the subscription, not an invoice wrapper; redirect to `pay_url`. Combined prepaid invoice totals must still satisfy invoice amount limits. There is no creation idempotency key. The subscription metadata is not copied wholesale into invoice metadata; subscription invoices carry their subscription ID and periods.

### GET /api/v1/subscriptions

List subscriptions.

Required scope: Read (any valid key).

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| limit | integer (query) | No | Page size 1–100; default 20. |
| starting_after | string (query) | No | ID of the last object on the preceding page. |
| status | string (query) | No | `incomplete`, `active`, `past_due`, `canceled`; any other value returns 400. |
| customer | string (query) | No | Customer ID. |

Response example:

```json
{
  "object": "list",
  "data": [],
  "has_more": false
}
```

Each entry is a subscription object.

### GET /api/v1/subscriptions/:id

Retrieve a subscription and current payment/manage links.

Required scope: Read (any valid key).

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| id | string (path) | Yes | Subscription ID belonging to this store and key mode. |

Response example:

```json
{
  "id": "sub_Example123",
  "object": "subscription",
  "livemode": false,
  "status": "incomplete",
  "plan": {
    "id": "plan_Example123",
    "object": "plan",
    "livemode": false,
    "name": "Pro",
    "description": null,
    "amount": "20.00",
    "currency": "USD",
    "interval": "month",
    "interval_count": 1,
    "prepay": [
      {
        "periods": 6,
        "percent_off": 10,
        "amount": "108.00"
      }
    ],
    "grace_days": 7,
    "active": true,
    "custom": false,
    "switchable": false,
    "created_at": "2026-10-22T10:00:00.000Z"
  },
  "items": [
    {
      "plan": { "id": "plan_Example123", "object": "plan", "name": "Pro", "amount": "20.00", "...": "full plan object" },
      "quantity": 1
    }
  ],
  "amount": "20.00",
  "interval": "month",
  "interval_count": 1,
  "pending_change": null,
  "customer": "cus_Example123",
  "customer_email": "buyer@example.com",
  "paid_through": null,
  "periods_paid": 0,
  "started_at": null,
  "cancel_at_period_end": false,
  "canceled_at": null,
  "cancel_reason": null,
  "latest_invoice": "inv_Subscription123",
  "pay_url": "https://z5.gg/pay/inv_Subscription123",
  "manage_url": "https://z5.gg/s/ExampleSubscriptionToken",
  "metadata": {},
  "created_at": "2026-10-22T10:00:00.000Z"
}
```

### POST /api/v1/subscriptions/:id/cancel

Stop renewals or end a subscription immediately.

Required scope: `invoices`.

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| id | string (path) | Yes | Subscription ID belonging to this store and key mode. |
| at_period_end | boolean | No | Default true; false ends immediately. |

Response example:

```json
{
  "id": "sub_Example123",
  "object": "subscription",
  "livemode": false,
  "status": "canceled",
  "plan": {
    "id": "plan_Example123",
    "object": "plan",
    "livemode": false,
    "name": "Pro",
    "description": null,
    "amount": "20.00",
    "currency": "USD",
    "interval": "month",
    "interval_count": 1,
    "prepay": [
      {
        "periods": 6,
        "percent_off": 10,
        "amount": "108.00"
      }
    ],
    "grace_days": 7,
    "active": true,
    "custom": false,
    "switchable": false,
    "created_at": "2026-10-22T10:00:00.000Z"
  },
  "items": [
    {
      "plan": { "id": "plan_Example123", "object": "plan", "name": "Pro", "amount": "20.00", "...": "full plan object" },
      "quantity": 1
    }
  ],
  "amount": "20.00",
  "interval": "month",
  "interval_count": 1,
  "pending_change": null,
  "customer": "cus_Example123",
  "customer_email": "buyer@example.com",
  "paid_through": null,
  "periods_paid": 0,
  "started_at": null,
  "cancel_at_period_end": false,
  "canceled_at": "2026-10-22T10:00:00.000Z",
  "cancel_reason": "requested",
  "latest_invoice": "inv_Subscription123",
  "pay_url": null,
  "manage_url": "https://z5.gg/s/ExampleSubscriptionToken",
  "metadata": {},
  "created_at": "2026-10-22T10:00:00.000Z"
}
```

Example: canceling an unpaid subscription. A subscription with future paid time normally retains its status with `cancel_at_period_end: true` until paid time ends. An incomplete or already-lapsed subscription ends immediately. Already canceled returns 400. Only untouched `open` invoices are canceled; pending/confirming payment attempts remain. No automatic refund. Allowed while suspended.

### POST /api/v1/subscriptions/:id/resume

Undo a scheduled cancellation before the subscription ends.

Required scope: `invoices`.

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| id | string (path) | Yes | Subscription ID belonging to this store and key mode. |

Response example:

```json
{
  "id": "sub_Example123",
  "object": "subscription",
  "livemode": false,
  "status": "active",
  "plan": {
    "id": "plan_Example123",
    "object": "plan",
    "livemode": false,
    "name": "Pro",
    "description": null,
    "amount": "20.00",
    "currency": "USD",
    "interval": "month",
    "interval_count": 1,
    "prepay": [
      {
        "periods": 6,
        "percent_off": 10,
        "amount": "108.00"
      }
    ],
    "grace_days": 7,
    "active": true,
    "custom": false,
    "switchable": false,
    "created_at": "2026-10-22T10:00:00.000Z"
  },
  "items": [
    {
      "plan": { "id": "plan_Example123", "object": "plan", "name": "Pro", "amount": "20.00", "...": "full plan object" },
      "quantity": 1
    }
  ],
  "amount": "20.00",
  "interval": "month",
  "interval_count": 1,
  "pending_change": null,
  "customer": "cus_Example123",
  "customer_email": "buyer@example.com",
  "paid_through": "2026-11-22T10:00:00.000Z",
  "periods_paid": 1,
  "started_at": "2026-10-22T10:00:00.000Z",
  "cancel_at_period_end": false,
  "canceled_at": null,
  "cancel_reason": null,
  "latest_invoice": "inv_Subscription123",
  "pay_url": null,
  "manage_url": "https://z5.gg/s/ExampleSubscriptionToken",
  "metadata": {},
  "created_at": "2026-10-22T10:00:00.000Z"
}
```

No body. Clears `cancel_at_period_end` and restarts renewal notices. No-op if no cancellation is scheduled. A fully canceled subscription returns 400; this endpoint does not revive it.

### POST /api/v1/subscriptions/:id/change

Change a subscription's items: another plan, a different quantity, or a custom price.

Required scope: `invoices`.

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| id | string (path) | Yes | Subscription ID belonging to this store and key mode. |
| items | array of objects | Conditional | The new items, same shape as on create. Replaces all current items. Required unless `plan` is sent. |
| plan | string | Conditional | Shortcut for one item. |
| quantity | integer | No | With `plan`: 1–1,000, default 1. |
| timing | string | No | `now` or `period_end`. Default: `now` when the new items cost more per period or have a different period length, `period_end` when cheaper. |

Response example (Supporter $5/month to Pro $15/month, 10 days into a 30-day month):

```json
{
  "subscription": {
    "id": "sub_Example123",
    "object": "subscription",
    "status": "active",
    "amount": "5.00",
    "paid_through": "2026-11-22T10:00:00.000Z",
    "pending_change": {
      "id": "chg_Example123",
      "items": [{ "plan": { "id": "plan_Pro123", "name": "Pro", "amount": "15.00", "...": "full plan object" }, "quantity": 1 }],
      "timing": "now",
      "amount_due": "6.67",
      "credit": "3.33",
      "invoice": "inv_Change123",
      "pay_url": "https://z5.gg/pay/inv_Change123",
      "created_at": "2026-11-01T10:00:00.000Z"
    },
    "...": "rest of the subscription object"
  },
  "invoice": { "id": "inv_Change123", "object": "invoice", "amount": "6.67", "...": "rest of the invoice object" }
}
```

Returns `{ "subscription": Subscription, "invoice": Invoice | null }`. `invoice` is the invoice to pay for the change, or null when there is nothing to pay now.

How it works, by state and timing:

- Active, `now`, same period length: the unused part of the current paid time is credited at what the customer actually paid per period (prepay discounts included). The invoice is the rest of the current period at the new price minus that credit. When it's paid, the items change and `paid_through` stays the same.
- Active, `now`, different period length (for example monthly to yearly): the invoice is the new items' first full period minus the credit, and a new period starts when it's paid. If the credit is worth more than that full period, the request is refused; use `period_end`.
- Less than $0.50 due: the change is applied at once without an invoice.
- The change invoice expires after at most 3 days and never after `paid_through`. If it isn't paid, the change lapses and the subscription stays as it was.
- `now` for cheaper items on the same period length returns 400. Paid time isn't refunded, so use `period_end`.
- Active, `period_end`: nothing is charged now. The next renewal invoice is for the new items, and the change is applied when that renewal is paid.
- `incomplete` or `past_due`: the items change straight away and `invoice` is the invoice to pay for the new items.

Worked example: Supporter costs $5/month, Pro $15/month. 10 days into a 30-day month, 20 days are left. Unused credit: $5 × 20/30 = $3.33. Rest of the month on Pro: $15 × 20/30 = $10.00. Due now: $10.00 − $3.33 = $6.67. After payment the subscription is on Pro, the renewal date is unchanged, and the next renewal is $15.00.

Only one change waits at a time; a new request replaces the waiting one (and cancels its unpaid invoice). Sending the current items again just drops the waiting change. Canceling the subscription drops a waiting change. A subscription with `cancel_at_period_end: true` can't be changed until it's resumed (400), and a canceled one can't be changed at all. Archived plans are only accepted if they're already on the subscription. Each step (scheduled, requested, applied, called off) emits `subscription.updated`.

### POST /api/v1/subscriptions/:id/change/cancel

Call off a waiting change, whether it's waiting for its payment or for the next renewal. Its invoice is canceled if nothing was paid on it yet.

Required scope: `invoices`.

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| id | string (path) | Yes | Subscription ID belonging to this store and key mode. |

No body. Returns the subscription object with `pending_change: null`. Returns 400 if no change is waiting. Allowed while suspended. Emits `subscription.updated`.

## Payouts, withdrawals, and refunds

Outgoing transfers reserve the requested amount and any top-up exit fee immediately, from the same coin/network balance. There is no automatic exchange or cross-network conversion. Store payout rules apply to user payouts, while platform limits, screening, address checks, and network minimums also apply to withdrawals/refunds. The examples below assume enough fee-paid balance and a valid external destination; real status may be `review`, `pending`, or `approved`.

### POST /api/v1/payouts

Pay a user or other external recipient from your balance.

Required scope: `payouts`.

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| network | string | Yes | Supported network key; consult /networks and balance availability. |
| asset | string | Yes | Coin symbol; normalized to uppercase. |
| amount | string | Yes | Positive coin amount within asset precision, or `max` for the sendable balance after any exit fee. |
| address | string | Yes | External destination on the same network; format/checksum validated. |
| external_id | string | No | Stable idempotency reference; truncated to 120 characters. |
| customer | string | No | Customer ID for history and per-user payout rules. |
| metadata | object | No | Custom JSON object, at most 4,000 serialized characters; arrays are not accepted. Default {}. |

Response example:

```json
{
  "id": "po_Example123",
  "object": "payout",
  "livemode": false,
  "status": "approved",
  "customer": "cus_Example123",
  "invoice": null,
  "external_id": "cashout_812_1",
  "metadata": {},
  "network": "tron",
  "asset": "USDT",
  "amount": "20",
  "fee": "0",
  "amount_usd": "20.00",
  "address": "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7",
  "bridged_token": null,
  "tx_hash": null,
  "explorer_url": null,
  "error": null,
  "review_reasons": [],
  "reviewed_at": null,
  "created_at": "2026-10-22T10:00:00.000Z",
  "processed_at": null
}
```

Insufficient same-network liquidity, below-minimum amount, blocked destination, invalid address, or a rejecting rule returns 400. Never use a token contract or a zerofive-owned deposit address as destination. Live Solana token payouts require the recipient token account to exist. `max` uses the entire available coin/network pool, not a customer-specific balance. Incoming deposit ownership does not maintain your app user balances for you.

### GET /api/v1/payouts

List payouts and refunds.

Required scope: Read (any valid key).

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| limit | integer (query) | No | Page size 1–100; default 20. |
| starting_after | string (query) | No | ID of the last object on the preceding page. |
| customer | string (query) | No | Customer ID. |
| external_id | string (query) | No | Exact idempotency reference. |
| status | string (query) | No | `review`, `pending`, `approved`, `processing`, `sent`, `failed`, `rejected`. |

While `processing` on an EVM chain, a payout stuck under a gas price the network has moved past is re-signed with a higher fee (paid by the platform, capped per payout): the same transfer and nonce, so only one transaction can land. `tx_hash` then changes to the newest attempt; `payout.sent` carries the hash that landed.
| kind | string (query) | No | `payout` or `refund`. |

Response example:

```json
{
  "object": "list",
  "data": [],
  "has_more": false
}
```

List entries use `object: "payout"` or `object: "refund"`; there is no returned `kind` field. Store withdrawals are excluded.

### GET /api/v1/payouts/:id

Retrieve an outgoing transfer by ID.

Required scope: Read (any valid key).

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| id | string (path) | Yes | Payout/refund ID belonging to this store and key mode. |

Response example:

```json
{
  "id": "po_Example123",
  "object": "payout",
  "livemode": false,
  "status": "approved",
  "customer": "cus_Example123",
  "invoice": null,
  "external_id": "cashout_812_1",
  "metadata": {},
  "network": "tron",
  "asset": "USDT",
  "amount": "20",
  "fee": "0",
  "amount_usd": "20.00",
  "address": "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7",
  "bridged_token": null,
  "tx_hash": null,
  "explorer_url": null,
  "error": null,
  "review_reasons": [],
  "reviewed_at": null,
  "created_at": "2026-10-22T10:00:00.000Z",
  "processed_at": null
}
```

The handler checks store/mode ownership but does not enforce a particular outgoing kind; inspect `object`. Refunds are retrieved here.

### POST /api/v1/payouts/:id/approve

Approve a transfer waiting for your store review.

Required scope: `payouts`.

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| id | string (path) | Yes | Payout/refund ID belonging to this store and key mode. |

Response example:

```json
{
  "id": "po_Example123",
  "object": "payout",
  "livemode": false,
  "status": "approved",
  "customer": "cus_Example123",
  "invoice": null,
  "external_id": "cashout_812_1",
  "metadata": {},
  "network": "tron",
  "asset": "USDT",
  "amount": "20",
  "fee": "0",
  "amount_usd": "20.00",
  "address": "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7",
  "bridged_token": null,
  "tx_hash": null,
  "explorer_url": null,
  "error": null,
  "review_reasons": [
    "First payout for this user"
  ],
  "reviewed_at": "2026-10-22T10:00:00.000Z",
  "created_at": "2026-10-22T10:00:00.000Z",
  "processed_at": null
}
```

No body. Only `review` is accepted. Returns `approved` if platform checks allow it, otherwise `pending`; approval is not confirmation of sending. Platform-held `pending` and `failed` transfers cannot be approved through this endpoint. A customer-requested overpayment refund in `review` can also use it.

### POST /api/v1/payouts/:id/reject

Reject a store-held transfer and reverse its balance reservation.

Required scope: `payouts`.

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| id | string (path) | Yes | Payout/refund ID belonging to this store and key mode. |
| reason | string | No | Trimmed explanation up to 200 characters; default Rejected by the store. |

Response example:

```json
{
  "id": "po_Example123",
  "object": "payout",
  "livemode": false,
  "status": "rejected",
  "customer": "cus_Example123",
  "invoice": null,
  "external_id": "cashout_812_1",
  "metadata": {},
  "network": "tron",
  "asset": "USDT",
  "amount": "20",
  "fee": "0",
  "amount_usd": "20.00",
  "address": "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7",
  "bridged_token": null,
  "tx_hash": null,
  "explorer_url": null,
  "error": "Rejected by the store",
  "review_reasons": [
    "First payout for this user"
  ],
  "reviewed_at": "2026-10-22T10:00:00.000Z",
  "created_at": "2026-10-22T10:00:00.000Z",
  "processed_at": "2026-10-22T10:00:00.000Z"
}
```

Only `review` is accepted. Reverses the amount and any exit fee, emits the relevant rejected event, and remains allowed during suspension. Do not treat `failed` as already reversed.

### POST /api/v1/withdrawals

Send your store balance to your own payout wallet.

Required scope: `withdrawals`.

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| network | string | Yes | Supported network key; consult /networks and balance availability. |
| asset | string | Yes | Coin symbol; normalized to uppercase. |
| amount | string | Yes | Positive coin amount within asset precision, or `max` for the sendable balance after any exit fee. |
| address | string | Yes | External destination on the same network; format/checksum validated. |
| external_id | string | No | Stable idempotency reference; truncated to 120 characters. |

Response example:

```json
{
  "id": "wd_Example123",
  "object": "withdrawal",
  "livemode": false,
  "status": "approved",
  "customer": null,
  "invoice": null,
  "external_id": "store_cashout_1",
  "metadata": {},
  "network": "tron",
  "asset": "USDT",
  "amount": "20",
  "fee": "0",
  "amount_usd": "20.00",
  "address": "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7",
  "bridged_token": null,
  "tx_hash": null,
  "explorer_url": null,
  "error": null,
  "review_reasons": [],
  "reviewed_at": null,
  "created_at": "2026-10-22T10:00:00.000Z",
  "processed_at": null
}
```

Live destinations must be saved payout addresses at least 24 hours old; owners receive a security email. These checks are skipped for test transfers. Store payout rules do not apply, but platform checks and minimums do. `customer` and `metadata` are not accepted creation fields for this route.

### GET /api/v1/withdrawals

List store withdrawals only.

Required scope: Read (any valid key).

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| limit | integer (query) | No | Page size 1–100; default 20. |
| starting_after | string (query) | No | ID of the last object on the preceding page. |

Response example:

```json
{
  "object": "list",
  "data": [],
  "has_more": false
}
```

Each entry has `object: "withdrawal"`. No status or external-ID filter is implemented here; paginate and match locally if needed.

### GET /api/v1/withdrawals/:id

Retrieve an outgoing transfer using the withdrawal route.

Required scope: Read (any valid key).

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| id | string (path) | Yes | Withdrawal ID belonging to this store and key mode. |

Response example:

```json
{
  "id": "wd_Example123",
  "object": "withdrawal",
  "livemode": false,
  "status": "approved",
  "customer": null,
  "invoice": null,
  "external_id": "store_cashout_1",
  "metadata": {},
  "network": "tron",
  "asset": "USDT",
  "amount": "20",
  "fee": "0",
  "amount_usd": "20.00",
  "address": "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7",
  "bridged_token": null,
  "tx_hash": null,
  "explorer_url": null,
  "error": null,
  "review_reasons": [],
  "reviewed_at": null,
  "created_at": "2026-10-22T10:00:00.000Z",
  "processed_at": null
}
```

The handler checks store/mode ownership but does not restrict the outgoing kind; inspect `object`.

### POST /api/v1/invoices/:id/refund

Refund credited invoice funds to a customer-supplied address.

Required scope: `payouts`.

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| id | string (path) | Yes | Invoice ID belonging to this store and key mode. |
| address | string | Yes | Customer-confirmed refund address on the original payment network. |
| amount | string | No | Coin amount; omitted refunds all still refundable units in the first credited coin/network. `max` is not supported. |
| external_id | string | No | Stable reference for safe retries; up to 120 characters. |
| reason | string | No | Truncated to 300 characters; stored in metadata.reason and included in refund email. |

Response example:

```json
{
  "id": "ref_Example123",
  "object": "refund",
  "livemode": false,
  "status": "approved",
  "customer": null,
  "invoice": "inv_Example123",
  "external_id": "refund_A1042_1",
  "metadata": {
    "reason": "Partial refund"
  },
  "network": "tron",
  "asset": "USDT",
  "amount": "10",
  "fee": "0",
  "amount_usd": "10.00",
  "address": "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7",
  "bridged_token": null,
  "tx_hash": null,
  "explorer_url": null,
  "error": null,
  "review_reasons": [],
  "reviewed_at": null,
  "created_at": "2026-10-22T10:00:00.000Z",
  "processed_at": null
}
```

Uses the first credited payment network/asset; if payments mixed coins, only payments in that pair determine the refundable cap. Earlier refunds in every status except `rejected` reduce the cap, including `failed` refunds. Requires enough current same-coin balance; the original processing fee is not returned, so a full gross refund may require more liquidity. Store payout rules are skipped, but platform controls and network minimums apply. Emits `payout.*` and, when sent, `invoice.refunded`. An unknown invoice here returns 400 `invalid_request`.

## Payout rules

Rule names are camelCase, exactly as below. Rules are shared by live and test mode. Per-user means the customer ID, or destination address when no customer is supplied. Rolling-hour/day totals count non-rejected payouts, including held, pending, and failed ones. `holdAll` remains effective even when `enabled` is false; platform controls remain effective regardless of store rules.

### GET /api/v1/payout-rules

Read current store payout rules.

Required scope: Read (any valid key).

Request fields: none.

Response example:

```json
{
  "object": "payout_rules",
  "enabled": true,
  "holdAll": false,
  "autoApproveMaxUsd": 1000,
  "maxPayoutUsd": null,
  "minPayoutUsd": null,
  "perUserDailyUsd": null,
  "perUserDailyCount": null,
  "storeDailyUsd": null,
  "storeHourlyCount": null,
  "reviewFirstPayout": false,
  "reviewNewAddress": false,
  "depositCooldownMinutes": null,
  "requireCustomer": false,
  "blockedAddresses": [],
  "allowedAddresses": [],
  "assetMaxUsd": {},
  "networks": []
}
```

Example shows defaults; use returned values for your store.

### POST /api/v1/payout-rules

Update selected payout rules.

Required scope: `rules`, on an owner-created live key.

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| enabled | boolean | No | Enable other rules; default true. |
| holdAll | boolean | No | Hold every new user payout for review; default false, works even if enabled is false. |
| autoApproveMaxUsd | number or null | No | Above this USD amount, review; default 1000. |
| maxPayoutUsd | number or null | No | Above this USD amount, reject. |
| minPayoutUsd | number or null | No | Below this USD amount, reject; cannot exceed maxPayoutUsd. |
| perUserDailyUsd | number or null | No | Rolling 24-hour per-user amount; excess requires review. |
| perUserDailyCount | integer or null | No | Rolling 24-hour per-user count, 0–10,000; excess requires review. |
| storeDailyUsd | number or null | No | Rolling 24-hour store total; excess requires review. |
| storeHourlyCount | integer or null | No | Rolling-hour store count, 0–1,000,000; excess requires review. |
| reviewFirstPayout | boolean | No | Review until this user has a sent payout; default false. |
| reviewNewAddress | boolean | No | Review addresses with no prior sent payout for this user; default false. |
| depositCooldownMinutes | integer or null | No | 0–43,200; review if customer deposited within this interval (confirming or credited payments). |
| requireCustomer | boolean | No | Reject payouts without a customer; default false. |
| blockedAddresses | array of strings | No | Reject these addresses. Maximum 2,000; current validation accepts alphanumeric strings up to 120 characters. |
| allowedAddresses | array of strings | No | If nonempty, other addresses require review; same validation as blockedAddresses. |
| assetMaxUsd | object | No | Map uppercase symbols to USD review thresholds, e.g. {"USDT":500}; supplied object replaces the map. |
| networks | array of strings | No | Network keys allowed for payouts; [] means all. Other networks are rejected. |

Response example:

```json
{
  "object": "payout_rules",
  "enabled": true,
  "holdAll": false,
  "autoApproveMaxUsd": 500,
  "maxPayoutUsd": null,
  "minPayoutUsd": null,
  "perUserDailyUsd": null,
  "perUserDailyCount": null,
  "storeDailyUsd": null,
  "storeHourlyCount": null,
  "reviewFirstPayout": false,
  "reviewNewAddress": false,
  "depositCooldownMinutes": null,
  "requireCustomer": false,
  "blockedAddresses": [],
  "allowedAddresses": [],
  "assetMaxUsd": {},
  "networks": []
}
```

Omitted fields stay unchanged; arrays and maps replace their previous value. Nullable limits use null to disable a limit; zero is a real limit. Numeric USD limits accept 0–1,000,000,000. Integer limit fields are floored. Use real network keys from `/networks`; this handler does not validate network-array members. An otherwise authorized test key gets 400; a non-owner-created key gets HTTP 403 with `permission`. No transfer is initiated by this endpoint, but the policy applies to both modes immediately.

## Balances and top-ups

### GET /api/v1/balances

Read available balances by network and coin.

Required scope: Read (any valid key).

Request fields: none.

Response example:

```json
{
  "object": "list",
  "data": [
    {
      "object": "balance",
      "network": "tron",
      "asset": "USDT",
      "amount": "248.755",
      "on_hold": "0",
      "on_hold_until": null,
      "fee_free_liquidity": "200",
      "amount_usd": "248.76"
    }
  ],
  "has_more": false
}
```

All nonzero balances are returned, sorted by estimated current USD value descending. Reserved outgoing funds are already deducted. `amount_usd` is null when no reference price is available; it is not an exchange quote. Fee-paid funds are spent before top-up liquidity.

New-store hold: in a store's first 30 days of taking live payments (counted from its first live customer payment), each customer payment can be sent out only 72 hours after it is credited during the first 7 days, and 24 hours after it is credited from day 8 to day 30. `on_hold` is the part of `amount` that cannot leave yet and `on_hold_until` (ISO 8601, or null) is when the next held part frees up. Withdrawals, payouts and refunds can spend `amount - on_hold`; asking for more fails with 400 `invalid_request` and a message stating the held amount. `"max"` sends only the free part. Treasury top-ups are never held, the payer's own overpayment claim is not held, and test mode has no hold. The platform can lift the hold for a store early.

### GET /api/v1/topup

Get your store treasury deposit addresses for payout liquidity.

Required scope: Read (any valid key).

Request fields: none.

Response example:

```json
{
  "object": "topup",
  "livemode": false,
  "fee": "0",
  "addresses": [
    {
      "network": "tron",
      "name": "Tron",
      "assets": [
        "USDT"
      ],
      "address": "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7",
      "uri": "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7",
      "qr_url": "https://z5.gg/d/ExampleCustomerToken/qr/tron"
    }
  ]
}
```

The real addresses list includes the enabled platform networks/assets, independently of store/key collection restrictions. Addresses are allocated if needed; unavailable while suspended. This is your treasury, not a user deposit account. No incoming fee: `topup.confirmed` reports credited liquidity. The fee-free portion later spent incurs the standard processing fee, shown as outgoing `fee`. Never credit an app user from `topup.confirmed`.

## Reports, exports, and statements

### GET /api/v1/balance_transactions

Every movement of the balance in the key's mode, newest first, one object per event (a payment and its fee are one object; so are a transfer and the fee on top-up money leaving). Paginated; accepts the `created[...]` range plus `type`, `network`, `asset`.

| Field | Type | Description |
| --- | --- | --- |
| type | string | `payment`, `topup`, `withdrawal`, `payout`, `refund`, `withdrawal_reversal` (a transfer returned to the balance), `adjustment`. |
| amount | decimal string | What moved, in the coin; negative going out. |
| fee | decimal string | Fee paid for it (negative when a fee was returned). |
| net | decimal string | Change to the balance: amount minus fee. |
| amount_usd, fee_usd, net_usd | decimal string or null | USD at the time. |
| source | string | The payment or transfer ID. |
| invoice, customer, external_id | string or null | Related objects. |
| tx_hash, explorer_url | string or null | Null for simulated transfers. |
| available_at | ISO time | When the money could be sent on (later than created_at while a new store's payments are held). |

### POST /api/v1/exports

Creates a file of records for a date range; any key may, including read-only keys. Body: `type` (`balance_transactions`, `invoices`, `payments`, `payouts` = withdrawals, payouts and refunds, `customers`, `subscriptions`), `format` (`csv` default, `json` = the API objects, or for `balance_transactions` only: `xero`, `quickbooks`, `koinly`, `cointracking`, laid out for that tool's import), `from` (inclusive) and `to` (exclusive), ISO 8601 or Unix seconds. Returns an export object with `status: "pending"`; at most 5 pending per store.

```json
{"id":"exp_qh0oEefrEejyhzQrsKW9","object":"export","livemode":false,"type":"invoices","format":"csv","status":"ready","from":"2026-09-01T00:00:00.000Z","to":"2026-10-01T00:00:00.000Z","rows":42,"size":10817,"error":null,"download_url":"https://z5.gg/api/v1/exports/exp_qh0oEefrEejyhzQrsKW9/download","filename":"invoices_2026-09-01_to_2026-09-30.csv","created_at":"2026-10-22T21:12:33.000Z","finished_at":"2026-10-22T21:12:35.000Z","expires_at":"2026-10-29T21:12:35.000Z"}
```

`GET /api/v1/exports` lists recent exports, `GET /api/v1/exports/:id` returns one (poll until `ready` or `failed`), `GET /api/v1/exports/:id/download` returns the file with the same key. Files are kept 7 days. Subscription CSVs have `plan` and `plan_name` (the first item), `items` (like `Pro × 1; Seat × 3`), `amount_usd` (one period of all items), `interval` and `interval_count`, plus status, customer, and billing dates. CSV is UTF-8 with a BOM; cells that would start a spreadsheet formula are prefixed with `'`.

Accounting formats (all CSV, UTF-8 without a BOM, dates in UTC; `rows` counts lines, and a movement with a fee can be two):

| format | Columns | Notes |
| --- | --- | --- |
| `xero` | `*Date,*Amount,Payee,Description,Reference` | Xero bank statement import. USD, money in positive, out negative; the fee is its own line. Date YYYY/MM/DD. |
| `quickbooks` | `Date,Description,Amount` | QuickBooks Online bank upload (3-column). USD, fee as its own line. Date dd/mm/yyyy. Up to 1,000 lines per upload. |
| `koinly` | `Date,Sent Amount,Sent Currency,Received Amount,Received Currency,Fee Amount,Fee Currency,Net Worth Amount,Net Worth Currency,Tag,Description,TxHash` | Koinly universal template, coin amounts with USD worth. Payments tagged `other income`; top-ups, withdrawals, payouts and refunds untagged. |
| `cointracking` | `Type,Buy Amount,Buy Currency,Sell Amount,Sell Currency,Fee,Fee Currency,Exchange,Trade-Group,Comment,Date,Liquidity pool,Tx-ID` | CoinTracking CSV import. Payments `Income` plus an `Other Fee` line, top-ups `Deposit`, withdrawals `Withdrawal`, payouts and refunds `Spend` (amount includes the fee). Date DD.MM.YYYY HH:MM:SS; import with time zone UTC. |

### GET /api/v1/invoices/:id/pdf

The receipt PDF of a paid invoice; 400 for unpaid invoices. Paid invoices also carry `receipt_url` and `receipt_pdf_url`.

### Monthly statements

Dashboard → Reports shows a statement per calendar month (UTC): opening and closing balance per coin, flows (payments, fees, top-ups, withdrawals, payouts, refunds, returned transfers, adjustments) and USD totals at the time, as a page, PDF, and CSV of the month's balance transactions. On the 1st, last month's live statement is emailed to recipients with "Monthly statement" on (Settings → Emails → Recipients). There is no statement API endpoint; use balance transactions or exports.

## Tips

### POST /api/v1/tips

Create a tip invoice from your server.

Required scope: `invoices`.

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| amount | string or number | Yes | USD, 0.50–100,000.00. |
| name | string | No | Public supporter name; normalized to one line, truncated to 40 characters. |
| message | string | No | Public supporter message; normalized to one line, truncated to 500 characters. |
| email | string | No | Receipt email; not exposed in public tip endpoints. |
| network | string | With asset | Enabled and accepted network key. |
| asset | string | With network | Uppercase accepted coin symbol. |
| redirect_url | string or null | No | HTTP(S) return URL; defaults to the store tip page when configured. null disables the default. |
| metadata | object | No | Custom JSON object, at most 4,000 serialized characters; arrays are not accepted. Default {}. |

Response example:

```json
{
  "id": "inv_Tip123",
  "object": "tip",
  "livemode": false,
  "status": "open",
  "amount": "5.00",
  "currency": "USD",
  "name": "Satoshi",
  "message": "Thank you",
  "email": null,
  "hidden": false,
  "metadata": {},
  "payment": null,
  "hosted_url": "https://z5.gg/pay/inv_Tip123",
  "receipt_url": null,
  "expires_at": "2026-10-22T11:00:00.000Z",
  "paid_at": null,
  "created_at": "2026-10-22T10:00:00.000Z"
}
```

Works with the public tip page enabled or disabled, without its minimum/required-name/message restrictions. The invoice lifetime is 60 minutes. `metadata.tip` is reserved and replaced by the service. IDs are `inv_` IDs and work with invoice quote/refund endpoints. Optional quote failure can leave the tip invoice created. A paid tip emits both `invoice.paid` and `tip.received`; avoid counting the same receipt twice.

### GET /api/v1/tips

List tip invoices.

Required scope: Read (any valid key).

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| limit | integer (query) | No | Page size 1–100; default 20. |
| starting_after | string (query) | No | ID of the last object on the preceding page. |
| status | string (query) | No | `open`, `pending`, `confirming`, `partially_paid`, `paid`, `expired`, `canceled`. |

Response example:

```json
{
  "object": "list",
  "data": [],
  "has_more": false
}
```

Each entry is a tip object; use status=paid for received tips.

### GET /api/v1/tips/:id

Retrieve a tip.

Required scope: Read (any valid key).

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| id | string (path) | Yes | Tip invoice ID belonging to this store and key mode. |

Response example:

```json
{
  "id": "inv_Tip123",
  "object": "tip",
  "livemode": false,
  "status": "open",
  "amount": "5.00",
  "currency": "USD",
  "name": "Satoshi",
  "message": "Thank you",
  "email": null,
  "hidden": false,
  "metadata": {},
  "payment": null,
  "hosted_url": "https://z5.gg/pay/inv_Tip123",
  "receipt_url": null,
  "expires_at": "2026-10-22T11:00:00.000Z",
  "paid_at": null,
  "created_at": "2026-10-22T10:00:00.000Z"
}
```

An ordinary non-tip invoice ID returns 404 here.

### POST /api/v1/tips/:id

Hide or show a tip in public supporter lists and overlays.

Required scope: `invoices`.

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| id | string (path) | Yes | Tip invoice ID belonging to this store and key mode. |
| hidden | boolean | Yes | true hides; false shows. Must be a JSON boolean. |

Response example:

```json
{
  "id": "inv_Tip123",
  "object": "tip",
  "livemode": false,
  "status": "open",
  "amount": "5.00",
  "currency": "USD",
  "name": "Satoshi",
  "message": "Thank you",
  "email": null,
  "hidden": true,
  "metadata": {},
  "payment": null,
  "hosted_url": "https://z5.gg/pay/inv_Tip123",
  "receipt_url": null,
  "expires_at": "2026-10-22T11:00:00.000Z",
  "paid_at": null,
  "created_at": "2026-10-22T10:00:00.000Z"
}
```

Does not change financial state, receipts, or payment history.

## Public tip integrations

These documented browser endpoints are outside `/api/v1` and use no Bearer key. They allow cross-origin browser requests. The public page must be enabled to create tips or read its feed; its configured minimum and name/message requirements apply. Use server-side signed webhooks for authoritative credit or fulfilment.

### POST /api/tip/:slug

Create a tip for an enabled public tip page.

Required scope: None (public).

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| slug | string (path) | Yes | Store tip-page slug. |
| amount | string or number | Yes | USD; configured page minimum up to 100,000.00. |
| name | string | Conditional | Required/disabled/optional per page settings; maximum 40 characters. |
| message | string | Conditional | Required/disabled/optional per page settings; truncated to its configured maximum. |
| email | string | No | Receipt email. |
| test | boolean | No | true creates a simulated tip; omitted/false is live. |
| network | string | With asset | Store-accepted enabled network. |
| asset | string | With network | Accepted uppercase symbol. |

Response example:

```json
{
  "id": "inv_Tip123",
  "status": "open",
  "amount": "5.00",
  "currency": "USD",
  "name": "Satoshi",
  "message": "Thank you",
  "payment": null,
  "hosted_url": "https://z5.gg/pay/inv_Tip123",
  "expires_at": "2026-10-22T11:00:00.000Z",
  "paid_at": null,
  "url": "/pay/inv_Tip123",
  "status_url": "https://z5.gg/api/tip/example-store/inv_Tip123"
}
```

Limit: 20 creations per 10 minutes per visitor IP. Returns public tip fields plus relative `url` and absolute `status_url`. Default mode is live, unlike a test API key flow; explicitly send test=true while developing. Normal errors are {"error":"message"} with 400, 403 (suspended), 404, or 429. Metadata and redirect overrides are not request fields.

### OPTIONS /api/tip/:slug

Answer the public tip creation CORS preflight.

Required scope: none (public).

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| slug | string (path) | Yes | Tip-page slug. |

Response: HTTP 204 with no body, not JSON. Headers allow origin `*`, methods `POST, OPTIONS`, and request header `content-type`.

### GET /api/tip/:slug/:id

Read a public tip status.

Required scope: None (public).

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| slug | string (path) | Yes | Store tip-page slug. |
| id | string (path) | Yes | Tip invoice ID belonging to that store. |

Response example:

```json
{
  "id": "inv_Tip123",
  "status": "open",
  "amount": "5.00",
  "currency": "USD",
  "name": "Satoshi",
  "message": "Thank you",
  "payment": null,
  "hosted_url": "https://z5.gg/pay/inv_Tip123",
  "expires_at": "2026-10-22T11:00:00.000Z",
  "paid_at": null
}
```

Limit: 120 requests/minute per visitor IP. Follow the creation response status_url. No email, private metadata, object discriminator, or livemode field is exposed. Unknown tip returns 404; rate limiting returns 429, both with a plain error string.

### GET /api/tip/:slug/feed

Read recent visible paid tips and aggregate totals.

Required scope: None; overlay key needed if the supporter wall is private.

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| slug | string (path) | Yes | Enabled tip-page slug. |
| limit | integer (query) | No | 1–50, default 20. |
| since | number (query) | No | Unix milliseconds; return tips paid after this time. |
| test | string (query) | No | `1` selects test tips; otherwise live. |
| key | string (query) | Conditional | Overlay key from Dashboard → Tips when public supporters are disabled. |

Response example:

```json
{
  "store": "Example store",
  "total": "5.00",
  "count": 1,
  "goal": null,
  "tips": [
    {
      "id": "tip_PublicExample",
      "name": "Satoshi",
      "message": "Thank you",
      "amount": "5.00",
      "at": "2026-10-22T10:00:20.000Z"
    }
  ]
}
```

Hidden tips are omitted from `tips`, but aggregate `total` and `count` include all paid tips, including hidden ones, and are not limited by `since`. The limit is applied before filtering hidden entries, so a page may contain fewer results than requested. Feed IDs start `tip_` and are opaque public identifiers, not invoice IDs. Unavailable/private feed without a valid key returns 404 {"error":"Not found"}. This is not a cursor-paginated list. Do not embed the overlay key in publicly distributed code unless you intend to grant that access.

## Network discovery

### GET /api/v1/networks

List currently enabled platform payment networks and coins.

Required scope: None (public).

Request fields: none.

Response example:

```json
{
  "object": "list",
  "data": [
    {
      "network": "tron",
      "name": "Tron",
      "assets": [
        {
          "symbol": "TRX",
          "contract": null,
          "decimals": 6
        },
        {
          "symbol": "USDT",
          "contract": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
          "decimals": 6
        }
      ],
      "confirmations_required": 19,
      "eta": "about 1 minute",
      "confirmations_required_small": 6,
      "eta_small": "about 20 seconds",
      "small_payment_limit_usd": 1000,
      "withdrawal_fee_usd": 0,
      "min_withdrawal_usd": 5
    }
  ],
  "has_more": false
}
```

The example shows one network with illustrative settings; actual responses contain all currently enabled networks and their active coins. `data` entries use the network schema below. This endpoint is not cursor-paginated. The Networks and coins table later in this reference is supplied from current platform settings by the documentation route. Assets are deduplicated by symbol; when native and older bridged contracts share a symbol, the API lists the primary contract. Outgoing support can still have additional restrictions; `/networks` is primarily payment discovery, not a guarantee that every outgoing request will be accepted.

## Objects

Object examples in the endpoint sections show complete shapes. Lists return objects in `data`. A nullable field is present with null when unavailable unless noted. The following schemas include nested objects; they do not expose internal ledger units or screening notes.

### Invoice object

| Field | Type | Meaning |
| --- | --- | --- |
| id | string | Invoice ID (`inv_`). |
| object | string | Always `invoice`. |
| livemode | boolean | True for real-money mode. |
| status | string | `open`, `pending`, `confirming`, `partially_paid`, `paid`, `expired`, `canceled`. |
| amount | decimal string | Total USD payable, including configured tax. |
| currency | string | Always `USD`. |
| subtotal | decimal string | USD subtotal before tax; equal to amount when no tax applies. |
| tax | object or null | Configured tax calculation; schema below. |
| customer_country | string or null | Two-letter buyer country. |
| customer_tax_id | string or null | Buyer tax identifier. |
| amount_paid_usd | decimal string | Total gross credited USD value across all invoice payments, not net of processing fees or refunds. |
| description | string or null | Checkout/receipt description. |
| order_id | string or null | Your unique order reference. |
| customer | string or null | Linked customer ID. |
| customer_email | string or null | Receipt/contact email. |
| subscription | string or null | Linked subscription ID. |
| metadata | object | Stored JSON data. |
| payment | object or null | Current quote, not a payment transfer object; schema below. |
| overpaid | boolean | Set when a paid invoice exceeds the overpayment threshold; remains set. |
| hosted_url | string | Hosted checkout URL. |
| receipt_url | string or null | Available once paid; append `/pdf` for PDF. |
| redirect_url | string or null | Configured post-payment destination. |
| merchant | object or null | `{id: string, name: string}` when the store is present. |
| expires_at | ISO timestamp | Invoice expiry; new quotes are disallowed after this time. |
| paid_at | ISO timestamp or null | First time fully paid. |
| created_at | ISO timestamp | Creation time. |

#### Invoice payment quote

| Field | Type | Meaning |
| --- | --- | --- |
| network | string | Selected network key. |
| asset | string | Selected uppercase coin symbol. |
| address | string or null | Invoice deposit address; null while suspended. |
| rate_usd | number or null | USD per coin at quote time. |
| amount_due | decimal string or null | Amount of this quote in coin units; a requote covers the remaining USD debt. |
| amount_paid | decimal string | Coin units credited against this quote while its rate was valid; resets on requote. |
| quote_expires_at | ISO timestamp or null | Quote validity deadline; read the returned value. |
| uri | string or null | Wallet payment link for the remaining coin amount; null while suspended. Tron uses the address itself. |
| qr_url | string or null | SVG QR URL; append `?type=address` for address-only QR. Null while suspended. |

#### Invoice tax

| Field | Type | Meaning |
| --- | --- | --- |
| name | string or null | Tax label, e.g. VAT. |
| rate | number | Percentage, e.g. 19 (not 0.19). |
| amount | decimal string | Tax in USD. |
| inclusive | boolean | Whether the input price already included tax. |
| country | string or null | Buyer country used for tax. |
| pending | boolean | Country required before quoting; use hosted checkout or supply country during creation. |

#### Invoice statuses

| Status | Meaning | Transitions/edge cases |
| --- | --- | --- |
| open | No coin chosen. | pending after quote; confirming after detected transfer; expired/canceled. |
| pending | Quote selected, awaiting payment. | confirming after detection; expired/canceled. |
| confirming | Transfer detected; confirmations or review may remain. | paid or partially_paid on credit; canceled if its only payment is declined; may remain confirming while held. |
| partially_paid | Credited total below tolerance. | confirming on another transfer; paid when covered. Does not automatically expire, but expires_at still prevents new quotes. |
| paid | Invoice covered after confirmations and screening. | Remains paid for extra payments and refunds; there is no refunded invoice status. |
| expired | Open/pending invoice reached expiry. | Late detected payment can move it to confirming, then paid or partially_paid. |
| canceled | Merchant/subscription canceled it or screening declined its only payment. | No new quote. A later credited on-chain payment can still settle an unrefunded invoice; cancellation is not an on-chain reversal. |

### Payment object

| Field | Type | Meaning |
| --- | --- | --- |
| id | string | Transfer ID (`pay_`); use for duplicate credit prevention. |
| object | string | Always `payment`. |
| livemode | boolean | Real-money mode. |
| status | string | `confirming`, `credited`, `held`, `declined`, `dropped`, `ignored`. |
| invoice | string or null | Invoice receiving this payment. |
| customer | string or null | Permanent-address owner, including the treasury customer for top-ups. Not inherited from an invoice customer. |
| network | string | Network key. |
| asset | string | Credited coin symbol. |
| amount | decimal string | Gross received coin amount. |
| fee | decimal string or null | Processing fee in that coin; set at crediting, zero for treasury top-ups. |
| net_amount | decimal string or null | Coin amount credited after fee. |
| amount_usd | decimal string or null | Gross reference value established at first detection, or later if pricing was unavailable; not a live quote. |
| address | string | Receiving deposit address. |
| from | string or null | Sending address when known; not necessarily a safe refund destination. |
| tx_hash | string | Chain hash or `sim_` hash. |
| explorer_url | string or null | Transaction explorer link; null for simulated transfers. |
| confirmations | integer | Observed confirmation count. |
| confirmations_required | integer | Threshold for this payment; can rise while confirming when later payments from the same sender or into the same address take their total to $1,000 or more. |
| simulated | boolean | Whether produced by the simulator. |
| detected_at | ISO timestamp | First detection. |
| credited_at | ISO timestamp or null | When added to the balance. |
| mismatch | string array | How an invoice payment differed from its quote: any of `chain`, `coin`, `late`. Empty when it matched or isn't for an invoice. |
| needs_attention | string or null | Why the store must decide about this credited payment: `short`, `canceled`, `refunded`, `extra`. Null when nothing is waiting. |
| resolution | string or null | The store's decision: `applied` (invoice marked paid), `refunded`, `kept`. |
| dropped_reason | string or null | Why a `dropped` payment was dropped. |

#### Payment statuses

| Status | Meaning | Transitions |
| --- | --- | --- |
| confirming | Awaiting sufficient confirmations, reliable pricing, or a timed screening hold. | credited, held, or ignored (e.g. reorganization). |
| credited | Funds posted to the store ledger. | Final incoming state. |
| held | Not credited; platform review is required, including suspension holds. | Released to confirming then credited, or declined. |
| declined | Review refused the funds; never credited. | Final. No automatic return transfer. |
| dropped | Its transaction never reached a block (dropped, replaced, failed), checked repeatedly over 30 minutes (3 hours on Bitcoin-family chains), or the store dismissed it while unconfirmed. Not credited; its invoice returns to waiting. | Back to confirming if the transaction is later seen in a block. |
| ignored | Dust (below $0.10 when detected), internal gas transfer, or reorganization duplicate. | Never credited as this payment; a moved chain transfer can be recorded separately. |

### Outgoing transfer object

Payouts, refunds, and withdrawals share this shape. The `object` discriminator supplies the kind; there is no `kind` property in the response.

| Field | Type | Meaning |
| --- | --- | --- |
| id | string | `po_` payout, `ref_` refund, or `wd_` withdrawal ID. |
| object | string | `payout`, `refund`, or `withdrawal`. |
| livemode | boolean | Real-money mode. |
| status | string | `review`, `pending`, `approved`, `processing`, `sent`, `failed`, `rejected`. |
| customer | string or null | Associated customer ID. |
| invoice | string or null | Refunded invoice ID, otherwise normally null. |
| external_id | string or null | Idempotency reference shared across outgoing kinds per store/mode. |
| metadata | object | Custom data, or refund reason in metadata.reason. |
| network | string | Source/destination network. |
| asset | string | Coin symbol. |
| amount | decimal string | Coin amount the recipient receives; no gas fee deduction. |
| fee | decimal string | Additional processing fee on fee-free liquidity used, in the same coin; otherwise 0. |
| amount_usd | decimal string or null | Reference USD value of amount when requested. |
| address | string | Destination. |
| bridged_token | object or null | `{contract: string, name: string or null}` when a bridged variant was used; otherwise null. |
| tx_hash | string or null | Transaction hash; may exist before sent. Not sufficient by itself to establish success. |
| explorer_url | string or null | Live transaction explorer URL; null in test mode or before a hash exists. |
| error | string or null | Failure or rejection explanation. |
| review_reasons | array of strings | Store-review reasons; [] when absent. Does not expose internal platform reasons. |
| reviewed_at | ISO timestamp or null | Store review decision time. |
| created_at | ISO timestamp | Request time. |
| processed_at | ISO timestamp or null | Completion/reversal time when recorded; not guaranteed for failed. |

#### Outgoing statuses

| Status | Meaning | Transitions |
| --- | --- | --- |
| review | Your store must approve/reject; includes held overpayment refunds. | approved or pending after store approval; rejected after rejection. |
| pending | Platform review/approval required, including platform controls. | approved or rejected by platform. Public approve/reject endpoints do not handle this state. |
| approved | Queued to send. | processing; may be held pending platform review if conditions change. |
| processing | Signing/broadcasting or waiting for the transaction outcome. | sent; approved for a safe automated retry; failed for manual attention. |
| sent | Transfer sent successfully; use its hash/explorer when live. | Completed. Test hashes are simulated. |
| failed | Needs platform attention; money remains reserved and an earlier transaction can still land. | sent if an attempt lands; approved for platform retry; rejected only after safe reversal. |
| rejected | Not sent; reservation and any exit fee have been returned. | Terminal for this request; no retry with a new reference without reconciling your own balance. |

Bridged variants can be used when the normal token is unavailable in the sending wallet, including Polygon USDC.e or older Optimism USDT. Some exchanges do not credit these variants. Check the recipient token support before requesting a payout; no public request field selects or forbids a bridged contract.

### Customer object

| Field | Type | Meaning |
| --- | --- | --- |
| id | string | Customer ID (`cus_`). |
| object | string | Always `customer`. |
| livemode | boolean | Real-money mode. |
| email | string or null | Deposit-confirmation email. |
| name | string or null | Name/reference. |
| external_id | string or null | Your user ID. |
| metadata | object | Stored data. |
| deposit_page_url | string | Hosted deposit page; treat its token URL as customer-specific. |
| deposits_count | integer | Number of credited payments with this customer as deposit-address owner. |
| deposits_usd | decimal string | Total gross credited USD value for those deposits. |
| created_at | ISO timestamp | Creation time. |

### Deposit address object

| Field | Type | Meaning |
| --- | --- | --- |
| network | string | Network key. |
| name | string | Network display name. |
| assets | array of strings | Accepted symbols on this network for this customer/treasury. |
| address | string | Receiving address. |
| uri | string | Wallet link without a fixed payment amount; may be plain address. |
| qr_url | string | SVG QR URL. |
| confirmations_required | integer | Only on the single-network customer address response: normal threshold. |
| confirmations_required_small | integer | Only on the single-network response: threshold below the small-payment limit. |
| warning | string | Only on the single-network response: network-specific instruction. |

### Plan object

| Field | Type | Meaning |
| --- | --- | --- |
| id | string | Plan ID (`plan_`). |
| object | string | Always `plan`. |
| livemode | boolean | Real-money mode. |
| name | string | Display name. |
| description | string or null | Description. |
| amount | decimal string | USD price per period. |
| currency | string | Always USD. |
| interval | string | day, week, month, or year. |
| interval_count | integer | Intervals per billing period. |
| prepay | array of objects | Each has periods (integer), percent_off (number), amount (total discounted USD decimal string). |
| grace_days | integer | Grace period after paid_through. |
| active | boolean | Whether new subscriptions can be created. |
| custom | boolean | Read-only. True for a plan made from a custom `price` on one subscription; left out of plan lists unless `?custom=true`. |
| switchable | boolean | Customers on another plan can switch to it themselves from their subscription page (one-item subscriptions only). |
| created_at | ISO timestamp | Creation time. |

### Subscription object

| Field | Type | Meaning |
| --- | --- | --- |
| id | string | Subscription ID (`sub_`). |
| object | string | Always subscription. |
| livemode | boolean | Real-money mode. |
| status | string | incomplete, active, past_due, canceled. |
| plan | plan object or null | Embedded plan, not just its ID. With several items, the first item's plan. |
| items | array of objects | Each `{ plan: Plan, quantity: integer }`. Everything the subscription is for, billed together. |
| amount | decimal string or null | USD for one period of all items together. |
| interval | string or null | Billing interval shared by all items. |
| interval_count | integer or null | Intervals per billing period, shared by all items. |
| pending_change | object or null | A change waiting to happen: `{ id, items, timing, amount_due, credit, invoice, pay_url, created_at }`. `timing` is `now` (waiting for its invoice to be paid) or `period_end` (applied when the next renewal is paid). `amount_due` and `credit` are USD decimal strings (`amount_due` null for `period_end`); `invoice` and `pay_url` are null when there's no change invoice. |
| customer | string or null | Linked customer ID. |
| customer_email | string | Renewal contact address. |
| paid_through | ISO timestamp or null | End of purchased access. |
| periods_paid | integer | Cumulative periods from the current billing anchor. |
| started_at | ISO timestamp or null | Billing anchor; normally first credited payment time. |
| cancel_at_period_end | boolean | Scheduled cancellation at paid_through. |
| canceled_at | ISO timestamp or null | Actual cancellation time. |
| cancel_reason | string or null | requested or unpaid. |
| latest_invoice | string or null | Most recently created subscription invoice ID, regardless of status. |
| pay_url | string or null | Current payable invoice URL; can differ from latest_invoice; null if none or canceled. |
| manage_url | string | Customer management URL; treat token as customer-specific. |
| metadata | object | Subscription data. |
| created_at | ISO timestamp | Creation time. |

#### Subscription statuses

| Status | Meaning | Transitions |
| --- | --- | --- |
| incomplete | First invoice unpaid. | active on payment; canceled on cancellation. |
| active | Paid through a future date. | past_due at expiry; canceled when scheduled/requested; remains active on renewal. |
| past_due | Paid time expired, within grace. | active on renewal; canceled after grace unless an invoice is still confirming. |
| canceled | Ended; cancel_reason distinguishes requested from unpaid. | Resume endpoint refuses. A late paid invoice can reactivate an unpaid cancellation with a new anchor; requested cancellation does not reactivate. |

Use `active` and, if honoring the grace period, `past_due` to grant service access. Calendar billing is anchored in UTC; month-end dates are clamped without accumulating drift. Canceling an unpaid subscription or immediate cancellation does not refund automatically. Archiving a plan does not cancel subscriptions.

### Tip object

| Field | Type | Meaning |
| --- | --- | --- |
| id | string | Underlying invoice ID (`inv_`). |
| object | string | Always tip. |
| livemode | boolean | Real-money mode. |
| status | string | Invoice status enum. |
| amount | decimal string | Invoice total in USD. |
| currency | string | USD. |
| name | string or null | Supporter name. |
| message | string or null | Supporter message. |
| email | string or null | Private receipt email. |
| hidden | boolean | Whether excluded from public supporter lists. |
| metadata | object | Custom metadata excluding reserved tip data. |
| payment | invoice quote object or null | Same quote fields as invoice.payment. |
| hosted_url | string | Hosted invoice checkout. |
| receipt_url | string or null | Receipt after payment. |
| expires_at | ISO timestamp | Expiry. |
| paid_at | ISO timestamp or null | First full payment. |
| created_at | ISO timestamp | Creation time. |

The tip object does not return `redirect_url`, `overpaid`, tax details, or `amount_paid_usd`; retrieve the underlying invoice if needed. Public tip status returns only `id`, `status`, `amount`, `currency`, `name`, `message`, `payment`, `hosted_url`, `expires_at`, and `paid_at`. Creation adds `url` and `status_url`. Public feed tips instead contain `id` (opaque tip_ ID), `name` (Anonymous when absent), `message`, `amount`, and `at` (paid ISO timestamp).

### Balance, top-up, network, list, and payout-rule objects

| Field | Type | Meaning |
| --- | --- | --- |
| balance.object | string | balance. |
| balance.network / asset | string | Network key and coin symbol; distinct balance for each pair. |
| balance.amount | decimal string | Available coin units, after reservations. |
| balance.on_hold | decimal string | Portion of amount still in the new-store hold; not sendable yet. "0" for established stores and in test mode. |
| balance.on_hold_until | ISO 8601 string or null | When the next held portion becomes sendable. |
| balance.fee_free_liquidity | decimal string | Portion of amount originating from fee-free top-ups. |
| balance.amount_usd | decimal string or null | Current estimated USD value. |
| topup.object | string | topup. |
| topup.livemode | boolean | Mode selected by key. |
| topup.fee | string | 0 for incoming treasury top-ups. |
| topup.addresses | array of deposit address objects | Currently enabled platform deposit instructions. |
| network.network / name | string | Network key and display name. |
| network.assets | array of objects | symbol: string; contract: string or null; decimals: integer. |
| network.assets[].contract | string or null | Token contract, Solana mint, or TON jetton master; null for native coin. |
| network.confirmations_required | integer | Normal payment confirmation threshold. |
| network.eta | string | Human-readable normal confirmation estimate. |
| network.confirmations_required_small | integer | Threshold for payments strictly below small_payment_limit_usd. |
| network.eta_small | string | Small-payment confirmation estimate. |
| network.small_payment_limit_usd | number | Currently 1000; threshold is strict, not inclusive. |
| network.withdrawal_fee_usd | number | 0: network fees are covered by zerofive. |
| network.min_withdrawal_usd | number | Current network minimum for outgoing transfers, converted to the asset at request time. |
| list.object | string | list. |
| list.data | array | Resource objects. |
| list.has_more | boolean | Whether another cursor page exists; false for unpaginated lists. |
| payout_rules.object | string | payout_rules; remaining fields/types are the POST /payout-rules table above. |

The network object has no `object`, `warning`, chain ID, fee rate, or screening-delay field. Read customer single-network addresses for their `warning`. A symbol may have different decimals/contracts on different networks. US-dollar stablecoins are valued at par; EURC follows the euro exchange rate.

## Webhooks

Configure receiving URLs and obtain signing secrets in Dashboard → Developers. Test and live mode are configured separately: each mode has its own endpoint URL and its own secret (live secrets start `whsec_`, test secrets `whsec_test_`). Test events (`livemode: false`) go only to the test URL, signed with the test secret; live events only to the live URL, signed with the live secret. Leaving a mode's URL empty means that mode's events are not delivered. Verify each delivery with the secret for the mode you configured on that server, and keep the live secret out of development and staging environments. There are no public `/api/v1/webhooks`, webhook-endpoint-management, event-list, or delivery-replay API routes. Inspect deliveries and use resend in the dashboard.

Use a public endpoint without credentials in the URL. Live webhooks must use HTTPS; HTTP is accepted only for test mode. Local/private hosts are rejected and redirects are not followed. Return a 2xx promptly after durable acceptance; delivery has a 10-second socket-inactivity timeout and a 15-second total deadline including DNS, so target well below 10 seconds.

### Event envelope

| Field | Type | Meaning |
| --- | --- | --- |
| `id` | string | Stable `evt_` ID; deduplicate this across delivery retries. |
| `type` | string | Exact event name from the table below. |
| `livemode` | boolean | True for live funds; false for simulated mode. |
| `created` | integer | Event creation time in Unix seconds, not milliseconds. |
| `data` | object | Event-specific object or wrapper. It is not always `{invoice, payment}`. |

Example signed request body:

```json
{
  "id": "evt_Example123",
  "type": "invoice.paid",
  "livemode": false,
  "created": 1792663220,
  "data": {
    "invoice": {
      "id": "inv_Example123",
      "object": "invoice",
      "livemode": false,
      "status": "paid",
      "amount": "49.00",
      "currency": "USD",
      "subtotal": "49.00",
      "tax": null,
      "customer_country": null,
      "customer_tax_id": null,
      "amount_paid_usd": "49.00",
      "description": "Order A-1042",
      "order_id": "A-1042",
      "customer": null,
      "customer_email": null,
      "subscription": null,
      "metadata": {},
      "payment": {
        "network": "tron",
        "asset": "USDT",
        "address": "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7",
        "rate_usd": 1,
        "amount_due": "49",
        "amount_paid": "49",
        "quote_expires_at": "2026-10-22T10:30:00.000Z",
        "uri": "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7",
        "qr_url": "https://z5.gg/pay/inv_Example123/qr"
      },
      "overpaid": false,
      "hosted_url": "https://z5.gg/pay/inv_Example123",
      "receipt_url": "https://z5.gg/receipt/ExampleReceiptToken",
      "redirect_url": null,
      "merchant": {
        "id": "mer_Example123",
        "name": "Example store"
      },
      "expires_at": "2026-10-22T11:00:00.000Z",
      "paid_at": "2026-10-22T10:00:20.000Z",
      "created_at": "2026-10-22T10:00:00.000Z"
    },
    "payment": {
      "id": "pay_Example123",
      "object": "payment",
      "livemode": false,
      "status": "credited",
      "invoice": "inv_Example123",
      "customer": null,
      "network": "tron",
      "asset": "USDT",
      "amount": "49",
      "fee": "0.245",
      "net_amount": "48.755",
      "amount_usd": "49.00",
      "address": "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7",
      "from": null,
      "tx_hash": "sim_ExampleTransaction123",
      "explorer_url": null,
      "confirmations": 19,
      "confirmations_required": 19,
      "simulated": true,
      "detected_at": "2026-10-22T10:00:00.000Z",
      "credited_at": "2026-10-22T10:00:20.000Z"
    }
  }
}
```

### Event names and data

| Event | `data` shape | Meaning |
| --- | --- | --- |
| `invoice.created` | Invoice object | Created from API, dashboard, tip, or subscription. May precede optional quoting. |
| `invoice.payment_detected` | `{invoice: Invoice, payment: Payment}` | Incoming transfer detected; do not fulfil yet. |
| `invoice.paid` | `{invoice: Invoice, payment: Payment}` | Invoice is covered by credited payments; fulfil once per invoice/order. The payment is the transfer that completed it, not necessarily the entire paid amount. |
| `invoice.partially_paid` | `{invoice: Invoice, payment: Payment}` | Transfer credited but total remains short. |
| `invoice.expired` | Invoice object | Unpaid open/pending invoice expired; late payment can still arrive. |
| `invoice.canceled` | Invoice object | Explicit invoice cancellation. Not every indirect cancellation emits this event. |
| `invoice.refunded` | `{invoice: string, refund: Refund}` | Refund sent; invoice is an ID, not an embedded object. |
| `deposit.detected` | Payment object | Transfer to permanent customer/treasury address detected; do not credit your user yet. |
| `deposit.confirmed` | Payment object | Non-treasury customer deposit credited; credit your user once per payment ID. |
| `topup.confirmed` | Payment object | Treasury liquidity credited without incoming processing fee; not user revenue. |
| `payment.held` | Payment object | Not credited; platform review required, including suspension holds. |
| `payment.declined` | `{invoice: Invoice, payment: Payment}` for invoice payments; otherwise Payment object | Held transfer declined; never credited. The invoice may become canceled or partially_paid. |
| `payment.dropped` | `{invoice: Invoice, payment: Payment}` for invoice payments; otherwise Payment object | The transaction never made it into a block, or was dismissed. Not credited; the invoice returns to waiting. May later be followed by a new `*.payment_detected` / `deposit.detected` if it's mined after all. |
| `payment.needs_attention` | `{invoice: Invoice, payment: Payment}` | Credited payment the invoice couldn't settle: short in another coin/chain or late, or the invoice was canceled, refunded or already paid. The store marks the invoice paid, refunds, or keeps it. |
| `payout.created` | Payout or Refund object | Amount and any exit fee reserved from balance. |
| `payout.pending_review` | Payout or Refund object | Store review required; use review_reasons. |
| `payout.sent` | Payout or Refund object | Outgoing transfer completed; check object to distinguish refund. |
| `payout.failed` | Payout or Refund object | Platform attention needed; funds are not automatically returned. |
| `payout.rejected` | Payout or Refund object | Transfer safely reversed; amount and any exit fee returned. |
| `withdrawal.created` | Withdrawal object | Store cash-out requested and reserved. |
| `withdrawal.sent` | Withdrawal object | Store cash-out completed. |
| `withdrawal.failed` | Withdrawal object | Platform attention needed; reconcile before any new request. |
| `withdrawal.rejected` | Withdrawal object | Store cash-out reversed. |
| `subscription.created` | Subscription object | Subscription and first invoice created. |
| `subscription.activated` | `{subscription: Subscription, invoice: Invoice, periods: integer}` | First payment, or late reactivation after an unpaid lapse. |
| `subscription.renewed` | `{subscription: Subscription, invoice: Invoice, periods: integer}` | Additional prepaid time credited. |
| `subscription.payment_due` | `{subscription: Subscription, invoice: Invoice}` | Renewal invoice available. |
| `subscription.past_due` | Subscription object | Paid time expired; grace period begins. |
| `subscription.updated` | Subscription object | Scheduled cancellation set or undone, or a change of items scheduled, requested, applied, or called off. |
| `subscription.canceled` | Subscription object | Subscription ended; inspect cancel_reason. |
| `tip.received` | Tip object | Tip became paid; invoice.paid also fires for it. |

There is no generic `payment.confirmed`, `refund.sent`, `payout.approved`, or `payment.released` event. Refunds use `payout.*` plus `invoice.refunded`. Released incoming payments proceed to the normal credit events. `invoice.paid` fires on the first transition to paid; additional transfers still credit the balance but do not emit another invoice.paid. Reconcile `/payments` if your application needs every extra invoice transfer. Invoice snapshots are captured when emitted and can be older than the current API object.

### Signature verification

Header name: `ZeroFive-Signature` (HTTP header names are case-insensitive).

```text
t=1792663220,v1=<64 lowercase hexadecimal characters>
```

1. Read the request body as unchanged bytes, before any JSON middleware or parsing.
2. Extract `t` (delivery Unix seconds) and `v1` (hex signature).
3. Compute HMAC-SHA256 using the complete webhook secret string as the key, and the bytes of `t + "."` followed by the original body as the message. Do not strip the `whsec_` prefix or hex/base64-decode the secret.
4. Decode `v1` from hex and compare in constant time.
5. Reject an absent/malformed signature or a timestamp outside a short tolerance, such as 300 seconds in either direction. Keep your clock synchronized. Check delivery `t`, not event `created`: old events may be legitimately resent with a new signature.
6. Only then parse JSON, select the expected mode, and durably deduplicate/process the event.

### Node.js verification and receiving handler

This uses built-in Node crypto and the Fetch Request/Response API. Supply a durable `enqueueEventOnce` implementation that atomically inserts an event with a unique key on event ID (partition by store if serving multiple stores). It must resolve only after committing the insert, treating an already-enqueued event as success. A worker then fulfils orders or credits users idempotently.

```javascript
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyWebhook(rawBody, header, secret, toleranceSeconds = 300) {
  if (!Buffer.isBuffer(rawBody) || typeof header !== "string" || !secret) return false;
  const parts = {};
  for (const piece of header.split(",")) {
    const match = /^([^=]+)=(.*)$/.exec(piece.trim());
    if (!match) return false;
    const [, name, value] = match;
    if ((name === "t" || name === "v1") && Object.hasOwn(parts, name)) return false;
    parts[name] = value;
  }
  if (!/^[0-9]+$/.test(parts.t ?? "")) return false;
  if (!/^[0-9a-f]{64}$/.test(parts.v1 ?? "")) return false;
  const timestamp = Number(parts.t);
  if (!Number.isSafeInteger(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
  const expected = createHmac("sha256", secret)
    .update(`${parts.t}.`, "utf8")
    .update(rawBody)
    .digest();
  const received = Buffer.from(parts.v1, "hex");
  return received.length === expected.length && timingSafeEqual(received, expected);
}

export function createWebhookHandler({ secret, expectedLivemode, enqueueEventOnce }) {
  return async function POST(request) {
    const rawBody = Buffer.from(await request.arrayBuffer());
    if (!verifyWebhook(rawBody, request.headers.get("zerofive-signature"), secret)) {
      return new Response("Invalid signature", { status: 400 });
    }
    let event;
    try {
      event = JSON.parse(rawBody.toString("utf8"));
    } catch {
      return new Response("Invalid JSON", { status: 400 });
    }
    if (!event || typeof event.id !== "string" || typeof event.type !== "string" ||
        typeof event.livemode !== "boolean") {
      return new Response("Invalid event", { status: 400 });
    }
    // A store sends both modes to one URL. Ignore the other mode intentionally,
    // or route it to a separate test/live queue if your application serves both.
    if (event.livemode !== expectedLivemode) return new Response(null, { status: 204 });
    try {
      await enqueueEventOnce(event);
    } catch {
      return new Response("Temporary queue failure", { status: 503 });
    }
    return new Response(null, { status: 204 });
  };
}
```

For Express, use `express.raw({ type: "application/json" })` on this route before any `express.json()` middleware; pass the resulting Buffer directly to `verifyWebhook`. Never parse then stringify the JSON to reconstruct the signed bytes.

Worker handling rules:

- `invoice.paid`: use `event.data.invoice.order_id` and `event.data.invoice.id`; fulfil an order once in your database transaction. Check it belongs to the expected order, store, currency, and mode.
- `deposit.confirmed`: use `event.data.id` as the unique payment credit key and `event.data.customer` to identify your user. The net coin amount is `event.data.net_amount`; preserve network and asset.
- `payout.sent`: complete the app cash-out corresponding to the outgoing object's external_id. On `payout.rejected`, restore the app reservation once. On `payout.failed`, retain the reservation and reconcile.
- `subscription.*`: inspect the current subscription before changing access if events might be stale.
- Accept unknown event types without repeatedly failing the delivery. Log or retain them for later handling.

The event insert and durable work scheduling must be atomic; do not mark an event processed before work is committed. For external fulfilment side effects, use a transactional outbox and the order ID as the downstream idempotency key. Event deduplication alone does not prevent duplicate business actions caused by multiple related events (for example invoice.paid and tip.received).

### Python verification

The standard library is sufficient. Pass the framework's unchanged request bytes and header to `verify_webhook`; parse only after it succeeds.

```python
import hashlib
import hmac
import json
import re
import time


def verify_webhook(raw_body: bytes, header: str | None, secret: str,
                   tolerance_seconds: int = 300) -> bool:
    if not isinstance(raw_body, bytes) or not isinstance(header, str) or not secret:
        return False
    parts = {}
    for piece in header.split(","):
        name, separator, value = piece.strip().partition("=")
        if not separator:
            return False
        if name in ("t", "v1") and name in parts:
            return False
        parts[name] = value
    timestamp_text = parts.get("t", "")
    signature = parts.get("v1", "")
    if not re.fullmatch(r"[0-9]{1,16}", timestamp_text):
        return False
    if not re.fullmatch(r"[0-9a-f]{64}", signature):
        return False
    timestamp = int(timestamp_text)
    if abs(time.time() - timestamp) > tolerance_seconds:
        return False
    signed_bytes = timestamp_text.encode("ascii") + b"." + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed_bytes, hashlib.sha256).digest()
    return hmac.compare_digest(expected, bytes.fromhex(signature))


def parse_verified_event(raw_body: bytes, signature: str | None, secret: str):
    if not verify_webhook(raw_body, signature, secret):
        raise ValueError("Invalid webhook signature")
    event = json.loads(raw_body)
    if (not isinstance(event, dict)
            or not isinstance(event.get("id"), str)
            or not isinstance(event.get("type"), str)
            or not isinstance(event.get("livemode"), bool)):
        raise ValueError("Invalid event")
    return event

# Framework integration:
# raw_body = request.get_data()       # Flask: bytes, before JSON parsing
# signature = request.headers.get("ZeroFive-Signature")
# event = parse_verified_event(raw_body, signature, configured_webhook_secret)
# Route by event["livemode"], enqueue durably once per event["id"], then return 2xx.
```

### Retries and ordering

Any non-2xx response, DNS/connect error, or timeout retries. Redirects count as failure. There are eight automatic attempts: the initial delivery plus seven retries, with these delays after the preceding failed attempt:

| Retry | Delay |
| --- | --- |
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 6 hours |
| 6 | 12 hours |
| 7 | 24 hours |

This is roughly 44 hours 36 minutes plus worker scheduling/delivery time. The event ID and event creation time remain the same; each attempt has a fresh signature timestamp. After automatic attempts are exhausted, inspect/resend from the dashboard. Delivery is not exactly once and ordering is not guaranteed: multiple events are sent concurrently, and retries can arrive after newer events. Persist event IDs, make business actions idempotent, and reconcile current objects when ordering matters.

## Payment lifecycle rules

### Quotes and settlement

Creating an invoice does not yet allocate its displayed payment instructions. Choosing a network/coin creates or reuses an invoice address for that address family and locks a quote. Default quote lifetime is 30 minutes; default invoice lifetime is 60 minutes, both configurable by the platform. Do not use these defaults instead of response timestamps.

Quote expiry is computed as the earlier of `now + configured quote lifetime` and `max(invoice expiry, now + 10 minutes)`. Therefore a quote issued close to invoice expiry can extend beyond the invoice's own expiry. Creation of a fresh quote still requires the invoice not to have expired. Always use the returned `quote_expires_at` and `expires_at` rather than inferring one from the other.

Payment amounts are rounded up to a practical coin precision. A transfer qualifies for the locked rate if it matches the quote network/asset and was mined no later than quote expiry. The chain's block time takes precedence over detection time; if no block time is available, credit time is used conservatively. A mempool observation before expiry does not guarantee the locked rate if mining happens later.

Settlement checks the locked coin amount when eligible, and the summed gross USD value of credited payments. USD valuation is normally established when detected; if pricing was unavailable, crediting waits until a price can be obtained. Credit happens after confirmations and screening. Invoice payment totals are gross; processing fees are separately deducted from the merchant balance.

### Partial payments, overpayments, and late transfers

| Situation | Behavior |
| --- | --- |
| Slight underpayment | Default tolerance is 0.5% (platform-configurable). Within tolerance, the invoice becomes paid; consume its status rather than reproducing settlement math. |
| Larger underpayment | Credited funds add to the balance and invoice total; status becomes partially_paid and invoice.partially_paid fires. Send the remainder to the same instructions or requote before invoice expiry. Partial invoices do not expire automatically. |
| Overpayment | Once paid, more than 1% above the invoice USD amount or eligible quoted coin amount sets overpaid. All credited coin units enter the ledger, minus the ordinary incoming fee. |
| Expired quote | A later-mined transfer does not use the old locked rate; its gross USD valuation can leave a shortfall. Requote while the invoice remains eligible. |
| Expired invoice | Open/pending invoices expire at expires_at. Their addresses remain watched for seven additional days, allowing late transfers to confirm and settle the invoice. Do not promise recovery after that window. |
| Already paid invoice | Extra transfers still credit the merchant; no second invoice.paid is emitted. |
| Canceled invoice | New quotes are disallowed, but cancellation cannot block a transfer already on chain. A credited transfer can still settle it unless a non-rejected refund blocks settlement. |
| Refunded invoice | Any non-rejected refund blocks new quotes and a new transition from unpaid to paid. An invoice already paid stays paid; there is no refunded status. Later funds still belong to the merchant and may require a separate reconciliation/refund. |
| Small transfer | Incoming transfers valued below $0.10 at detection are ignored as dust, including customer deposits. |

### Confirmations and screening

Live payments with detected gross value strictly below $1,000 use the network's `confirmations_required_small`; $1,000 and above, or unknown value at detection, use the normal threshold. Value is counted together with the same sender's live payments (to any store) and other payments into the same address over the previous 3 hours, so a split amount uses the normal threshold and the large-payment screening hold; when a new piece takes the group over, its other pieces still confirming are raised to the normal threshold. Each payment's `confirmations_required` is authoritative and can increase while confirming. Simulations use the network's normal confirmation count. Solana and TON rely on finality rather than treating displayed numbers as ordinary block counts.

Live incoming senders are screened against sanctions, issuer freeze, and platform ban lists before crediting, and payers by customer `name` (UN, EU and OFAC sanctions lists) and the invoice's `customer_country`. An invoice whose `customer_country` is a sanctioned country is refused with a 400. A flagged payment becomes `held`, emits `payment.held`, and does not add funds or fulfil its invoice. Only the platform can release/decline it; there is no merchant API to override incoming screening. A release returns it to confirming so normal credit events can follow. A decline emits `payment.declined`; funds are not credited or automatically sent back. An invoice with no other waiting payment becomes canceled if nothing was credited, otherwise partially_paid.

Large live payments can remain `confirming` after enough confirmations for a timed rescreening hold (defaults: $1,000 threshold and 60 minutes). These are configurable defaults, not guaranteed maximum waits; unavailable screening data can delay credit longer. Timed holds do not necessarily emit payment.held. Public payment objects omit review reasons and hold-until timestamps. Fulfil on invoice.paid or credit on deposit.confirmed, never merely because the confirmation count meets the threshold.

Suspended stores' incoming payments are also held without crediting; that does not mean the payer is flagged. Outgoing platform-held transfers use `pending`, while store-held payouts use `review` with public `review_reasons`. Keep those two review processes distinct.

### Refund handling

Ask the customer for a refund destination; `payment.from` can be an exchange hot wallet and may not credit them. Refunds use the first credited coin/network and require enough balance and the network minimum. The initial processing fee is not refunded. For a 49 USDT gross payment with a 0.245 USDT incoming fee, a full 49 USDT refund requires 0.245 USDT beyond that payment's net proceeds, plus any fee due if fee-free liquidity is used.

Only rejected refunds cease counting against the refundable cap. A failed refund may still be sent later; keep it associated with the original external_id and wait for reconciliation. Once a refund is sent, both payout.sent (object refund) and invoice.refunded are emitted. Do not book two refunds for those two events.

The hosted checkout can offer an overpayment return when the merchant enables it. Defaults are automatic approval with a merchant-retained fee of 1% of the extra, at least $1; modes are auto, review, or off in dashboard settings. Eligibility requires all credited payments in the quoted coin/network, enough extra after that fee and the network minimum, and a claim within 30 days of payment. The original payer must prove access through the paying browser or receipt-email claim link. Only one request is permitted per invoice, using external_id `overpay_<invoice ID>`; ordinary payout/refund events apply. These overpayment settings are not fields on the public `/payout-rules` endpoint.

### Fees and liquidity

The standard incoming fee is 0.5% in the received coin; a merchant-specific rate can be configured, so use actual `fee` and `net_amount` fields in accounting. No fiat conversion occurs. Payouts/refunds/withdrawals have zero network fee and the recipient receives `amount` in full.

Treasury top-ups incur no arrival fee and increase `fee_free_liquidity`. Outgoing transfers spend fee-paid balance first; the portion funded by fee-free liquidity incurs the processing fee, added to the debit and shown as outgoing `fee`. `amount: "max"` calculates a sendable amount that leaves room for this fee. Outgoing balance requirement is `amount + fee`, not just amount. Minimum outgoing USD values are discovered per network and converted to coin units at request time.

## Embedding

Load `https://z5.gg/js/z5.js` once in your browser. It exposes `window.Zerofive.open` and `window.Zerofive.mount`; it does not create invoices or accept API keys. Create the invoice on your server, then pass only the returned hosted_url to the browser.

Example loading the script without an HTML snippet:

```javascript
async function loadZerofive() {
  if (window.Zerofive) return window.Zerofive;
  await new Promise((resolve, reject) => {
    const script = document.createElement("script");
    script.src = "https://z5.gg/js/z5.js";
    script.onload = resolve;
    script.onerror = reject;
    document.head.appendChild(script);
  });
  return window.Zerofive;
}

const Zerofive = await loadZerofive();
// hostedUrl is the invoice.hosted_url supplied by your own server.
const popup = Zerofive.open(hostedUrl, {
  onStatus: (status) => console.log("Checkout status:", status),
  onPaid: ({ invoice, redirect_url }) => {
    console.log("Checkout reports paid:", invoice, redirect_url);
    // Display a pending/thanks screen; your server webhook controls fulfilment.
  },
  onClose: ({ paid }) => console.log("Closed; checkout reported paid:", paid),
});
// When your UI needs to close it: popup.close();
```

Mount into an existing DOM element or selector:

```javascript
const checkout = Zerofive.mount("#checkout", hostedUrl, {
  onStatus: (status) => console.log(status),
  onPaid: ({ invoice }) => console.log("Paid invoice ID:", invoice),
});
// When removing this screen: checkout.unmount();
```

| API or option | Type | Behavior |
| --- | --- | --- |
| `Zerofive.open(url, options)` | function | Open a modal checkout; returns `{close: function}`. |
| `Zerofive.mount(target, url, options)` | function | Replace target contents with checkout iframe; returns `{unmount: function}`. target is a CSS selector or DOM element. Missing target throws. |
| `options.onStatus` | `(status: string) => void` | Invoice status on load and changes; uses invoice status enum. |
| `options.onPaid` | `({invoice: string, redirect_url: string or null}) => void` | Once per mounted/opened frame when paid. `invoice` is its ID, not an invoice object. |
| `options.onClose` | `({paid: boolean}) => void` | Popup only. Reports whether that frame observed paid, not authoritative financial state. |
| `options.onDeposit` | `({payment: string, status: string, network: string, asset: string, amount: string, usd: string or null}) => void` | Deposit pages only. Called when a transfer to the customer's address appears (`confirming`) and again when it's `credited`; deposits already listed when the page opened aren't reported. A UI hint: credit the user from the `deposit.confirmed` webhook. |

Allowed URLs are invoices at `https://z5.gg/pay/inv_...`, store tips at `https://z5.gg/tip/<slug>`, and customer deposit pages (a customer's `deposit_page_url`, `https://z5.gg/d/<token>`); query parameters are allowed. Subscription manage URLs are not accepted by z5.js. Tip popups are narrower and automatically request embed mode; deposit popups are a single column too. Inline mounts resize on checkout resize messages; popup height is bounded by the viewport.

A link to a supported URL with a `data-zerofive` attribute opens a popup automatically. You can set it in JavaScript with `link.setAttribute("data-zerofive", "")`. Ordinary modified clicks retain normal link behavior. Popups close on the close control, Escape, or outside click; the `close()` return method also calls onClose.

Without onPaid, a paid invoice redirects the containing page to redirect_url after about 2.5 seconds when a redirect is present. With onPaid, your callback owns that behavior. Tip popups show their thank-you destination inside the frame instead of navigating away. The script validates postMessage origin and source frame; browser events remain a UI hint and must never replace server-side signature verification and payment reconciliation.

## Networks and coins

Read `GET /api/v1/networks` at runtime and refresh it rather than hard-coding network keys, coins, contracts, decimals, minimums, or confirmations. The endpoint describes currently enabled platform payment options; store and per-key collection restrictions may narrow them further. Pair every coin with its network. A shared address format or an identical symbol does not imply interchangeable balances.

| Network key | Name | Coins | Confirmations | Typical time |
| --- | --- | --- | --- | --- |
| `eth` | Ethereum | `ETH`, `USDC`, `USDT`, `DAI`, `PYUSD`, `USD1`, `EURC` | 12 (3 for small payments) | about 3 minutes |
| `base` | Base | `ETH`, `USDC`, `USDT`, `EURC` | 30 (5 for small payments) | about 1 minute |
| `arbitrum` | Arbitrum | `ETH`, `USDC`, `USDT`, `PYUSD` | 60 (20 for small payments) | under a minute |
| `optimism` | Optimism | `ETH`, `USDC`, `USDT` | 30 (5 for small payments) | about 1 minute |
| `polygon` | Polygon | `POL`, `USDC`, `USDT`, `DAI` | 40 (16 for small payments) | about 1–2 minutes |
| `bsc` | BNB Chain | `BNB`, `USDT`, `USDC`, `USD1` | 15 (5 for small payments) | under a minute |
| `tron` | Tron | `TRX`, `USDT` | 19 (6 for small payments) | about 1 minute |
| `btc` | Bitcoin | `BTC` | 2 (1 for small payments) | about 20 minutes |
| `ltc` | Litecoin | `LTC` | 3 (1 for small payments) | about 8 minutes |
| `sol` | Solana | `SOL`, `USDC`, `USDT`, `USD1`, `EURC` | 1 | under a minute |
| `avalanche` | Avalanche | `AVAX`, `USDC`, `USDT`, `EURC` | 12 (6 for small payments) | about 15 seconds |
| `plasma` | Plasma | `XPL`, `USDT`, `USDC` | 10 (4 for small payments) | about 10 seconds |
| `arc` | Arc | `USDC`, `EURC` | 2 (1 for small payments) | a few seconds |
| `doge` | Dogecoin | `DOGE` | 10 (3 for small payments) | about 10 minutes |
| `bch` | Bitcoin Cash | `BCH` | 6 (1 for small payments) | about 1 hour |
| `ton` | TON | `TON`, `USDT` | 1 | a few seconds |

Use network keys returned by the API, not display names (for example, `tron` rather than `Tron`). Tokens are detected by configured contracts, not arbitrary symbols. Older bridged variants of some coins are credited under the same symbol; only the primary contract is listed by network discovery. The API is not a universal token deposit address.

Network-specific constraints:

- EVM networks in the same family share a customer's address, but balances remain separate by network and coin. Show the selected chain prominently.
- Bitcoin is on-chain only, not Lightning. Litecoin MWEB is not supported.
- Bitcoin Cash payouts require CashAddr form, not a legacy Bitcoin-looking address.
- Solana Token-2022 transfers are not detected. Token payouts require a recipient account for that token to exist.
- TON uses distinct invoice/customer addresses; no deposit memo/comment is needed.
- Arc native USDC uses 18 decimals; do not assume USDC is always a six-decimal token. Tempo has no native coin.
- For custom invoice screens, use returned `payment.uri` and `payment.qr_url`; the URI may encode base units even though REST amounts use coin units. Tron URIs are plain addresses. Never invent your own URI from an assumed common format.

## Common mistakes and FAQ

### Which payment signal should fulfil an order?

A verified `invoice.paid` event, or a server-authenticated GET returning paid after reconciliation. Neither a redirect, onPaid callback, invoice.payment_detected event, nor confirmation count alone is sufficient. For wallets, credit on deposit.confirmed using payment ID deduplication.

### Is amount USD or crypto?

Invoice, plan, and tip creation amounts are USD. Payout, withdrawal, and refund request amounts are coin units. Response crypto amounts are decimal strings. Use decimal arithmetic or integer units with the discovered asset decimals; do not add JavaScript floats for accounting. Avoid scientific notation and excess decimal places. Only payout/withdrawal creation supports the special amount `max`.

### Why did a quote fail even though the coin appears in networks?

The platform list can be narrowed by store settings and the object's stored key restrictions. Both network and asset must be supplied together. Tax country may still be required, the invoice may have expired or started confirming, a refund may already be booked, or a reliable price may be unavailable. Creation with an immediate quote can create the invoice before quoting fails; reconcile the unique order_id.

### Why are my funds unavailable for a payout?

Confirm incoming payments are credited, not confirming/held, and check the exact coin/network balance. Another outgoing transfer may have reserved funds. Top-up liquidity can incur an exit fee in addition to the requested amount. A network minimum or recipient restriction can also reject the request. Depositing USDT on Tron does not fund USDT on Base.

### Can a failed payout be retried as a new payout?

Do not automatically create a replacement. A failed transfer can still land, and funds remain reserved until a safe reversal. Reconcile its ID, wait for sent/rejected, and keep your original external_id. Public approve/reject endpoints are only for review, not pending/failed.

### Can I refund to the sender automatically?

Ask for a destination. The sending address may be an exchange wallet. Refund in the original first-paid coin/network and include an external_id. Full gross refunds can require extra balance because incoming processing fees are not returned. A refund does not change invoice.status to refunded.

### Why does a live key return not_found for my test invoice?

Objects belong to one store and mode. Test and live keys cannot access each other's records. Webhooks share the store URL, so partition handling by livemode. Public tip creation defaults to live unless test is explicitly true.

### Why does webhook verification fail?

Use raw bytes before parsing; preserve whitespace and property order. Use the full whsec_ secret, not the API key. Sign timestamp text, a dot, then body bytes using HMAC-SHA256; compare hex-decoded signatures in constant time. Validate the delivery timestamp, not event.created. Check clock skew and ensure request middleware has not modified the body.

### Are events ordered or unique deliveries?

No. Persist event IDs, deduplicate business operations by invoice/payment/outgoing ID, and acknowledge only after durable acceptance. Read current objects when reconciling out-of-order snapshots. Tip and refund flows intentionally emit more than one event about the same financial action.

### Can I assume fixed fees, quote lifetimes, or confirmation counts?

Use actual returned fee/net fields and timestamps. Query networks for enabled options, minimums, and thresholds; use payment.confirmations_required for each transfer. Documented defaults can change with platform/store configuration, and screening can extend the wait.

### Can I put an API key in my custom checkout or tip page?

No. Call authenticated v1 endpoints from your server. Use hosted URLs or z5.js in the browser, or the documented unauthenticated public tip routes. A public tip status response is suitable for UI updates; authoritative backend processing still uses signed webhooks or authenticated reads.
