> ## Documentation Index
> Fetch the complete documentation index at: https://assetpay.gg/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Crypto Deposits & Cashouts

> Let your users deposit and cash out in USDT and USDC on Ethereum, BNB Smart Chain, Solana and Tron: permanent deposit addresses, the approval call for cashouts, fees, limits and webhooks.

Your users can fund their balance on your platform with stablecoins and cash out to their own wallet. Every user gets a permanent deposit address per chain. AssetPay watches those addresses, credits your merchant balance when a deposit clears, and tells you which user it was for. A cashout runs the other way: it is paid out of your merchant balance to an address the user chose.

> [!NOTE]
>
> Crypto for end users is available to verified merchants only. Unverified merchants receive `ONCHAIN_MERCHANT_NOT_VERIFIED`.

## Supported Networks

| Chain | `chain` | Deposits | Cashouts |
| - | - | - | - |
| Ethereum | `ETH` | USDT, USDC | USDT, USDC |
| BNB Smart Chain | `BSC` | USDT, USDC | USDT, USDC |
| Solana | `SOL` | USDT, USDC (one at a time, see below) | USDT, USDC |
| Tron | `TRON` | USDT | USDT |

> [!WARNING]
>
> Only the tokens listed in the address response are credited. On Solana each token has its own account, so ask for the address with the `token` the user is about to send and wait until `tokens` lists it. Show your users the token list from the response, never a hard-coded one.

All amounts are in USD cents. USDT and USDC are credited 1:1.

## Deposits

**1. Ask for the user's address**

Call `GET /client/crypto/deposit-address?chain=ETH` with the client token, or `GET /secure/crypto/deposit-address` with your API key and a `steamId`. The first call creates the address. It never changes, so you can cache it and reuse it for every deposit on Ethereum, BNB Smart Chain and Tron.

**2. Show it to the user**

Display `address`, the network, the accepted `tokens`, `minDepositCents` and the deposit fee. While `status` is `paused` the address is valid but deposits are credited only once the chain resumes.

**3. The user sends the tokens**

AssetPay sees the transfer within seconds (on Tron after about a minute, once its block is final) and records a deposit with status `pending`.

**4. The deposit clears**

After `minConfirmations` blocks the deposit becomes `completed`, your merchant balance is credited, and you receive a `crypto_deposit` webhook with the `steamId`. Credit your user when that webhook arrives.

### Solana addresses are opened on request

A Solana account costs rent while it is open, so it is opened when the user asks for it and closed again once it has been emptied or has sat unused.

* Pass `token=USDT` or `token=USDC` (default `USDC`) for the token the user is about to send. USDT and USDC use separate accounts behind the same `address`, and each one is opened on its own.
* The first call answers `status: "pending"` with `address: null`. Poll the same endpoint with the same `token` every few seconds until it is `active` and `tokens` lists that token. This usually takes under a minute.
* Call the endpoint again before **every** later Solana deposit. If the account was closed in the meantime, the call reopens it.
* `ONCHAIN_ADDRESS_CAPACITY` (503) means too many of your users have an open Solana account right now. Retry later.

### Minimum deposit

| Chain | Minimum |
| - | - |
| Ethereum | \$10.00 |
| BNB Smart Chain | \$10.00 |
| Solana | \$10.00 |
| Tron | \$10.00 |

The live value is `minDepositCents` in the address response. A deposit under the minimum is recorded with status `below_minimum` and waits up to 72 hours for more to the same address. Once the total reaches the minimum, every waiting deposit is credited (`completed`); if it never does, each one ends `failed` and is **not credited**. Tell your users the minimum before they send.

### Deposit fee

Deposits on Tron pay a flat \$2.50, taken from the deposit, unless the deposit is \$10,000 or more. The other chains have no deposit fee. Your merchant balance is credited the amount minus the fee, and the `crypto_deposit` webhook carries `amount` (as sent), `fee` and `netAmount` (what was credited).

The address response states the terms for its chain: `depositFeeCents` is the fee and `depositFeeFreeFromCents` the amount from which none is charged (`null` when the chain has no fee). Show both to your users and read them from the response rather than hard-coding them.

