# CoinsSend Payments: Webhook receiver

This context bundle contains selected public documentation. The linked canonical pages remain the source of truth.

Documentation MCP: https://api.coinssend.com/v1/docs/mcp
OpenAPI: https://docs.coinssend.com/openapi.json
Signature fixtures: https://docs.coinssend.com/signature-test-vectors.json

---

Source: https://docs.coinssend.com/webhooks.md

# Webhooks

CoinsSend uses webhooks to notify your application about events that happen in your merchant account. Webhooks are HTTP callbacks that receive notification payloads when events occur.

## Setting Up Webhooks

You can set up a webhook URL in your merchant settings. This URL will receive POST requests when specific events occur.

## Authentication

For security, all webhook requests include an HMAC signature in the `X-Signature` header. You should validate this signature to ensure the webhook was sent by CoinsSend.

The signature is generated using HMAC-SHA256, with your API key as the secret:

```php
// PHP example for validating a webhook signature
function isValidSignature($payload, $signature, $secretKey) {
    $computedSignature = hash_hmac('sha256', $payload, $secretKey);
    return hash_equals($computedSignature, $signature);
}

// Usage
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_SIGNATURE'];
$secretKey = 'your_api_key';

if (!isValidSignature($payload, $signature, $secretKey)) {
    http_response_code(401);
    echo json_encode(['error' => 'Invalid signature']);
    exit;
}
```

Important: calculate the HMAC from the exact raw request body bytes that were received. Do not decode the JSON and re-encode it before verification, because key order, spacing, and escaping may change the payload and produce a different signature.

