# CoinsSend Payments: Withdrawal integration

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/withdrawals.md

# Withdrawals API

This document describes the endpoints for initiating cryptocurrency withdrawals.

<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> To use this API, you must first enable the "<strong>Allow Withdrawals</strong>" setting in your merchant dashboard. Withdrawal requests will be rejected if this setting is not enabled.
  </div>
</div>

## Initiate Withdrawal

Initiates a withdrawal of funds from your merchant account to an external wallet address.

### Request

```
POST /v1/withdrawals
```

### Headers

| Name           | Required | Description                                                                                                                      |
| -------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `Content-Type` | Yes      | Must be `application/json`                                                                                                       |
| `Merchant`     | Yes      | Your merchant ID                                                                                                                 |
| `Timestamp`    | Yes      | Unix timestamp in seconds. Must be within 5 minutes of server time. `X-Timestamp` is also accepted.                              |
| `Sign`         | Yes      | HMAC-SHA256 withdrawal signature (see [Authentication](https://docs.coinssend.com/authentication.md#timestamped-hmac-signature)) |

Generate the canonical JSON body described in [Authentication](https://docs.coinssend.com/authentication.md),
sign it, and send that same string as the request body:

```text
Sign = hmac_sha256(base64(canonical_json_body) + "." + Timestamp, API_KEY)
```

### Request Parameters

| Parameter    | Type   | Required | Description                                                                                                             |
| ------------ | ------ | -------- | ----------------------------------------------------------------------------------------------------------------------- |
| `network`    | string | Yes      | Blockchain network (e.g., "tron", "bsc")                                                                                |
| `coin`       | string | Yes      | Cryptocurrency (e.g., "usdt", "usdc")                                                                                   |
| `amount`     | string | Yes      | Amount to withdraw in actual value (the minimum depends on the selected asset/network pair, e.g., "50.5" for 50.5 USDT) |
| `to_address` | string | Yes      | Destination wallet address                                                                                              |

> **Asset catalog:** Use `GET /v1/coins-and-fee` to fetch the currently enabled
> coin/network pairs, network fees, and minimum withdrawal values. These values
> can change. The withdrawal endpoint remains the final validation
> authority for the pair and amount.

See [Supported Coins & Networks](https://docs.coinssend.com/supported-coins.md) for the coin/network
tables and guidance on displaying withdrawal options.

### Example Request

```json
{
  "network": "tron",
  "coin": "usdt",
  "amount": "50.75",
  "to_address": "TXHXiQ2aXYqtz3y9gEGiVjzxXxXechdLwE"
}
```

<div class="alert alert-info">
  <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>Note:</strong> Minimum and maximum withdrawal amounts depend on the current asset/network settings. Do not embed example thresholds as permanent constants.
  </div>
</div>

### Example Response

```json
{
  "data": {
    "success": true,
    "withdrawal_id": "5118a064-d5b9-4fab-ad14-8cf42f0020de",
    "message": "Withdrawal request submitted successfully",
    "status": "pending",
    "amounts": {
      "requested": {
        "crypto": "100.00",
        "usd": "100.00"
      },
      "net": {
        "crypto": "98.15",
        "usd": "98.15"
      }
    },
    "fees": {
      "merchant": {
        "crypto": "0.45",
        "usd": "0.45"
      },
      "network": {
        "crypto": "1.40",
        "usd": "1.40"
      },
      "total": {
        "crypto": "1.85",
        "usd": "1.85"
      }
    },
    "fee_payer": {
      "merchant_fee": false,
      "network_fee": false
    }
  },
  "status": "success"
}
```

### Response Field Descriptions

| Field                           | Type    | Description                                                        |
| ------------------------------- | ------- | ------------------------------------------------------------------ |
| `data`                          | object  | Response data container                                            |
| `data.success`                  | boolean | Whether the withdrawal request succeeded                           |
| `data.withdrawal_id`            | string  | Unique withdrawal identifier                                       |
| `data.message`                  | string  | Human-readable message                                             |
| `data.status`                   | string  | Current withdrawal status (pending, processing, completed, failed) |
| `data.amounts`                  | object  | Requested and net withdrawal amounts                               |
| `data.amounts.requested.crypto` | string  | Requested amount in cryptocurrency                                 |
| `data.amounts.requested.usd`    | string  | Requested amount in USD                                            |
| `data.amounts.net.crypto`       | string  | Net amount after fees in cryptocurrency                            |
| `data.amounts.net.usd`          | string  | Net amount after fees in USD                                       |
| `data.fees`                     | object  | Fee breakdown                                                      |
| `data.fees.merchant.crypto`     | string  | Merchant fee in cryptocurrency                                     |
| `data.fees.merchant.usd`        | string  | Merchant fee in USD                                                |
| `data.fees.network.crypto`      | string  | Network fee in cryptocurrency                                      |
| `data.fees.network.usd`         | string  | Network fee in USD                                                 |
| `data.fees.total.crypto`        | string  | Total fee in cryptocurrency                                        |
| `data.fees.total.usd`           | string  | Total fee in USD                                                   |
| `data.fee_payer.merchant_fee`   | boolean | Whether merchant pays the merchant fee                             |
| `data.fee_payer.network_fee`    | boolean | Whether merchant pays the network fee                              |
| `status`                        | string  | Overall response status ("success" or "error")                     |

If your merchant account has the "Merchant Pays" settings enabled, the fields in `fee_payer` would be `true` and the `amounts.net.crypto` would equal the requested `amounts.requested.crypto`.

### Error Responses

| HTTP status | Response shape                                                   | Description                                                     |
| ----------- | ---------------------------------------------------------------- | --------------------------------------------------------------- |
| `400`       | `{"error":"Missing headers"}` or `{"error":"Missing timestamp"}` | Required authentication input is missing                        |
| `400`       | endpoint error with `status` and `message`                       | Includes withdrawal-permission denial and balance/fee rejection |
| `401`       | authentication `{"error":"..."}`                                 | Invalid merchant, signature, or expired timestamp               |
| `403`       | authentication or endpoint error                                 | Merchant status blocks withdrawals                              |
| `422`       | validation error with `errors`, `status`, and `message`          | Pair, amount, or destination validation failed                  |

### Example Error Response

```json
{
  "status": "error",
  "message": "Insufficient available balance"
}
```

### Withdrawal Limits

Each asset/network pair has current minimum and maximum amounts. Fetch the
current discovery catalog immediately before presenting withdrawal choices and
handle `422` as the authoritative answer if configuration changes between
discovery and submission. The public schema intentionally does not publish a
closed asset enum or static limit table.

### Merchant Settings for Withdrawals

To use the Withdrawals API, you must first enable withdrawals in your merchant settings by checking the "**Allow Withdrawals**" checkbox. The description in settings reads: "Enable this to allow withdrawals via API". This setting is required before any withdrawal API calls will work.

Additionally, there are two important settings that control how withdrawal fees are handled:

1. **"Pay Withdrawal Network Fee"** - The description in settings reads: "Enable this if merchant will pay withdrawal network fee". When enabled, the network fee is deducted from the merchant's balance instead of the withdrawal amount.

2. **"Pay Withdrawal Service Fee"** - The description in settings reads: "Enable this if merchant will pay withdrawal service fee". When enabled, the merchant fee is deducted from the merchant's balance instead of the withdrawal amount.

If both checkboxes are enabled, the recipient will receive exactly the amount specified in the API request, as all fees will be deducted from your merchant balance instead of the withdrawal amount.

### Withdrawal Fees

When initiating withdrawals, two types of fees apply:

1. **Network Fee**: A fixed fee for processing transactions on the blockchain
2. **Merchant Fee**: A percentage-based fee applied to the withdrawal amount

#### Fee Discovery

Use `GET /v1/merchants/fees` for merchant-specific percentages and fee-payer
settings, and `GET /v1/coins-and-fee` for current network-fee discovery. The
accepted withdrawal response is the authoritative fee snapshot for that
withdrawal.

#### Fee Calculation Examples

The following arithmetic is illustrative only; substitute live fee values.

**Scenario 1: Recipient Amount Is Reduced by Fees**

When both "Pay Withdrawal Service Fee" and "Pay Withdrawal Network Fee" checkboxes are disabled:

```
Withdrawal amount: 100 USDT
Network fee: 1.4 USDT (fixed)
Merchant fee: 100 × 0.45% = 0.45 USDT
Total fees: 1.4 + 0.45 = 1.85 USDT
Amount sent to destination: 100 - 1.85 = 98.15 USDT
Deducted from merchant balance: 0 USDT
```

**Scenario 2: Merchant Pays All Fees**

When both "Pay Withdrawal Service Fee" and "Pay Withdrawal Network Fee" checkboxes are enabled:

```
Withdrawal amount: 100 USDT
Network fee: 1.4 USDT (fixed)
Merchant fee: 100 × 0.45% = 0.45 USDT
Total fees: 1.4 + 0.45 = 1.85 USDT
Amount sent to destination: 100 USDT (full amount)
Deducted from merchant balance: 1.85 USDT (all fees)
```

The response includes the fields `fee_payer.merchant_fee` and `fee_payer.network_fee` to indicate which fee payment options are active for your merchant account. These correspond directly to the "Pay Withdrawal Service Fee" and "Pay Withdrawal Network Fee" settings.

### Additional Notes

- Withdrawals are processed asynchronously
- The HTTP `200` response contains the current projected status, commonly `pending` or `processing`; it is not final settlement
- You will receive `withdrawal.in_progress`, `withdrawal.success`, or `withdrawal.failed` webhooks
- The `to_address` must be valid for the selected network
- Always double-check the destination address before initiating a withdrawal
- There is no request `Idempotency-Key`. If the outcome is unknown, reconcile by the returned/stored withdrawal ID or contact support instead of automatically submitting the same withdrawal again.

## Code Examples

Generate withdrawal signatures only in trusted server-side code. Do not expose merchant API keys in browsers, mobile apps, or public frontend bundles.

<div datatype="code-tabs">

```php
<?php
// API credentials
$merchantId = 'your_merchant_id';
$apiKey = 'your_api_key';

// Withdrawal data
$withdrawalData = [
    'network' => 'tron',
    'coin' => 'usdt',
    'amount' => '50.75',
    'to_address' => 'TXHXiQ2aXYqtz3y9gEGiVjzxXxXechdLwE'
];

// Generate signature
$jsonData = json_encode($withdrawalData);
$base64Data = base64_encode($jsonData);
$timestamp = time();
$signature = hash_hmac('sha256', $base64Data . '.' . $timestamp, $apiKey);

// Set up request
$ch = curl_init('https://api.coinssend.com/v1/withdrawals');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $jsonData);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Content-Type: application/json',
    'Merchant: ' . $merchantId,
    'Timestamp: ' . $timestamp,
    'Sign: ' . $signature
]);

// Execute request
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

// Process response
if ($httpCode >= 200 && $httpCode < 300) {
    $responseData = json_decode($response, true);
    
    if ($responseData['data']['success'] === true) {
        echo "Withdrawal initiated successfully!\n";
        echo "Withdrawal ID: " . $responseData['data']['withdrawal_id'] . "\n";
        echo "Status: " . $responseData['data']['status'] . "\n";
        echo "Message: " . $responseData['data']['message'] . "\n";
    }
} else {
    $errorData = json_decode($response, true);
    echo "Error: " . ($errorData['error'] ?? $errorData['message'] ?? 'Unknown error') . "\n";
}
```

```javascript
// API credentials
const merchantId = 'your_merchant_id';
const apiKey = 'your_api_key';

// Withdrawal data
const withdrawalData = {
  'network': 'tron',
  'coin': 'usdt',
  'amount': '50.75',
  'to_address': 'TXHXiQ2aXYqtz3y9gEGiVjzxXxXechdLwE'
};

const crypto = require('node:crypto');

// Generate signature
const jsonData = JSON.stringify(withdrawalData)
  .replace(/\//g, '\\/')
  .replace(/[\u0080-\uFFFF]/g, character =>
    `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`
  );
const base64Data = Buffer.from(jsonData).toString('base64');
const timestamp = Math.floor(Date.now() / 1000).toString();
const signature = crypto
  .createHmac('sha256', apiKey)
  .update(`${base64Data}.${timestamp}`)
  .digest('hex');

// Make the API request
fetch('https://api.coinssend.com/v1/withdrawals', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Merchant': merchantId,
    'Timestamp': timestamp,
    'Sign': signature
  },
  body: jsonData
})
.then(response => {
  if (!response.ok) {
    return response.json().then(errorData => {
      throw new Error(errorData.error || errorData.message || `HTTP error! status: ${response.status}`);
    });
  }
  return response.json();
})
.then(data => {
  if (data.data.success) {
    console.log("Withdrawal initiated successfully!");
    console.log(`Withdrawal ID: ${data.data.withdrawal_id}`);
    console.log(`Status: ${data.data.status}`);
    console.log(`Message: ${data.data.message}`);
  }
})
.catch(error => {
  console.error('Withdrawal error:', error.message);
});
```

```python
import requests
import json
import base64
import hashlib
import hmac
import time

# API credentials
merchant_id = 'your_merchant_id'
api_key = 'your_api_key'

# Withdrawal data
withdrawal_data = {
    'network': 'tron',
    'coin': 'usdt',
    'amount': '50.75',
    'to_address': 'TXHXiQ2aXYqtz3y9gEGiVjzxXxXechdLwE'
}

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)


# Generate signature from the exact body that will be sent
json_data = canonical_json(withdrawal_data)
base64_data = base64.b64encode(json_data.encode()).decode()
timestamp = str(int(time.time()))
signature = hmac.new(
    api_key.encode(),
    f'{base64_data}.{timestamp}'.encode(),
    hashlib.sha256
).hexdigest()

# Set up headers
headers = {
    'Content-Type': 'application/json',
    'Merchant': merchant_id,
    'Timestamp': timestamp,
    'Sign': signature
}

# Make the API request
try:
    response = requests.post(
        'https://api.coinssend.com/v1/withdrawals', 
        headers=headers, 
        data=json_data
    )
    
    # Check for HTTP errors
    response.raise_for_status()
    
    # Parse the response
    response_data = response.json()
    
    if response_data.get('data', {}).get('success'):
        print("Withdrawal initiated successfully!")
        print(f"Withdrawal ID: {response_data['data']['withdrawal_id']}")
        print(f"Status: {response_data['data']['status']}")
        print(f"Message: {response_data['data']['message']}")
    else:
        print(f"Error: {response_data.get('message', 'Unknown error')}")
        
except requests.exceptions.HTTPError as e:
    error_data = {}
    try:
        error_data = response.json()
    except:
        pass
    
    error_message = error_data.get('error') or error_data.get('message') or str(e)
    print(f"HTTP Error: {error_message}")

except requests.exceptions.RequestException as e:
    print(f"Request Error: {str(e)}")
```

</div>

For more details on working with withdrawals, see [the Node.js withdrawal example](https://docs.coinssend.com/examples/node-withdrawal.md).

---

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/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/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).

---

Source: https://docs.coinssend.com/examples/node-withdrawal.md

# Node.js Example: Withdrawal and Webhook Receiver

Withdrawals require timestamped HMAC signing and must run on a trusted server.
There is no request idempotency key: do not automatically resubmit after a
timeout or another unknown outcome.

## Submit a Withdrawal

```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')}`
    )
}

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

async function initiateWithdrawal({ merchantId, apiKey, network, coin, amount, toAddress }) {
  const payload = {
    network,
    coin,
    amount,
    to_address: toAddress
  }
  const body = canonicalJson(payload)
  const timestamp = Math.floor(Date.now() / 1000).toString()

  const response = await fetch('https://api.coinssend.com/v1/withdrawals', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Merchant: merchantId,
      Timestamp: timestamp,
      Sign: withdrawalSign(body, timestamp, apiKey)
    },
    body
  })

  const result = await response.json()
  if (!response.ok) {
    throw new Error(result.error || result.message || `HTTP ${response.status}`)
  }

  return result.data
}