Below-minimum deposits that reach the minimum together pay one fee between them. Those credited along with a deposit that cleared on its own pay none.

### Deposit statuses

| Status | Meaning |
| - | - |
| `pending` | Seen on the network, waiting for confirmations |
| `completed` | Cleared and credited to your merchant balance |
| `below_minimum` | Under the minimum, waiting up to 72 hours for more to the same address |
| `frozen` | Held for a compliance review. It ends as `completed` or `refunded` |
| `refunded` | Not credited; the funds are returned to the sender |
| `failed` | Not credited: dropped by the network before it cleared, or under the minimum for 72 hours |

## Cashouts

A cashout debits **your merchant balance**. Debit your own user before you let it go through.

There are two ways to start one:

| Route | Called by |
| - | - |
| `POST /client/crypto/withdraw` | The end user, with the client token |
| `POST /secure/crypto/withdraw` | Your server, with your API key and the user's `steamId` |

Both work the same way from there: AssetPay asks your server to approve every cashout before anything is sent, the same way it asks for a skin withdrawal. A cashout cannot be taken back once it is on chain, so even a request from your own server is confirmed through your callback URL.

### The approval call

Every cashout starts as `awaiting_approval`. The amount is already held from your merchant balance, and nothing is sent until your server answers.

AssetPay sends a signed `POST` to your callback URL, the same URL and the same [signature](https://assetpay.gg/docs/guides/callbacks.md#signature-verification) as the skin withdrawal approval:

```json theme={null}
{
  "withdrawal": {
    "id": "b0f3c1e2-...",
    "type": "crypto_withdraw",
    "status": "awaiting_approval",
    "steamId": "76561198012345678",
    "chain": "BSC",
    "token": "USDT",
    "amount": "25.00",
    "amountCents": 2500,
    "feeCents": 0,
    "receiveCents": 2500,
    "cryptoAmount": "25",
    "address": "0x9c1f...",
    "txHash": null,
    "reason": null,
    "createdAt": "2026-09-20T10:00:00.000Z",
    "updatedAt": "2026-09-20T10:00:00.000Z",
    "completedAt": null
  }
}
```

Tell it apart from a skin approval by the body: a cashout carries `withdrawal`, a skin trade carries `trade`.

| Your answer | Result |
| - | - |
| `2xx` with `{ "action": "approve" }` | Approved. The payout is sent |
| `2xx` with `{ "action": "reject", "reason": "..." }` | Refused, with your reason stored on the withdrawal |
| `2xx` with any other body, or none | Refused |
| `4xx` (except `408` and `429`) | Refused. The held amount returns to your balance |
| `408`, `429`, `5xx`, timeout | Retried. Three attempts in total, 10 second timeout each |
| No usable answer after three attempts | Refused |

Inside your handler: verify the signature, check that `steamId` has at least `amountCents` on your platform, debit them, then answer `200` with `{ "action": "approve" }`. If you approve and the payout later ends as `failed`, `rejected` or `cancelled`, give the user their balance back when that webhook arrives.

> [!WARNING]
>
> A bare `200` does **not** approve a cashout. This differs from the skin withdrawal approval on purpose: a handler that answers `200` to every event must never send money on chain by accident. Answer with `{ "action": "approve" }` explicitly.

> [!WARNING]
>
> You need an active callback URL and an API secret. Without them every cashout request is refused up front with `ONCHAIN_CLIENT_WITHDRAW_NO_CALLBACK` (409). A callback URL that was switched off after repeated delivery failures counts as missing: save it again in the dashboard.

### Fees and amounts

`amountCents` is what leaves your merchant balance. A flat network fee per chain, `feeCents`, comes **out of** that amount, and the user receives `receiveCents`:

```
receiveCents = amountCents - feeCents
```

The current fee per chain:

| Chain | Cashout fee |
| - | - |
| Ethereum | \$1.00 |
| BNB Smart Chain | none |
| Solana | none |
| Tron | \$2.50 (USDT only) |

The fee is the same for every amount. It can change, so read `feeCents` from the response rather than hard-coding it. The amount the user receives must be at least \$1.00, otherwise the request fails with `ONCHAIN_WITHDRAW_BELOW_MIN`.

### Safe retries

`requestId` is required. Sending the same `requestId` for the same user returns the first withdrawal and never creates a second one, so a timed out request can be repeated safely. Use a fresh `requestId` for every new cashout.

### Daily limits

Two rolling 24 hour limits apply: one per end user and one for all of your users together. When one is reached the request fails with `ONCHAIN_CLIENT_WITHDRAW_LIMIT` (403):

```json theme={null}
{
  "requestId": "...",
  "success": false,
  "error": {
    "code": 2525,
    "key": "ONCHAIN_CLIENT_WITHDRAW_LIMIT",
    "message": "This withdrawal is over the daily crypto withdrawal limit",
    "details": { "scope": "user", "remainingCents": 1200 }
  }
}
```

`scope` is `user` or `merchant`, and `remainingCents` is what can still be withdrawn now. Cashouts that were refused, failed or cancelled do not count.

### Cashout statuses

| Status | Meaning |
| - | - |
| `awaiting_approval` | Held from your balance, waiting for your server's answer |
| `in_review` | Large cashout held for a manual review by AssetPay |
| `approved` | Approved, waiting to be sent |
| `sent` | Broadcast to the network, `txHash` is set |
| `completed` | Confirmed on the network |
| `rejected` | Refused by your server or by the review. The full amount is back in your balance |
| `failed` | The payout could not be completed. The full amount is back in your balance |
| `cancelled` | Cancelled before it was sent. The full amount is back in your balance |

## Webhooks

Both flows report to your regular callback URL with the regular [signature](https://assetpay.gg/docs/guides/callbacks.md#signature-verification). The body has one top-level key that tells you what it is:

| Body | Sent on |
| - | - |
| `{ "deposit": { ... } }` | Every deposit status, including `below_minimum` |
| `{ "withdrawal": { ... } }` | `in_review`, `approved`, `sent`, `completed`, `failed`, `rejected`, `cancelled` |
| `{ "trade": { ... } }` | Skin trades, unchanged |

The objects are the same ones the list endpoints return, and the event is the object's `status`. `awaiting_approval` has no webhook of its own: the approval call is that announcement.

Webhooks can arrive more than once. Key your handling on `id` plus `status`.

## Errors

| Code | Key | HTTP | When |
| - | - | - | - |
| 2500 | `ONCHAIN_DISABLED` | 503 | Crypto is not available in this environment |
| 2501 | `ONCHAIN_CHAIN_PAUSED` | 503 | The chain is paused. Cashouts on it are refused until it resumes |
| 2503 | `ONCHAIN_UNSUPPORTED_TOKEN` | 400 | The token is not supported on this chain |
| 2508 | `ONCHAIN_WITHDRAW_BELOW_MIN` | 400 | The user would receive less than \$1.00 after the fee |
| 2509 | `ONCHAIN_WITHDRAW_INVALID_ADDRESS` | 400 | The address is not valid for this chain |
| 2512 | `ONCHAIN_INSUFFICIENT_HOT_BALANCE` | 503 | AssetPay cannot cover this payout right now. Retry later |
| 2514 | `ONCHAIN_DEPEG` | 503 | A stablecoin is trading off its peg and the rail is paused |
| 2522 | `ONCHAIN_MERCHANT_NOT_VERIFIED` | 403 | Your merchant account is not verified |
| 2523 | `ONCHAIN_ADDRESS_CAPACITY` | 503 | Too many open Solana accounts for your users |
| 2524 | `ONCHAIN_CLIENT_WITHDRAW_DISABLED` | 503 | Cashouts are switched off |
| 2525 | `ONCHAIN_CLIENT_WITHDRAW_LIMIT` | 403 | A daily limit is reached, see `details` |
| 2526 | `ONCHAIN_CLIENT_WITHDRAW_BUSY` | 409 | Another cashout of yours is being created. Retry in a moment |
| 2527 | `ONCHAIN_CLIENT_WITHDRAW_NO_CALLBACK` | 409 | You have no active callback URL or no API secret, so the cashout cannot be approved |
| 1600 | `INSUFFICIENT_BALANCE` | 400 | Your merchant balance does not cover the cashout |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.