This raw-body rule is specific to **receiving webhooks**. Merchant API request
signing uses the separate canonical-JSON contract in
[Authentication](https://docs.coinssend.com/authentication.md).

> **AI assistant prompt:** Implement a CoinsSend webhook receiver that preserves the raw request body before JSON parsing.
> Read `X-Signature`, verify it with HMAC-SHA256 and the merchant API key, then parse the payload only after the signature passes.
> Add idempotent processing for duplicate deliveries and persist outcomes for `invoice.paid`, `invoice.expired`, and `aml.rejected.invoice`.
> Use [AI Integration Prompts](https://docs.coinssend.com/ai-integration-prompts.md) for the full webhook receiver prompt.

## Webhook Events

The externally emitted `event` values are:

- `invoice.paid`
- `invoice.expired`
- `wallet.transaction`
- `aml.rejected.invoice`
- `aml.rejected.static_wallet`
- `withdrawal.in_progress`
- `withdrawal.success`
- `withdrawal.failed`
- `test`

Use only the public event values listed above when routing webhook payloads.

### Fee Payer Information

For invoice webhooks (`invoice.paid` and `invoice.expired`), the `fee_payer` object indicates who pays each fee:

- `merchant_fee`: `true` if customer pays, `false` if merchant pays
- `network_fee`: `true` if customer pays, `false` if merchant pays

For withdrawal webhooks the boolean direction is different: `true` means the
merchant pays that withdrawal fee. Treat invoice and withdrawal payloads as
event-discriminated schemas rather than sharing one ambiguous `fee_payer` type.

### `invoice.paid`

Sent when an invoice is paid.

```json
{
  "event": "invoice.paid",
  "timestamp": 1746984000,
  "data": {
    "invoice_id": "12345678-1234-1234-1234-123456789012",
    "order_id": "order_12345",
    "code": "invoice_code",
    "status": "paid",
    "amounts": {
      "requested": {
        "amount": "100.50",
        "currency": "usd",
        "crypto": "100.5"
      },
      "paid": {
        "amount": "100.50",
        "currency": "usd",
        "crypto": "100.5"
      }
    },
    "fees": {
      "merchant": {
        "amount": "2.0100",
        "currency": "usd",
        "crypto": "2.0100"
      },
      "network": {
        "amount": "1.4000",
        "currency": "usd",
        "crypto": "1.4000"
      },
      "total": {
        "amount": "3.4100",
        "currency": "usd",
        "crypto": "3.4100"
      }
    },
    "payment": {
      "coin": "usdt",
      "network": "tron",
      "address": "TXHXiQ2aXYqtz3y9gEGiVjzxXxXechdLwE",
      "rate": "1.0001"
    },
    "fee_payer": {
      "merchant_fee": true,
      "network_fee": true
    },
    "created_at": "2025-01-01T11:30:00Z",
    "paid_at": "2025-01-01T12:00:00Z"
  }
}
```

### `invoice.expired`

Sent when an invoice expires without being paid (after 24 hours).

<div class="alert alert-warning">
  <div class="alert-icon">
    <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
  </div>
  <div class="alert-content">
    <strong>Important:</strong> The <code>payment</code> field is optional and will only be present if a payment method was selected before the invoice expired.
  </div>
</div>

```json
{
  "event": "invoice.expired",
  "timestamp": 1746984000,
  "data": {
    "invoice_id": "12345678-1234-1234-1234-123456789012",
    "order_id": "order_12345",
    "code": "invoice_code",
    "status": "expired",
    "amounts": {
      "requested": {
        "amount": "100.50",
        "currency": "usd",
        "crypto": "100.5"
      },
      "paid": {
        "amount": "0",
        "currency": "usd",
        "crypto": "0"
      }
    },
    "fees": {
      "merchant": {
        "amount": "0",
        "currency": "usd",
        "crypto": "0"
      },
      "network": {
        "amount": "0",
        "currency": "usd",
        "crypto": "0"
      },
      "total": {
        "amount": "0",
        "currency": "usd",
        "crypto": "0"
      }
    },
    "payment": {
      "coin": "usdt",
      "network": "tron",
      "address": "TXHXiQ2aXYqtz3y9gEGiVjzxXxXechdLwE",
      "rate": "1.0001"
    },
    "fee_payer": {
      "merchant_fee": true,
      "network_fee": true
    },
    "created_at": "2025-01-01T11:30:00Z",
    "expired_at": "2025-01-02T11:30:00Z"
  }
}
```

### `wallet.transaction`

Sent when a static wallet receives a deposit.

```json
{
  "event": "wallet.transaction",
  "timestamp": 1746984120,
  "data": {
    "wallet": {
      "address": "TXHXiQ2aXYqtz3y9gEGiVjzxXxXechdLwE",
      "network": "tron",
      "coin": "usdt",
      "label": "Main wallet",
      "rate": "1.0001"
    },
    "transaction": {
      "id": "87654321-4321-4321-4321-210987654321",
      "hash": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
      "block_number": 53214321,
      "amounts": {
        "gross": {
          "amount": "10.05",
          "currency": "usd",
          "crypto": "10.05"
        },
        "net": {
          "amount": "9.849",
          "currency": "usd",
          "crypto": "9.849"
        }
      },
      "fees": {
        "merchant": {
          "amount": "0.201",
          "currency": "usd",
          "crypto": "0.201"
        },
        "network": {
          "amount": "0",
          "currency": "usd",
          "crypto": "0"
        },
        "total": {
          "amount": "0.201",
          "currency": "usd",
          "crypto": "0.201"
        }
      },
      "from_address": "TMDKznuDWaZwfZHcM61FYFRx9dAVPndpNv",
      "to_address": "TXHXiQ2aXYqtz3y9gEGiVjzxXxXechdLwE",
      "status": "success",
      "timestamp": 1746984100
    }
  }
}
```

### `aml.rejected.invoice`

Sent when AML screening blocks an invoice deposit. Snapshot details supplied by
the AML flow are carried inside `risk_details`.

```json
{
  "event": "aml.rejected.invoice",
  "timestamp": 1765472000,
  "data": {
    "wallet_address": "TXHXiQ2aXYqtz3y9gEGiVjzxXxXechdLwE",
    "coin": "usdt",
    "network": "tron",
    "amounts": {
      "amount": "100.00",
      "currency": "usd",
      "crypto": "1"
    },
    "invoice": {
      "id": "5118a064-d5b9-4fab-ad14-8cf42f0020de",
      "code": "INV123",
      "order_id": "ORDER-1"
    },
    "risk_score": 95,
    "risk_level": "severe",
    "risk_details": {
      "amount_smallest": "1000000",
      "usd_cents": "10000",
      "balance_snapshot": "1000000",
      "source": "misttrack"
    }
  }
}
```

### `aml.rejected.static_wallet`

Sent when AML blocks a static-wallet deposit. Snapshot details supplied by the
AML flow are carried inside `risk_details`.

```json
{
  "event": "aml.rejected.static_wallet",
  "timestamp": 1765472000,
  "data": {
    "wallet_address": "TXHXiQ2aXYqtz3y9gEGiVjzxXxXechdLwE",
    "coin": "usdt",
    "network": "tron",
    "amounts": {
      "amount": "250.00",
      "currency": "usd",
      "crypto": "2.5"
    },
    "static_wallet": {
      "id": "87654321-4321-4321-4321-210987654321",
      "label": "USDT Payment Address"
    },
    "risk_score": 87,
    "risk_level": "high",
    "risk_details": {
      "amount_smallest": "2500000",
      "usd_cents": "25000",
      "balance_snapshot": "2500000",
      "source": "misttrack"
    }
  }
}
```

### `withdrawal.in_progress`

Sent when a withdrawal request is being processed.

```json
{
  "event": "withdrawal.in_progress",
  "timestamp": 1746984100,
  "data": {
    "withdrawal_id": "5118a064-d5b9-4fab-ad14-8cf42f0020de",
    "status": "processing",
    "amounts": {
      "requested": {
        "amount": "5.0000",
      "currency": "usd",
      "crypto": "5"
      },
      "net": {
        "amount": "5.0000",
        "currency": "usd",
        "crypto": "5"
      }
    },
    "fees": {
      "merchant": {
        "amount": "0.0225",
        "currency": "usd",
        "crypto": "0.0225"
      },
      "network": {
        "amount": "1.4000",
        "currency": "usd",
        "crypto": "1.4"
      },
      "total": {
        "amount": "1.4225",
        "currency": "usd",
        "crypto": "1.4225"
      }
    },
    "payment": {
      "coin": "usdt",
      "network": "tron",
      "to_address": "TLbqtn4EPerCj5a3TmbSffadYgDZ3UEmoe",
      "rate": "1.0001"
    },
    "created_at": "2025-05-11T17:20:50+00:00",
    "updated_at": "2025-05-11T17:21:40+00:00",
    "transaction_hash": "22255482649487ca9f6f70a618c5b2e5e93399c09fd85ab589eff20cb0996e34",
    "fee_payer": {
      "merchant_fee": false,
      "network_fee": false
    }
  }
}
```

### `withdrawal.success`

Sent when a withdrawal has been successfully completed.

```json
{
  "event": "withdrawal.success",
  "timestamp": 1746984108,
  "data": {
    "withdrawal_id": "5118a064-d5b9-4fab-ad14-8cf42f0020de",
    "status": "completed",
    "amounts": {
      "requested": {
        "amount": "5.0000",
        "currency": "usd",
        "crypto": "5"
      },
      "net": {
        "amount": "5.0000",
        "currency": "usd",
        "crypto": "5"
      }
    },
    "fees": {
      "merchant": {
        "amount": "0.0225",
        "currency": "usd",
        "crypto": "0.0225"
      },
      "network": {
        "amount": "1.4000",
        "currency": "usd",
        "crypto": "1.4"
      },
      "total": {
        "amount": "1.4225",
        "currency": "usd",
        "crypto": "1.4225"
      }
    },
    "payment": {
      "coin": "usdt",
      "network": "tron",
      "to_address": "TLbqtn4EPerCj5a3TmbSffadYgDZ3UEmoe",
      "rate": "1.0001"
    },
    "created_at": "2025-05-11T17:20:50+00:00",
    "transaction_hash": "934cc03cfac6a1fe99be9156fde1807cfbe74e1ed7fe5e184596b932b7c9f30a",
    "updated_at": "2025-05-11T17:21:48+00:00",
    "fee_payer": {
      "merchant_fee": false,
      "network_fee": false
    }
  }
}
```

### `withdrawal.failed`

Sent when a withdrawal has failed.

```json
{
  "event": "withdrawal.failed",
  "timestamp": 1746984110,
  "data": {
    "withdrawal_id": "5118a064-d5b9-4fab-ad14-8cf42f0020de",
    "status": "failed",
    "amounts": {
      "requested": {
        "amount": "5.0000",
        "currency": "usd",
        "crypto": "5"
      },
      "net": {
        "amount": "5.0000",
        "currency": "usd",
        "crypto": "5"
      }
    },
    "fees": {
      "merchant": {
        "amount": "0.0225",
        "currency": "usd",
        "crypto": "0.0225"
      },
      "network": {
        "amount": "1.4000",
        "currency": "usd",
        "crypto": "1.4"
      },
      "total": {
        "amount": "1.4225",
        "currency": "usd",
        "crypto": "1.4225"
      }
    },
    "payment": {
      "coin": "usdt",
      "network": "tron",
      "to_address": "TLbqtn4EPerCj5a3TmbSffadYgDZ3UEmoe",
      "rate": "1.0001"
    },
    "created_at": "2025-05-11T17:20:50+00:00",
    "updated_at": "2025-05-11T17:21:50+00:00",
    "error_message": "Insufficient energy on network",
    "fee_payer": {
      "merchant_fee": false,
      "network_fee": false
    }
  }
}
```

## Responding to Webhooks

Your server should respond with a 2xx HTTP status to acknowledge receipt. A
non-2xx response or transport failure can result in another delivery. The exact
attempt count and schedule are not guaranteed, so do not depend on a fixed
retry count.

### Delivery Idempotency

Withdrawal events currently include a stable header:

```text
X-Idempotency-Key: withdrawal:{withdrawal_id}:{event}
```

The same key is reused across withdrawal delivery attempts and manual resends.
Invoice, wallet, AML, and test events do not currently guarantee this header.
For those events, store a receiver-side deduplication key built from the event
and its stable entity identifier where available. Make processing idempotent
even when the header is absent.

## Testing Webhooks

You can test your webhook integration from your merchant dashboard using the "Test Webhook" feature. This will send a test webhook to your configured webhook URL with sample data.

### Test Webhook Payload

The dashboard sends a simple payload when you trigger a test:

```json
{
  "event": "test",
  "timestamp": 1746984000,
  "data": {
    "merchant_id": "12345678-1234-1234-1234-123456789012",
    "test": true,
    "message": "This is a test webhook to verify your integration / - ё"
  }
}
```

## Webhook Delivery

- Webhooks are delivered in real-time as events occur
- Delivery can be attempted more than once; the exact retry policy is not a public constant
- Delivery outcomes are logged for operational review
- Failed webhooks can be manually resent from the merchant dashboard

## Best Practices

1. **Respond Quickly**: Your webhook endpoint should respond as quickly as possible, ideally within a few seconds.

2. **Process Asynchronously**: To ensure quick responses, process the webhook data asynchronously after acknowledging receipt.

3. **Verify Signatures**: Always verify the webhook signature to ensure the request is legitimate.

4. **Handle Duplicate Events**: Use `X-Idempotency-Key` when present and an event/entity deduplication key otherwise.

5. **Monitor Webhook Failures**: Regularly check your merchant dashboard for failed webhook deliveries.

6. **Test Your Integration**: Use the test webhook feature to ensure your endpoint properly handles and processes webhook data.

---

Source: https://docs.coinssend.com/authentication.md

# Authentication

CoinsSend merchant API endpoints use request signatures. Keep signing code and the API key on a trusted server; never put a merchant API key in browser or mobile code.

Authenticated endpoints require:

| Header      | Required                                             | Description                                                       |
| ----------- | ---------------------------------------------------- | ----------------------------------------------------------------- |
| `Merchant`  | Yes                                                  | Merchant ID                                                       |
| `Sign`      | Yes                                                  | Lowercase hexadecimal request signature                           |
| `Timestamp` | Withdrawals: yes; other merchant endpoints: optional | Unix timestamp in seconds. `X-Timestamp` is accepted as an alias. |

Public catalog and checkout lookup endpoints do not use these headers. See the verified allowlist in [API Reference](https://docs.coinssend.com/api-reference.md) or [OpenAPI](https://docs.coinssend.com/openapi.json).

## Canonical JSON Body

Request signing does **not** use arbitrary raw JSON formatting. For a JSON object or array, the API parses the body and re-encodes it with PHP's default `json_encode` behavior before verifying the signature. That canonical form:

- has no insignificant whitespace;
- preserves object member order;
- escapes `/` as `\/`;
- escapes non-ASCII characters as lowercase `\uXXXX` sequences;
- uses an empty string for a request with no body.

Generate this canonical JSON once, sign it, and send those same bytes as the HTTP body. Keep money values as strings and avoid numeric object keys or language-specific floating-point values.

Interoperability fixtures, including whitespace, URL, Unicode, withdrawal, and empty-GET cases, are in [`signature-test-vectors.json`](https://docs.coinssend.com/signature-test-vectors.json).

### Canonical JSON Helpers

<div datatype="code-tabs">

```php
<?php

function canonicalJson(array $payload): string
{
    return json_encode($payload, JSON_THROW_ON_ERROR);
}
```

```javascript
// Node.js. Object insertion order must match the body you intend to send.
function canonicalJson(payload) {
  return JSON.stringify(payload)
    .replace(/\//g, '\\/')
    .replace(/[\u0080-\uFFFF]/g, character =>
      `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`
    )
}
```

```python
import json

def canonical_json(payload):
    encoded = json.dumps(
        payload,
        ensure_ascii=False,
        separators=(',', ':'),
        allow_nan=False,
    )
    escaped = []
    for character in encoded:
        codepoint = ord(character)
        if character == '/':
            escaped.append(r'\/')
        elif codepoint < 0x80:
            escaped.append(character)
        elif codepoint <= 0xFFFF:
            escaped.append(f'\\u{codepoint:04x}')
        else:
            codepoint -= 0x10000
            escaped.append(f'\\u{0xD800 + (codepoint >> 10):04x}')
            escaped.append(f'\\u{0xDC00 + (codepoint & 0x3FF):04x}')
    return ''.join(escaped)
```

</div>

## Legacy MD5 Signature

Invoice creation, wallet creation, balances, and fees accept the legacy signature when no timestamp header is supplied:

```text
canonical_body = canonical_json(request_object) or "" for a bodyless request
payload_base64 = base64_utf8(canonical_body)
Sign = md5(payload_base64 + API_KEY)
```

MD5 here is part of the current compatibility protocol, not a password-hashing recommendation. Always use HTTPS and keep the key server-side.

<div datatype="code-tabs">

```php
<?php

$body = json_encode($payload, JSON_THROW_ON_ERROR);
$sign = md5(base64_encode($body).$apiKey);
```

```javascript
const crypto = require('node:crypto')

const body = canonicalJson(payload)
const payloadBase64 = Buffer.from(body, 'utf8').toString('base64')
const sign = crypto.createHash('md5').update(payloadBase64 + apiKey).digest('hex')
```

```python
import base64
import hashlib

body = canonical_json(payload)
payload_base64 = base64.b64encode(body.encode('utf-8')).decode('ascii')
sign = hashlib.md5((payload_base64 + api_key).encode('utf-8')).hexdigest()
```

</div>

## Timestamped HMAC Signature

`POST /v1/withdrawals` requires a Unix timestamp within 300 seconds of server time. Other authenticated endpoints also accept this mode when `Timestamp` or `X-Timestamp` is supplied.

```text
canonical_body = canonical_json(request_object) or "" for a bodyless request
payload_base64 = base64_utf8(canonical_body)
message = payload_base64 + "." + Timestamp
Sign = hmac_sha256(message, API_KEY)
```

Send exactly the timestamp string used in the signature:

```text
Merchant: 12345678-1234-1234-1234-123456789012
Timestamp: 1716200000
Sign: 64-character-lowercase-hmac-sha256-hex
```

<div datatype="code-tabs">

```php
<?php

$body = json_encode($payload, JSON_THROW_ON_ERROR);
$payloadBase64 = base64_encode($body);
$sign = hash_hmac('sha256', $payloadBase64.'.'.$timestamp, $apiKey);
```

```javascript
const crypto = require('node:crypto')

const body = canonicalJson(payload)
const payloadBase64 = Buffer.from(body, 'utf8').toString('base64')
const sign = crypto
  .createHmac('sha256', apiKey)
  .update(`${payloadBase64}.${timestamp}`)
  .digest('hex')
```

```python
import base64
import hashlib
import hmac

body = canonical_json(payload)
payload_base64 = base64.b64encode(body.encode('utf-8')).decode('ascii')
message = f'{payload_base64}.{timestamp}'
sign = hmac.new(api_key.encode('utf-8'), message.encode('utf-8'), hashlib.sha256).hexdigest()
```

</div>

The timestamp prevents stale replay but does not make a write idempotent. In particular, it is unsafe to automatically repeat a withdrawal after an unknown outcome.

## Complete Invoice Example

```javascript
const crypto = require('node:crypto')

function canonicalJson(payload) {
  return JSON.stringify(payload)
    .replace(/\//g, '\\/')
    .replace(/[\u0080-\uFFFF]/g, character =>
      `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`
    )
}

const payload = {
  order_id: 'order_12345',
  amount: '100.50',
  success_url: 'https://merchant.example/success'
}
const body = canonicalJson(payload)
const payloadBase64 = Buffer.from(body, 'utf8').toString('base64')
const sign = crypto.createHash('md5').update(payloadBase64 + process.env.COINSSEND_API_KEY).digest('hex')

const response = await fetch('https://api.coinssend.com/v1/invoices', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Merchant: process.env.COINSSEND_MERCHANT_ID,
    Sign: sign
  },
  body
})
```

## GET Requests

For an authenticated GET with no body, the canonical body and Base64 payload are both empty. The legacy signature is therefore:

```text
Sign = md5("" + API_KEY)
```

Do not add merchant signature headers to public `GET /v1/invoices/{invoiceCode}`, `GET /v1/get-coin-rate`, or `GET /v1/coins-and-fee` calls.

## Authentication Errors

Authentication failures return a compact body such as:

```json
{
  "error": "Invalid sign"
}
```

| HTTP status | Message                         | Meaning                                               |
| ----------- | ------------------------------- | ----------------------------------------------------- |
| `400`       | `Missing headers`               | `Merchant` or `Sign` is missing                       |
| `400`       | `Missing timestamp`             | Withdrawal timestamp is missing                       |
| `401`       | `Invalid merchant`              | Merchant or API key was not found                     |
| `401`       | `Invalid sign`                  | Signature verification failed                         |
| `401`       | `Timestamp expired`             | Timestamp is invalid or outside the 300-second window |
| `403`       | `Merchant account is suspended` | Merchant is suspended                                 |

These messages are not a separate `code` property. Endpoint and validation
errors use different documented shapes; always branch on the HTTP status and
then parse the applicable schema.

## Operational Guidance

- Keep the API key in a secrets manager or server environment variable.
- Generate and send the body as one immutable string.
- Use a clock synchronized with NTP for withdrawal timestamps.
- Compare webhook HMAC values in constant time; webhook signing is a separate raw-body contract described in [Webhooks](https://docs.coinssend.com/webhooks.md).
- Use the published test vectors in automated client tests before making live calls.

---

Source: https://docs.coinssend.com/error-handling.md

# Error Handling

CoinsSend does not use one universal error envelope and does not guarantee a
symbolic `code` field. Branch on HTTP status first, then parse the applicable
documented shape.

## Success Envelope

Most successful operations return:

```json
{
  "data": {},
  "status": "success"
}
```

Successful write operations currently return HTTP `200`, including asynchronous
withdrawal submission.

## Error Shapes

### Authentication Errors

Header authentication failures return only `error`:

```json
{
  "error": "Invalid sign"
}
```

Verified messages include `Missing headers`, `Missing timestamp`,
`Invalid merchant`, `Invalid sign`, `Timestamp expired`, and
`Merchant account is suspended`.

### Endpoint Errors

Endpoint failures normally return:

```json
{
  "status": "error",
  "message": "Order id already exists"
}
```

Some endpoints add fields at the top level. Provider-card failures add
`failure_code`; withdrawal failures can add amounts and fee details. Treat
human-readable message text as descriptive rather than a stable machine code.

### Validation

Validation failures use HTTP `422`:

```json
{
  "errors": {
    "amount": [
      "The amount field is required."
    ]
  },
  "status": "error",
  "message": "The given data was invalid."
}
```

### Rate Limiting

The wallet-creation limit returns `status` and `message`. Other HTTP `429`
responses may use a different shape or include `retry_after` and a
`Retry-After` header. Treat rate-limit metadata as optional; see
[Rate Limits and Safe Retries](https://docs.coinssend.com/rate-limits.md).

## HTTP Statuses

| Status | Meaning in the public contract                                                                        |
| ------ | ----------------------------------------------------------------------------------------------------- |
| `400`  | Missing auth input or an endpoint rejection. Withdrawal permission denial currently uses this status. |
| `401`  | Invalid merchant/signature or expired timestamp.                                                      |
| `403`  | Suspended/blocked merchant state or authorization failure.                                            |
| `404`  | Public invoice or QR resource not found.                                                              |
| `409`  | Invoice `order_id` already exists, or wallet creation is already in progress.                         |
| `422`  | Request validation or provider-card capability/state failure.                                         |
| `429`  | A rate limit was exceeded.                                                                            |
| `500`  | Unexpected application failure.                                                                       |
| `502`  | Provider-card checkout could not obtain a usable provider response.                                   |

## Endpoint-Specific Cases

The labels in this table are suggested **client-side categories**. They are not
wire fields returned by CoinsSend.

| Client category                | HTTP status   | Endpoint / condition                                  | Wire shape                                |
| ------------------------------ | ------------- | ----------------------------------------------------- | ----------------------------------------- |
| `missing_headers`              | `400`         | Authenticated endpoint without Merchant or Sign       | `{"error":"Missing headers"}`             |
| `missing_timestamp`            | `400`         | Withdrawal without Timestamp/X-Timestamp              | `{"error":"Missing timestamp"}`           |
| `invalid_signature`            | `401`         | Signature mismatch                                    | `{"error":"Invalid sign"}`                |
| `timestamp_expired`            | `401`         | Invalid or stale timestamp                            | `{"error":"Timestamp expired"}`           |
| `invoice_order_conflict`       | `409`         | Duplicate merchant `order_id`                         | endpoint error                            |
| `validation_failed`            | `422`         | Invalid invoice, wallet, or withdrawal input          | validation error                          |
| `withdrawal_permission_denied` | `400`         | API key lacks withdrawal permission                   | endpoint error                            |
| `withdrawal_rejected`          | `400`         | Balance/fee/service rejection                         | endpoint error with possible extra fields |
| `provider_card_unavailable`    | usually `422` | Current availability or invoice state blocks checkout | endpoint error with `failure_code`        |
| `wallet_creation_in_progress`  | `409`         | Merchant-scoped wallet lock conflict                  | endpoint error                            |

## Robust Parsing

<div datatype="code-tabs">

```php
<?php

function apiErrorMessage(array $body, int $httpStatus): string
{
    return (string) ($body['error'] ?? $body['message'] ?? "HTTP {$httpStatus}");
}

function validationErrors(array $body): array
{
    return is_array($body['errors'] ?? null) ? $body['errors'] : [];
}
```

```javascript
async function parseCoinsSendResponse(response) {
  const body = await response.json()

  if (!response.ok) {
    const error = new Error(body.error || body.message || `HTTP ${response.status}`)
    error.httpStatus = response.status
    error.validationErrors = body.errors || {}
    error.failureCode = body.failure_code
    throw error
  }

  return body.data
}
```

```python
def parse_coinssend_response(response):
    body = response.json()
    if response.ok:
        return body.get('data')

    message = body.get('error') or body.get('message') or f'HTTP {response.status_code}'
    error = RuntimeError(message)
    error.http_status = response.status_code
    error.validation_errors = body.get('errors', {})
    error.failure_code = body.get('failure_code')
    raise error
```

</div>

## Retry Safety

Automatically retry only verified read operations with bounded exponential
backoff and jitter. A timeout on a write does not prove the server rejected it.

- Duplicate invoice `order_id` returns `409`; it does not replay the first
  response.
- Withdrawals have no request idempotency key.
- Static-wallet creation can create another address when repeated.
- Provider-card creation only reuses an already-active order; this is not a
  general idempotency contract.

Reconcile an unknown write outcome instead of blindly resubmitting it. Full
guidance is in [Rate Limits and Safe Retries](https://docs.coinssend.com/rate-limits.md).

## Troubleshooting Invalid Signatures

- Generate the PHP-compatible canonical JSON described in
  [Authentication](https://docs.coinssend.com/authentication.md).
- Sign and send the same immutable canonical string.
- Use strings for monetary amounts.
- For withdrawals, recompute a fresh timestamp and HMAC.
- Run the client against
  [`signature-test-vectors.json`](https://docs.coinssend.com/signature-test-vectors.json), including the
  URL/Unicode vector.
- Remember that webhook verification is different: it uses the exact raw
  received body bytes.

The exact machine-readable response schemas are in
[OpenAPI](https://docs.coinssend.com/openapi.json).

---

Source: https://docs.coinssend.com/rate-limits.md

# Rate Limits and Safe Retries

The public contract does not promise universal `X-RateLimit-*` headers or one
fixed global request count. Clients must handle HTTP `429` on any operation.

## Documented Wallet-Creation Limit

`POST /v1/wallet-address` has this merchant limit:

- threshold: 100 successful wallet creations;
- window: 60 seconds;
- key: current merchant;
- failed creation attempts are not counted;
- rejection: HTTP `429` with `status` and `message`.

Example:

```json
{
  "status": "error",
  "message": "Too many requests. Please try again later."
}
```

This response does not currently guarantee
`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`,
`Retry-After`, or `retry_after`.

## Additional Limits

Additional limits may apply. Their response bodies and headers are not part of
a universal contract. Clients should tolerate HTTP `429` whether or not
`Retry-After` is present.

## Retry Decision Table

| Operation                                              | Automatic retry after `429` or transport failure? | Reason                                                                                 |
| ------------------------------------------------------ | ------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `GET /v1/get-coin-rate`                                | Yes, with bounded backoff                         | Read-only public discovery                                                             |
| `GET /v1/coins-and-fee`                                | Yes, with bounded backoff and caching             | Read-only public discovery                                                             |
| `GET /v1/merchants/balances`                           | Yes, with a fresh signature/timestamp when used   | Read-only merchant state                                                               |
| `GET /v1/merchants/fees`                               | Yes, with a fresh signature/timestamp when used   | Read-only merchant state                                                               |
| `GET /v1/invoices/{invoiceCode}`                       | Yes                                               | Read-only checkout state                                                               |
| `GET /v1/wallet-addresses/{walletAddress}/qr`          | Yes                                               | Read-only binary response                                                              |
| `POST /v1/invoices`                                    | No blind retry after an unknown outcome           | `order_id` uniqueness returns `409`; it does not replay the original response          |
| `POST /v1/withdrawals`                                 | No                                                | No request idempotency key; timestamp freshness is not idempotency                     |
| `POST /v1/wallet-address`                              | No for static wallets                             | A successful retry can create another address                                          |
| `POST /v1/invoices/{invoiceCode}/provider-card-orders` | Do not generalize                                 | Only an already-active order is reused; there is no general `Idempotency-Key` contract |

An HTTP response proves whether a request was rejected. A client-side timeout
does not: the server may already have accepted the write.

## Backoff for Safe Reads

For read operations, use bounded exponential backoff with jitter:

```javascript
async function retrySafeRead(makeRequest, maxAttempts = 4) {
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
    const response = await makeRequest()

    if (response.status !== 429 || attempt === maxAttempts) {
      return response
    }

    const retryAfterHeader = response.headers.get('Retry-After')
    const retryAfter = retryAfterHeader === null ? Number.NaN : Number(retryAfterHeader)
    const baseDelayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1000
      : Math.min(500 * 2 ** (attempt - 1), 8000)
    const jitterMs = Math.floor(Math.random() * 250)

    await new Promise(resolve => setTimeout(resolve, baseDelayMs + jitterMs))
  }
}
```

For timestamp-signed reads, recompute `Timestamp` and `Sign` for each attempt.
Never sleep and reuse a timestamp that can age beyond the 300-second signature
window.

## Handling an Unknown Write Outcome

1. Persist the client order/reference and request intent before sending.
2. If a complete success response arrives, persist the returned CoinsSend ID.
3. If the result is unknown, do not issue a blind duplicate.
4. Reconcile using a known returned/public identifier where one exists, or
   investigate through merchant operations/support.
5. For withdrawals, wait for the signed withdrawal webhook when an ID was
   obtained; otherwise escalate the unknown outcome instead of resubmitting.

## Monitoring

- Record endpoint, HTTP status, request correlation data, and `Retry-After` when
  present; never log the API key or full `Sign` header.
- Separate application `429` responses from edge `429` responses.
- Alert on sustained rate limiting and unknown outcomes for write operations.
- Cache the catalog according to its advertised 900-second cache lifetime.

For request-signature freshness and canonicalization, see
[Authentication](https://docs.coinssend.com/authentication.md). For exact operation metadata, see
[OpenAPI](https://docs.coinssend.com/openapi.json).