const withdrawal = await initiateWithdrawal({
  merchantId: process.env.COINSSEND_MERCHANT_ID,
  apiKey: process.env.COINSSEND_API_KEY,
  network: 'tron',
  coin: 'usdt',
  amount: '50.75',
  toAddress: process.env.WITHDRAWAL_DESTINATION
})

console.log(withdrawal.withdrawal_id, withdrawal.status)
```

HTTP `200` means the command was accepted; `data.status` is only the current
projected state. Persist `withdrawal_id` and wait for signed status webhooks.
Timestamp freshness limits replay but does not make the POST idempotent.

## Verify Withdrawal Webhooks

Webhook signing is different from request signing: verify HMAC-SHA256 over the
exact raw body bytes before parsing JSON.

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

const app = express()

function validWebhookSignature(rawBody, suppliedHex, apiKey) {
  if (typeof suppliedHex !== 'string' || !/^[a-f0-9]{64}$/.test(suppliedHex)) {
    return false
  }

  const expected = crypto.createHmac('sha256', apiKey).update(rawBody).digest()
  const supplied = Buffer.from(suppliedHex, 'hex')

  return supplied.length === expected.length && crypto.timingSafeEqual(supplied, expected)
}

app.post('/coinssend/webhook', express.raw({ type: 'application/json' }), async (request, response) => {
  const rawBody = request.body
  const signature = request.header('X-Signature')

  if (!validWebhookSignature(rawBody, signature, process.env.COINSSEND_API_KEY)) {
    return response.status(401).json({ error: 'Invalid signature' })
  }

  const event = JSON.parse(rawBody.toString('utf8'))
  const deliveryKey = request.header('X-Idempotency-Key')
    || `${event.event}:${event.data.withdrawal_id}`

  // Insert deliveryKey into a table with a unique constraint, enqueue new
  // events, and acknowledge duplicates without applying side effects twice.
  await enqueueIfNew(deliveryKey, event)

  return response.status(204).end()
})
```

Withdrawal deliveries currently use
`withdrawal:{withdrawal_id}:{event}` as `X-Idempotency-Key`. The header is not a
universal guarantee for other webhook families.
