# 2328.io API Documentation > Technical specification for cryptocurrency payment processing and withdrawal integration with 2328.io. --- # General Information > Technical specification for cryptocurrency payment processing and withdrawal integration with 2328.io. Welcome to the 2328.io API documentation. This reference describes how to integrate cryptocurrency payment processing and withdrawals into your application. ## Getting started To begin integrating: 1. Create a merchant account and project at [2328.io](https://2328.io) 2. Obtain your **project UUID** and **API key** from project settings 3. Generate a separate **Payout API key** if you plan to use withdrawals 4. Read the [Authentication](/docs/authentication) section to learn how to sign requests 5. Make your first [Create Payment](/docs/payments) call ## Base URL All production API requests use the following base URL: ``` https://api.2328.io/api ``` > **WARNING:** All requests must be made over **HTTPS**. Requests without HTTPS are blocked. ## What you can do With the 2328.io API you can: - **Accept crypto payments** — create payment sessions and redirect customers to a hosted checkout or Telegram MiniApp - **Withdraw funds** — programmatically send payouts from your merchant balance to any blockchain address - **Check balances** — see merchant account balances per currency, USD equivalents, and AML-locked amounts - **Use static wallets** — generate permanent deposit addresses tied to a user or order - **Fetch exchange rates** — get real-time rates for fiat and crypto pairs - **Receive webhooks** — get notified instantly when a payment status changes ## Rate limits The API allows up to **10 requests per second per project**. Requests above the limit get an HTTP `429 Too Many Requests` response — back off and retry. --- # Authentication & Request Signing > Sign API requests with HMAC-SHA256 using your project UUID and API key. Every API request (except incoming webhooks) must carry your project UUID and a request signature. The signature proves the request came from you and that nobody changed it on the way. ## API keys 2328.io uses **two keys** that share the same signing algorithm but cover different endpoints: | Key | Used for | |-----|----------| | **API key** | Payments, static wallets, balance, exchange rates, and verification of payment / static-wallet webhooks | | **Payout API key** | All `/v1/payout/*` endpoints and verification of payout webhooks | Both keys live in your project settings on [2328.io](https://2328.io). Examples below say "API key" generically — substitute the right one for the endpoint you're calling. > **INFO:** **Never** mix the two keys: signing a payout request with the regular API key (or a payment request with the payout key) returns a signature error. ## Required headers | Header | Type | Required | Description | |--------|------|----------|-------------| | `Content-Type` | string | yes | Always `application/json` | | `project` | string | yes | Your project UUID | | `sign` | string | yes | HMAC-SHA256 signature of the request, computed with your API key | | `User-Agent` | string | yes | Identifies your application (e.g. `MyShop/1.4 (+https://myshop.example)`). Requests without a `User-Agent` may be blocked. | ## How the signature works Think of the signature as a fingerprint of the request body. It is built by: 1. Serializing the body to JSON (compact — no extra whitespace). 2. Base64-encoding that JSON. This step normalises the input across languages — once it's plain ASCII, every language produces the same bytes for HMAC. 3. Computing **HMAC-SHA256** of the Base64 string using your API key, then converting the result to lowercase hex. For **GET** and other request types without a body, sign an empty string instead of the JSON. > **INFO:** The empty-string signature is constant for a given API key. You can cache it if you make many GET calls. ## Implementations ### Bodyless requests (GET) For empty-body requests (e.g. `GET /v1/payout/status/{uuid}`), sign an empty string. Since `base64_encode('')` is also empty, the HMAC input is just `""`: ## Full request example > **DANGER:** **Never expose your API key in client-side code.** Sign requests on your backend. A leaked API key gives anyone full access to your merchant account. ## Verifying webhook signatures When 2328.io sends you a webhook, the same algorithm runs in reverse: 1. Pull the `sign` field out of the payload. 2. JSON-encode the remaining fields (compact, no whitespace). 3. Base64-encode that string. 4. Compute `HMAC-SHA256` with the appropriate key. 5. Compare it with the received `sign` using a **constant-time** comparison (`hash_equals`, `crypto.timingSafeEqual`, `hmac.compare_digest`, `subtle.ConstantTimeCompare`, `OpenSSL.fixed_length_secure_compare`). The signing key depends on the webhook source: | Webhook | Key to verify with | |---------|---------------------| | Payment / static-wallet webhooks (`/v1/payment`, `/v1/static-wallet`) | **API key** | | Payout webhooks (`/v1/payout`) | **Payout API key** | > **WARNING:** **Common verification pitfalls.** Your JSON encoder must produce the **exact same bytes** the sender produced — otherwise the Base64 differs and the signature won't match. - **Go**: use `json.NewEncoder` with `SetEscapeHTML(false)`. The default `json.Marshal` escapes `<`, `>`, `&` to `<` and breaks the signature. - **Python**: pass `ensure_ascii=False` to `json.dumps`. Without it, non-ASCII (Cyrillic, Chinese, …) is escaped to `\uXXXX`. - **Compact JSON**: no whitespace between fields (`separators=(",", ":")` in Python). - **Field order** (Go): a plain `map[string]any` randomises keys on re-encode. Use `json.RawMessage`, an ordered struct, or strip `sign` from the raw bytes. If verification keeps failing, run `apiSign` on the payload yourself — it must produce the same hex string as the received `sign`. > **INFO:** **A valid signature does not prevent replays.** It only proves the webhook came from 2328.io — it doesn't stop an attacker from re-posting a *captured* webhook later. Always check idempotency by `uuid` (or `txid` for static wallets) before crediting funds. Reject with HTTP `401` if the signature is missing or wrong. Full code examples live on **[Webhook Notifications](/docs/webhooks#verifying-the-signature)**. Retry handling and idempotency rules are in [Best practices](/docs/webhooks#best-practices). --- # References > Network codes, currency-network mappings, and payment status values used across the 2328.io API. This page lists all the reference values used across API requests and responses. ## Network codes These codes are used wherever a `network` field is present: | Code | Network | |------|---------| | `TRX-TRC20` | Tron TRC-20 | | `BSC-BEP20` | BNB Smart Chain | | `ETH-ERC20` | Ethereum (ERC-20) | | `AVAX-C` | Avalanche C-Chain | | `POL-MATIC` | Polygon (Matic) | | `TON` | TON | | `BTC` | Bitcoin | | `LTC` | Litecoin | | `DASH` | Dash | | `SOL` | Solana | | `DOGE` | Dogecoin | ## Currency-network mapping Each currency is only available on a subset of networks. Use this table to pick a valid combination: | Currency | Allowed networks | |----------|-----------------| | `USDT` | TRX-TRC20, BSC-BEP20, ETH-ERC20, AVAX-C, POL-MATIC, TON, SOL | | `USDC` | BSC-BEP20, ETH-ERC20, AVAX-C, POL-MATIC, SOL | | `BTC` | BTC | | `ETH` | ETH-ERC20 | | `BNB` | BSC-BEP20 | | `TRX` | TRX-TRC20 | | `LTC` | LTC | | `DASH` | DASH | | `TON` | TON | | `AVAX` | AVAX-C | | `POL` | POL-MATIC | | `SOL` | SOL | | `DOGE` | DOGE | ## Payment statuses The `payment_status` field on payments and `/v1/payment/list` filter takes the following values: | Status | Description | |--------|-------------| | `pending` | Created, awaiting initialization | | `check` | Awaiting payment from customer | | `paid` | Paid successfully | | `underpaid_check` | Underpaid (can top up) | | `underpaid` | Underpaid | | `overpaid` | Overpaid (credited) | | `cancel` | Cancelled / expired | | `aml_lock` | Transaction blocked due to AML | > **INFO:** When listening for a successful payment, you should treat both `paid` and `overpaid` as successful states and credit the customer's order. ## Payout statuses The `status` field on `/v1/payout` and `/v1/payout/status/{uuid}` takes one of: | Status | Description | |--------|-------------| | `pending` | Created, awaiting processing | | `completed` | Completed successfully — `txid` is set | | `failed` | Sending error — see `error_type` | | `cancelled` | Cancelled | ## Payout error types When a payout has `status = failed`, the `error_type` field describes why: | Code | Description | |------|-------------| | `aml_risk` | Payout blocked by AML risk checks (recipient address flagged as high-risk) | --- # Webhook Notifications > Receive real-time payment and payout status updates via HMAC-signed webhooks. The 2328.io system sends a webhook to your `url_callback` whenever a payment status changes. This is the recommended way to get notified about successful payments. ## Request format - **Method:** `POST` - **Content-Type:** `application/json` - **Signature:** `sign` field in the request body ## Payload The webhook body is identical to the `/v1/payment/info` response, plus a `sign` field used for signature verification. ### Successful payment ```json { "uuid": "db17d490-15b6-47b9-9015-91d1d8b119f2", "order_id": "ORDER-12345", "amount": "180.00000000", "currency": "RUB", "url": "https://go.2328.io/db17d490-15b6-47b9-9015-91d1d8b119f2", "expires_at": "2026-05-09T16:56:58+03:00", "created_at": "2026-05-09T15:56:58+03:00", "payer_currency": "TON", "payer_amount": "0.95256917", "network": "TON", "address": "UQA0RevhkCQx-EltyNgPPeG8dqtnCz7ZslOzMdNQlLxVaNBb", "payment_status": "paid", "txid": "41c2a327323480af8e705d05deb09c238a41779928832abef4bb77c862357b11", "payment_amount": "0.95256917", "merchant_amount": "0.949711462490000000", "amount_usd": "2.41324380", "exchange_rate": "0.01340691", "sign": "6f8c15b6e53b506d5bfa38ed3fb3b50697af73434262153c02e412541372f04d" } ``` ### Cancelled / failed payment When the payment is not in a terminal `paid` state, `txid`, `payment_amount`, and `merchant_amount` are `null`: ```json { "uuid": "48edaf2d-2c49-4638-8f86-88636f661c1f", "order_id": "ORDER-12345", "amount": "2800.00000000", "currency": "RUB", "url": "https://go.2328.io/48edaf2d-2c49-4638-8f86-88636f661c1f", "expires_at": "2026-05-09T06:19:04+03:00", "created_at": "2026-05-09T05:19:04+03:00", "payer_currency": "ETH", "payer_amount": "0.01620968", "network": "ETH-ERC20", "address": "0x37c20d6d96d130Bc5B33D832e43b8e16aACe0c59", "payment_status": "cancel", "txid": null, "payment_amount": null, "merchant_amount": null, "amount_usd": "37.53934800", "exchange_rate": "0.01340691", "sign": "40ce68ad9691ad54e684329d75ab5adaf5b01409a2d18d3e0110b8c1be605342" } ``` ### Field reference | Field | Type | Description | |-------|------|-------------| | `uuid` | string | Payment UUID | | `order_id` | string | Your order ID | | `amount` | decimal (8 dp) | Fiat amount in `currency` | | `currency` | string | Fiat currency the merchant requested | | `url` | string | Hosted checkout URL | | `expires_at` | string (ISO 8601) | When the payment session expires | | `created_at` | string (ISO 8601) | When the payment session was created | | `payer_currency` | string | Crypto the payer is paying in | | `payer_amount` | decimal (8 dp) | Amount of crypto expected | | `network` | string | Blockchain network | | `address` | string | Deposit address | | `payment_status` | string | One of: `pending`, `check`, `paid`, `underpaid_check`, `underpaid`, `overpaid`, `cancel`, `aml_lock` (see [References](/docs/references)) | | `txid` | string \| null | Blockchain tx hash, present only after a confirmed payment | | `payment_amount` | decimal \| null | Actual paid amount, present only after payment | | `merchant_amount` | decimal (18 dp) \| null | Amount credited to merchant after fees | | `amount_usd` | decimal (8 dp) | Amount in USD at the time of creation | | `exchange_rate` | decimal | Crypto / fiat exchange rate used | | `sign` | string (hex) | HMAC-SHA256 signature of the payload | ## Verifying the signature To verify a webhook signature: 1. Extract the `sign` field from the payload 2. Remove the `sign` field from the object 3. Encode the remaining fields as JSON 4. Encode the JSON in Base64 5. Compute HMAC-SHA256 from the Base64 string using your API_KEY 6. Compare the computed signature with the `sign` value using a constant-time comparison > **DANGER:** **Always verify the signature** before crediting any funds to a user. An unsigned or incorrectly-signed webhook could be a spoofed request. ## Payout webhooks When a payout's `status` changes, the system sends a `POST` webhook to the `url_callback` URL passed when the payout was created. If `url_callback` was not provided, no webhooks are sent for that payout. > **WARNING:** Payout webhooks must be verified with your **Payout API key** — not the regular API key. The signing algorithm is identical to payment webhooks (strip `sign`, JSON-encode, base64, HMAC-SHA256), only the key differs. ### Payload ```json { "uuid": "019dff1f-0dbd-7277-8d45-271e7775388f", "order_id": "4dfdcc84402b1185b71cbe399321533e", "status": "completed", "currency": "TRX", "network": "TRX-TRC20", "amount": "3.00", "merchant_amount": "3.00", "network_amount": "3.00", "amount_usd": "1.04", "to_address": "THauRv5tcucQRohXg8NiyGTk16DX1XQG5x", "memo": null, "txid": "9242e533703704ef3eaba840f70b4a26333e72c943377ee375fea17badb53def", "block_number": null, "error_type": null, "created_at": "2026-05-07T00:08:38+03:00", "updated_at": "2026-05-07T00:08:54+03:00", "from_currency": "USDT", "debited_amount": "1.050735", "debited_currency": "USDT", "sign": "925ad7bf3d6841864101f7cc2c7e30652e70a06cdb04dbe07a0129480000ce4a" } ``` ### Field reference | Field | Type | Description | |-------|------|-------------| | `uuid` | string | Payout UUID | | `order_id` | string | Your idempotency / reference ID, if you provided one | | `status` | string | `pending`, `completed`, `failed`, `cancelled` (see [References](/docs/references)) | | `currency` | string | Withdrawal currency | | `network` | string | Blockchain network | | `amount` | decimal | Withdrawal amount (in `currency`) | | `merchant_amount` | decimal | Amount charged from the merchant balance | | `network_amount` | decimal | Amount actually sent on-chain | | `amount_usd` | decimal | USD value at the time of the payout | | `to_address` | string | Recipient blockchain address | | `memo` | string \| null | Memo / destination tag, if used | | `txid` | string \| null | Blockchain transaction hash, set on `completed` | | `block_number` | integer \| null | Block height of the on-chain transaction | | `error_type` | string \| null | Reason when `status = failed` (e.g. `aml_risk`, see [References](/docs/references)) | | `created_at` | string (ISO 8601) | When the payout was created | | `updated_at` | string (ISO 8601) | When the status last changed | | `from_currency` | string | Source balance the payout was debited from when auto-conversion was used (e.g. `USDT` for a `BTC` payout) | | `debited_amount` | decimal | Amount debited from `from_currency` balance | | `debited_currency` | string | Currency of the debit | | `sign` | string (hex) | HMAC-SHA256 signature of the payload, signed with the **Payout API key** | ## Best practices - **Idempotency** — Always check if the payment has already been processed (by `order_id` or `uuid`). Webhooks may arrive multiple times. - **Fast response** — Return HTTP 200 as quickly as possible. Offload heavy work to a background queue. - **Retries** — If the system doesn't receive an HTTP 200, the webhook is resent after 2 minutes. Maximum 5 retry attempts. - **Async processing** — Handle webhook events asynchronously to avoid blocking the response. - **Security** — ALWAYS verify the `sign` signature before trusting the payload. > **WARNING:** Webhooks can arrive out of order. Don't assume the first webhook you receive is the final state — always re-fetch via `/v1/payment/info` (or `/v1/payout/status/{uuid}`) if you need certainty. --- # AI Integration > Integrate 2328.io into your application in minutes using AI assistants like Claude, ChatGPT, Cursor, and GitHub Copilot. The 2328.io documentation is built to be **LLM-friendly**. You can hand the entire API reference to any modern AI assistant and have it generate a working integration in the language of your choice — PHP, Node.js, Python, Go, Rust — in minutes instead of hours. This page explains how to do it efficiently. ## Why use AI to integrate - **Faster onboarding** — skip boilerplate, jump straight to business logic - **Correct signing** — AI reliably reproduces HMAC-SHA256 signing in any language - **Webhook handlers** — generate signature verification and idempotent handlers out of the box - **Up-to-date** — our `llms-full.txt` is regenerated on every docs update, so you always get current schemas ## Machine-readable docs We publish three endpoints following the [llmstxt.org](https://llmstxt.org) standard: | Endpoint | Purpose | |----------|---------| | [`/llms.txt`](https://doc.2328.io/llms.txt) | Short index of all docs with links | | [`/llms-full.txt`](https://doc.2328.io/llms-full.txt) | Full documentation as a single file — paste this into your AI chat | | [`/md/{locale}/{slug}`](https://doc.2328.io/md/en/payments) | Any page as raw Markdown | Every HTML page also exposes `` pointing to its Markdown version, so AI crawlers discover it automatically. ## Quick start with Claude or ChatGPT ### Step 1 — Provide the docs Open a fresh chat and paste the contents of [`llms-full.txt`](https://doc.2328.io/llms-full.txt) as your first message, or just share the link if the model can fetch it. ### Step 2 — Describe your stack Tell the assistant what you are building: ``` I'm building a Laravel 11 application. I need to: 1. Create a payment for an order (amount in USD, user pays in USDT TRC20) 2. Handle the webhook and credit the user's balance 3. Store payment records in a `payments` table Use the 2328.io API above. Include HMAC signing, webhook signature verification, and idempotency. ``` ### Step 3 — Review and test The assistant will produce a controller, a service class, and a webhook handler. Before shipping: - Verify that `apiSign()` encodes the body as Base64 **before** HMAC-SHA256 - Check that webhook handlers call `hash_equals()` (not `===`) to compare signatures - Make sure the handler is idempotent — check `order_id` / `txid` before crediting - Test with a small real payment on a dev environment first > **WARNING:** Never ship AI-generated payment code without reviewing the signing and webhook verification logic. These are the critical security boundaries. ## IDE integrations ### Cursor Add the docs as a custom docs source in Cursor settings: ``` Settings → Features → Docs → Add new doc URL: https://doc.2328.io ``` Then in chat, prefix your question with `@2328.io`: ``` @2328.io generate a webhook handler in Next.js App Router with signature verification and idempotent credit logic ``` ### GitHub Copilot Copilot Chat can read `llms-full.txt` directly: ``` #fetch https://doc.2328.io/llms-full.txt Using the 2328.io API docs above, implement a payout endpoint in Express that withdraws USDT BEP20 to a user-supplied address. ``` ### Windsurf / Continue / other assistants Any assistant that supports a URL context or file attachment works the same way — attach `llms-full.txt` and describe your goal. ## Claude API (Agent SDK) If you're building your own agent or chatbot that needs to interact with 2328.io, inject the docs once into the system prompt: ```python from anthropic import Anthropic import urllib.request docs = urllib.request.urlopen( "https://doc.2328.io/llms-full.txt" ).read().decode() client = Anthropic() response = client.messages.create( model="claude-opus-4-7", max_tokens=4096, system=f"""You are an integration assistant for 2328.io. Use the API reference below to answer questions and generate code. {docs} """, messages=[ {"role": "user", "content": "Write a Python function that creates a USDT payment"} ], ) print(response.content[0].text) ``` > **INFO:** The full docs file is ~15 KB — well under any modern model's context limit. You can cache it on your side and refresh it once a day. ## Example prompts that work well Copy these into Claude, ChatGPT, or your AI IDE after sharing `llms-full.txt`: **Full backend integration:** ``` Build a Node.js + Express service that exposes two routes: - POST /checkout → creates a 2328.io payment and returns the payment URL - POST /webhook/2328 → verifies the signature and marks the order as paid Use TypeScript, Zod for validation, and a simple in-memory store. ``` **Payout tool:** ``` Write a CLI in Go that takes a currency, network, amount, and address and creates a payout via the 2328.io Payout API. Use a separate payout API key from env. Poll the status endpoint until the payout is completed. ``` **Static wallet for user deposits:** ``` I have a Django app where users deposit USDT TRC20 to top up their balance. Each user should have a permanent deposit address. Implement this using 2328.io static wallets, including the webhook handler that credits their balance when a deposit arrives. ``` ## Best practices for AI-assisted integration - **Start from `llms-full.txt`** — it's designed for LLM context, no boilerplate - **Be specific about your stack** — framework, language version, ORM - **Ask for tests** — AI is good at generating unit tests for signing logic - **Double-check error handling** — AI sometimes skips failure paths - **Review signature code manually** — this is the only part that *must* be exactly right - **Refresh periodically** — if our API changes, refetch `llms-full.txt` and re-prompt --- # Payment API > Create and manage cryptocurrency payment sessions with the 2328.io Payment API. The Payment API lets you create payment sessions, redirect customers to a hosted checkout, and track payment status. ## Create payment Creates a payment session and returns a URL for the customer to pay. ### Request parameters | Field | Type | Required | Description | Values | |-------|------|----------|-------------|--------| | `amount` | decimal | yes | Payment amount in the currency, e.g. `100.00` | | | `currency` | string | yes | Fiat currency (USD, EUR, RUB, …) or cryptocurrency (USDT, TRX, BTC, …) | | | `order_id` | string | yes | Your order ID, e.g. `ORDER-12345` (up to 128 chars) | | | `to_currency` | string | no | Preselected cryptocurrency | | | `network` | string | no\* | Network code (required if `to_currency` is set or `currency` is a cryptocurrency) | | | `url_return` | string | no | Redirect URL after payment, e.g. `https://your-site.com/return` | | | `url_success` | string | no | Alternative to `url_return` | | | `url_callback` | string | no | URL for webhook notifications, e.g. `https://your-site.com/webhook` | | | `invite_code` | string | no | Referrer code | | | `fee_split` | decimal | no | Share of the merchant fee passed to the payer, 0–100 (%). 0 = merchant pays fully, 100 = payer pays fully. Overrides the project-level setting. **Example: `30`** (payer covers 30% of the fee). | | | `price_markup` | decimal | no | Markup or discount on the invoice amount, −99 to 100 (%). Overrides the project-level setting. **Example: `5`** (+5%) or `-10` (10% discount). | | | `description` | string | no | Optional invoice description (max 200 chars). Shown to the payer on the payment page. **Example: `Premium plan — Order #12345`**. | | | `ttl_seconds` | int | no | Invoice lifetime in seconds, from `300` (5 minutes) to `86400` (24 hours). After this period the invoice expires and can no longer be paid. Default: `3600` (1 hour). **Example: `3600`**. | | ### Response ```json { "state": 0, "result": { "uuid": "abc123-def456-...", "order_id": "ORDER-12345", "amount": "100.00", "currency": "USD", "amount_usd": "100.00", "exchange_rate": null, "url": "https://2328.io/pay/abc123-def456-...", "tg_deeplink": "https://t.me/my2328bot?start=pay_abc123-def456-...", "expires_at": "2026-01-11T21:00:00Z", "created_at": "2026-01-11T20:00:00Z", "payer_currency": "USDT", "payer_amount": "100.50", "network": "TRX-TRC20", "address": "TXYZabc123...", "payment_status": "check", "txid": null, "payment_amount": null, "qr": "data:image/png;base64,iVBORw0..." } } ``` - Redirect the customer to `result.url` to complete payment. - `tg_deeplink` — Telegram bot deeplink for payment via Telegram MiniApp. - `qr` — Base64-encoded QR code (data URI) of the deposit address. Present when an address is already assigned (when `network` is set together with `to_currency`, or when `currency` is a cryptocurrency); otherwise `null`. - `txid`, `payment_amount` — `null` until the customer pays. Filled in once the transaction is detected on-chain. Listen for the `payment_status: paid` webhook to know when. - `exchange_rate` — `null` if conversion isn't applicable yet (e.g. fiat → crypto rate hasn't been locked). Filled in once a payer currency is chosen. ## Payment info Get the current payment status by `uuid` or `order_id`. ### Request parameters | Field | Type | Required | Description | Values | |-------|------|----------|-------------|--------| | `uuid` | string | yes\* | Payment UUID (from `result.uuid` on creation) | | | `order_id` | string | yes\* | Your order ID | | > **INFO:** At least one of `uuid` or `order_id` is required. ## Payment list Get a list of all payments with filtering and pagination. ### Request parameters | Field | Type | Required | Description | Values | |-------|------|----------|-------------|--------| | `status` | string | no | Filter by payment status (see [References](/docs/references)) | | | `date_from` | date | no | Start date (YYYY-MM-DD), e.g. `2026-01-01` | | | `date_to` | date | no | End date (YYYY-MM-DD), e.g. `2026-01-31` | | | `page` | int | no | Page number, default `1` | | | `per_page` | int | no | Items per page, default `15`, max `5000` | | --- # Payout API > Send withdrawals from your merchant balance to any blockchain address. The Payout API lets you programmatically withdraw funds from your merchant balance to any blockchain address. > **WARNING:** For all Payout endpoints, you must use a separate **Payout API key** to generate the `sign` signature. This key is different from your regular API key and must be generated in your project settings. ## Create payout Creates a withdrawal request from your merchant balance. `POST /v1/payout` ### Request parameters | Field | Type | Required | Description | |-------|------|----------|-------------| | `currency` | string | yes | Withdrawal currency (see [References](/docs/references)) | | `network` | string | yes | Network code (see [References](/docs/references)) | | `amount` | string | yes | Withdrawal amount | | `to_address` | string | yes | Recipient blockchain address | | `order_id` | string | no | **Idempotency key** — unique within a project. A repeated `POST` with the same `order_id` does not create a new payout — the existing one is returned instead | | `url_callback` | string | no | URL for payout webhooks. Omit to disable webhooks for this payout | | `memo` | string \| null | no | Destination tag / memo. Currently used only by **TON** and **SOL** networks; max 255 chars | | `from_currency` | string | no | Source balance to debit and auto-convert into `currency` at the moment of payout. Lets you pay out in volatile assets (`BTC`, `ETH`, …) while keeping your balance in a stable coin like `USDT` — you don't have to hold the volatile crypto yourself. Pass `"USDT"` to debit the USDT balance | | `fee_option` | string | no | How fees are charged. `deduct` (default) — network + platform fees subtracted from `amount`, the recipient gets `amount - fees`. `add` — fees added on top, the merchant is debited `amount + fees`, the recipient receives exactly `amount` | > **INFO:** **Idempotency.** Within a project, a payout is unique by `order_id`. Re-sending the same `POST` with the same `order_id` is **safe** — the API returns the existing payout instead of creating a duplicate. Always pass an `order_id` for production payouts. ### Request examples ```bash curl -X POST https://api.2328.io/api/v1/payout \ -H "Content-Type: application/json" \ -H "User-Agent: MyShop/1.0 (+https://myshop.example)" \ -H "project: YOUR_PROJECT_UUID" \ -H "sign: YOUR_HMAC_SIGNATURE" \ -d '{"currency":"TRX","network":"TRX-TRC20","amount":"1.00","to_address":"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t","order_id":"9ed25264-8be4-439f-acf5-2a8732538d27","url_callback":"https://your-site.com/webhook/payout","memo":null,"fee_option":"deduct"}' ``` ```php 'TRX', 'network' => 'TRX-TRC20', 'amount' => '1.00', 'to_address' => 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', 'order_id' => '9ed25264-8be4-439f-acf5-2a8732538d27', 'url_callback' => 'https://your-site.com/webhook/payout', 'memo' => null, 'fee_option' => 'deduct', ]; $body = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $sign = apiSign($body, $apiKey); $ch = curl_init('https://api.2328.io/api/v1/payout'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'User-Agent: MyShop/1.0 (+https://myshop.example)', "project: $project", "sign: $sign", ], ]); $response = json_decode(curl_exec($ch), true); ``` ```javascript import { createHmac } from "crypto"; function apiSign(body, apiKey) { const base64 = Buffer.from(body, "utf8").toString("base64"); return createHmac("sha256", apiKey).update(base64).digest("hex"); } const PROJECT_UUID = "YOUR_PROJECT_UUID"; const PAYOUT_API_KEY = process.env.PAYOUT_API_KEY; const data = { currency: "TRX", network: "TRX-TRC20", amount: "1.00", to_address: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", order_id: "9ed25264-8be4-439f-acf5-2a8732538d27", url_callback: "https://your-site.com/webhook/payout", memo: null, fee_option: "deduct", }; const body = JSON.stringify(data); const sign = apiSign(body, PAYOUT_API_KEY); const res = await fetch("https://api.2328.io/api/v1/payout", { method: "POST", headers: { "Content-Type": "application/json", "User-Agent": "MyShop/1.0 (+https://myshop.example)", project: PROJECT_UUID, sign, }, body, }); const json = await res.json(); ``` ```python import json import hmac import hashlib import base64 import httpx def api_sign(body: str, api_key: str) -> str: b64 = base64.b64encode(body.encode("utf-8")).decode() return hmac.new(api_key.encode(), b64.encode(), hashlib.sha256).hexdigest() PROJECT_UUID = "YOUR_PROJECT_UUID" PAYOUT_API_KEY = "YOUR_PAYOUT_API_KEY" data = { "currency": "TRX", "network": "TRX-TRC20", "amount": "1.00", "to_address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", "order_id": "9ed25264-8be4-439f-acf5-2a8732538d27", "url_callback": "https://your-site.com/webhook/payout", "memo": None, "fee_option": "deduct", } body = json.dumps(data, separators=(",", ":"), ensure_ascii=False) sign = api_sign(body, PAYOUT_API_KEY) r = httpx.post( "https://api.2328.io/api/v1/payout", headers={ "Content-Type": "application/json", "User-Agent": "MyShop/1.0 (+https://myshop.example)", "project": PROJECT_UUID, "sign": sign, }, content=body.encode("utf-8"), ) response = r.json() ``` ```go package main import ( "bytes" "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "net/http" ) func apiSign(body []byte, apiKey string) string { b64 := base64.StdEncoding.EncodeToString(body) h := hmac.New(sha256.New, []byte(apiKey)) h.Write([]byte(b64)) return hex.EncodeToString(h.Sum(nil)) } func marshalCanonical(v any) ([]byte, error) { var buf bytes.Buffer enc := json.NewEncoder(&buf) enc.SetEscapeHTML(false) if err := enc.Encode(v); err != nil { return nil, err } return bytes.TrimRight(buf.Bytes(), "\n"), nil } type CreatePayout struct { Currency string `json:"currency"` Network string `json:"network"` Amount string `json:"amount"` ToAddress string `json:"to_address"` OrderID string `json:"order_id"` URLCallback string `json:"url_callback"` Memo *string `json:"memo"` FeeOption string `json:"fee_option"` } func main() { const projectUUID = "YOUR_PROJECT_UUID" const payoutAPIKey = "YOUR_PAYOUT_API_KEY" data := CreatePayout{ Currency: "TRX", Network: "TRX-TRC20", Amount: "1.00", ToAddress: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", OrderID: "9ed25264-8be4-439f-acf5-2a8732538d27", URLCallback: "https://your-site.com/webhook/payout", Memo: nil, FeeOption: "deduct", } body, err := marshalCanonical(data) if err != nil { panic(err) } sign := apiSign(body, payoutAPIKey) req, _ := http.NewRequest("POST", "https://api.2328.io/api/v1/payout", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "MyShop/1.0 (+https://myshop.example)") req.Header.Set("project", projectUUID) req.Header.Set("sign", sign) resp, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer resp.Body.Close() } ``` ### Response example ```json { "state": 0, "result": { "uuid": "019dea62-1727-72aa-ac2c-eaf2ade193ef", "order_id": "9ed25264-8be4-439f-acf5-2a8732538d27", "status": "pending", "currency": "TRX", "network": "TRX-TRC20", "amount": "1.00", "merchant_amount": "1", "network_amount": "0.89", "amount_usd": "0.33", "to_address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", "memo": null, "txid": null, "block_number": null, "error_type": null, "created_at": "2026-05-02T23:29:50+03:00", "updated_at": "2026-05-02T23:29:50+03:00" } } ``` > **INFO:** **Fees.** Default `fee_option: deduct` — network + platform fees are subtracted from `amount` (recipient gets `amount - fees`). Pass `fee_option: add` to charge fees on top — the recipient gets exactly `amount` and the merchant is debited `amount + fees`. ## Calculate payout Estimates withdrawal amounts and fees **without creating a payout** or debiting your balance. Use it to show users the exact amount they will receive (or pay) before they confirm. `POST /v1/payout/calc` ### Request parameters Identical to [Create payout](#create-payout) — same fields, same signing. `order_id`, `url_callback`, `to_address` and `memo` are accepted but ignored: no payout is persisted and no callbacks are sent. ### Request example ```bash curl -X POST https://api.2328.io/api/v1/payout/calc \ -H "Content-Type: application/json" \ -H "User-Agent: MyShop/1.0 (+https://myshop.example)" \ -H "project: YOUR_PROJECT_UUID" \ -H "sign: YOUR_HMAC_SIGNATURE" \ -d '{"currency":"USDT","network":"TRX-TRC20","amount":"100","fee_option":"add"}' ``` ### Response example ```json { "state": 0, "result": { "currency": "USDT", "network": "TRX-TRC20", "amount": "100", "fee_option": "add", "merchant_amount": "103.00000000", "network_amount": "100", "total_fee": "3.00000000", "total_fee_usd": "3.00000000" } } ``` > **INFO:** **Preview only.** This endpoint is read-only — no balance is debited and no payout record is created. Call it as often as you need to render fee breakdowns in your UI. ## Payout status Get the status of a payout request. `GET /v1/payout/status/{uuid}` ### Path parameters | Field | Type | Required | Description | |-------|------|----------|-------------| | `uuid` | string | yes | Payout UUID (from `result.uuid` on creation) | ### Response example ```json { "state": 0, "result": { "uuid": "019dff1f-0dbd-7277-8d45-271e7775388f", "order_id": "4dfdcc84402b1185b71cbe399321533e", "status": "completed", "currency": "TRX", "network": "TRX-TRC20", "amount": "3.00", "merchant_amount": "3.00", "network_amount": "3.00", "amount_usd": "1.04", "to_address": "THauRv5tcucQRohXg8NiyGTk16DX1XQG5x", "memo": null, "txid": "9242e533703704ef3eaba840f70b4a26333e72c943377ee375fea17badb53def", "block_number": null, "error_type": null, "created_at": "2026-05-07T00:08:38+03:00", "updated_at": "2026-05-07T00:08:54+03:00", "from_currency": "USDT", "debited_amount": "1.050735", "debited_currency": "USDT" } } ``` > **INFO:** For this GET request the signature is computed from an empty body: `hash_hmac('sha256', base64_encode(''), $apiKey)` ## Response fields Fields returned in `result` from `POST /v1/payout` and `GET /v1/payout/status/{uuid}`: | Field | Type | Description | |-------|------|-------------| | `uuid` | string | Payout UUID assigned by the system | | `order_id` | string | Your internal payout identifier (unique within the project) | | `status` | string | Current payout status (see below) | | `currency` | string | Withdrawal currency | | `network` | string | Network code | | `amount` | string | Withdrawal amount as requested | | `merchant_amount` | string | Amount debited from the merchant balance | | `network_amount` | string | Amount actually sent on-chain (after network + platform fees) | | `amount_usd` | string | USD equivalent of the withdrawal amount | | `to_address` | string | Recipient blockchain address | | `memo` | string \| null | Destination tag / memo (TON, SOL). `null` otherwise | | `txid` | string \| null | Blockchain transaction hash. `null` until the transaction is sent | | `block_number` | int \| null | Block number where the transaction was included. `null` until included | | `error_type` | string \| null | Reason for failure when `status = failed` (see Error types below). `null` otherwise | | `created_at` | string (ISO 8601) | Payout creation time | | `updated_at` | string (ISO 8601) | Last status change time | | `from_currency` | string \| null | Source balance the payout was debited from when auto-conversion was used (e.g. `USDT` for a `BTC` payout). `null` if no conversion happened | | `debited_amount` | string \| null | Amount actually debited from the source balance after conversion. Present only when auto-conversion is used | | `debited_currency` | string \| null | Currency of `debited_amount` — the balance from which funds were debited | ## Payout statuses The `status` field can take the following values: | Status | Description | |--------|-------------| | `pending` | Created, awaiting processing | | `completed` | Completed successfully — `txid` is set | | `failed` | Sending error — see `error_type` | | `cancelled` | Cancelled | ## Error types When `status = failed`, the `error_type` field describes why: | Code | Description | |------|-------------| | `aml_risk` | Payout blocked by AML risk checks (recipient address flagged as high-risk) | ## Webhook notifications When a payout's status changes, the system sends a `POST` webhook to the `url_callback` URL passed when the payout was created. If `url_callback` was not provided, no webhooks are sent for that payout. - **Method:** `POST` - **Content-Type:** `application/json` - **Signature:** `sign` field in the request body, computed with the **Payout API key** (the same key used to sign payout requests). The payload mirrors the `result` object from `GET /v1/payout/status/{uuid}` plus a `sign` field for verification. ### Payload ```json { "uuid": "019dff1f-0dbd-7277-8d45-271e7775388f", "order_id": "4dfdcc84402b1185b71cbe399321533e", "status": "completed", "currency": "TRX", "network": "TRX-TRC20", "amount": "3.00", "merchant_amount": "3.00", "network_amount": "3.00", "amount_usd": "1.04", "to_address": "THauRv5tcucQRohXg8NiyGTk16DX1XQG5x", "memo": null, "txid": "9242e533703704ef3eaba840f70b4a26333e72c943377ee375fea17badb53def", "block_number": null, "error_type": null, "created_at": "2026-05-07T00:08:38+03:00", "updated_at": "2026-05-07T00:08:54+03:00", "from_currency": "USDT", "debited_amount": "1.050735", "debited_currency": "USDT", "sign": "925ad7bf3d6841864101f7cc2c7e30652e70a06cdb04dbe07a0129480000ce4a" } ``` > **WARNING:** **Verifying the signature.** Use the same algorithm as for [payment webhooks](/docs/webhooks), but sign with your **Payout API key** instead of the regular API key. Strip the `sign` field, JSON-encode the remaining payload, Base64-encode it, then compute `hash_hmac('sha256', $base64, $payoutApiKey)` and compare with the received `sign`. --- # Static Wallets > Permanent deposit addresses tied to a specific order or user, perfect for recurring and long-term payments. Static wallets are permanent addresses for receiving cryptocurrency payments. They are linked to a specific `order_id` and are unique by the combination of `project_id + order_id + currency + network`. Use static wallets for: - Recurring deposits from the same user - Long-term payment addresses displayed on a user profile - High-volume deposit flows where you want a stable address per user ## Create static wallet `POST /v1/static-wallet` ### Request parameters | Field | Type | Required | Description | |-------|------|----------|-------------| | `currency` | string | yes | Cryptocurrency (USDT, BTC, ETH, etc.) | | `network` | string | yes | Network code | | `order_id` | string | yes | Your order/user ID (up to 255 chars) | | `label` | string | no | Wallet label (up to 255 chars) | | `url_callback` | string | yes | URL for webhook notifications | | `invite_code` | string | no | Referrer code | ### Request example ```json { "currency": "USDT", "network": "TRX-TRC20", "order_id": "USER-123", "label": "User deposit #123", "url_callback": "https://your-site.com/webhook/static" } ``` ### Response example ```json { "state": 0, "result": { "uuid": "019b2265-34d8-7001-a230-8f97de90d481", "address": "TXYZabc123...", "currency": "USDT", "network": "TRX-TRC20", "label": "User deposit #123", "order_id": "USER-123", "status": "active", "url": "https://go.2328.io/static/019b2265-34d8-7001-a230-8f97de90d481", "created_at": "2026-01-20T12:00:00Z", "qr": "data:image/png;base64,iVBORw0..." } } ``` ## Wallet info Get static wallet information by `uuid` or `address`. `POST /v1/static-wallet/info` ### Request parameters | Field | Type | Required | Description | |-------|------|----------|-------------| | `uuid` | string | yes* | Static wallet UUID | | `address` | string | yes* | Blockchain wallet address | > **INFO:** At least one of `uuid` or `address` is required. ### Response example ```json { "state": 0, "result": { "uuid": "019b2265-34d8-7001-a230-8f97de90d481", "address": "TXYZabc123...", "currency": "USDT", "network": "TRX-TRC20", "status": "active", "total_received": "1250.50", "transactions_count": 3, "created_at": "2026-01-20T12:00:00Z", "qr": "data:image/png;base64,iVBORw0..." } } ``` - `total_received` — sum of all deposits received by this wallet, in `currency`. - `transactions_count` — number of deposits received so far. - `qr` — Base64-encoded QR data URI of the deposit address (always present for static wallets, the address is assigned at creation). ## Wallet list `POST /v1/static-wallet/list` ### Request parameters | Field | Type | Required | Description | |-------|------|----------|-------------| | `status` | string | no | Filter by status (`active`, `inactive`) | | `currency` | string | no | Filter by currency | | `network` | string | no | Filter by network | | `order_id` | string | no | Filter by order_id | | `page` | int | no | Page number (default: 1) | | `per_page` | int | no | Items per page (default: 20, max: 100) | ### Response example ```json { "state": 0, "result": { "items": [ { "uuid": "019b2265-...", "address": "TXYZabc123...", "currency": "USDT", "network": "TRX-TRC20", "status": "active", "total_received": "1250.50", "transactions_count": 3 } ], "paginate": { "count": 1, "current_page": 1, "per_page": 20, "total": 1, "total_pages": 1, "has_more": false } } } ``` ## Enable / disable wallet Toggle whether a static wallet accepts new payments. `POST /v1/static-wallet/disable` `POST /v1/static-wallet/enable` ### Request Both endpoints take a single parameter: ```json { "uuid": "019b2265-34d8-7001-a230-8f97de90d481" } ``` ### Response example ```json { "state": 0, "result": { "uuid": "019b2265-34d8-7001-a230-8f97de90d481", "status": "inactive", "message": "Static wallet disabled successfully" } } ``` For `enable`, `status` is `"active"` and `message` reads `"Static wallet enabled successfully"`. ## Wallet transactions Get a list of all deposits received by a static wallet. `POST /v1/static-wallet/transactions` ### Request parameters | Field | Type | Required | Description | |-------|------|----------|-------------| | `uuid` | string | yes | Static wallet UUID | | `date_from` | date | no | Start date (YYYY-MM-DD) | | `date_to` | date | no | End date (YYYY-MM-DD) | | `page` | int | no | Page number (default: 1) | | `per_page` | int | no | Items per page (default: 15, max: 5000) | ### Response example ```json { "state": 0, "result": { "items": [ { "uuid": "abc123-def456-...", "order_id": "USER-123", "amount": "100.00", "currency": "USDT", "payment_status": "paid", "txid": "0xabc123def456...", "fee_amount": "3.00", "net_amount": "97.00", "created_at": "2026-01-20T15:30:00Z" } ], "paginate": { "count": 1, "hasPages": true, "perPage": 15, "page": 1 } } } ``` - `fee_amount` — platform fee deducted from this deposit, in `currency`. - `net_amount` — amount credited to the merchant balance after the fee. ## Static wallet webhooks When a payment is received on a static wallet, the system sends a webhook to `url_callback`. > **WARNING:** The webhook format for static wallets differs from regular payment webhooks. Notably, static wallet webhooks include a `merchant_amount` field which you should use for crediting. ### Webhook payload ```json { "uuid": "a28b293f-5c76-4053-8062-ae9ca4ab784b", "order_id": "USER-7666308594", "amount": "10.00000000", "currency": "USDT", "amount_usd": "10.00000000", "exchange_rate": "1.00000000", "payer_currency": "USDT", "payer_amount": "10.00000000", "network": "TRX-TRC20", "address": "TMU9Tgpchvgbywkbj5SdC8KJS73t5m3M7G", "payment_status": "paid", "txid": "8369ede26a0da05b1bae154b4bb4072eb2453db30ba86b21831902670929454f", "payment_amount": "10.00000000", "merchant_amount": "9.920000000000000000", "created_at": "2026-05-09T16:13:04+03:00", "sign": "dd958d1405febce670a9a196e9141784b9f2a5f39cd6d1832d6f3f68d0de1e10" } ``` > **INFO:** Static wallet webhooks **do not** include `url` or `expires_at` (since the address is permanent, not a session). They **do** include `exchange_rate` and `created_at`. ### Field reference | Field | Type | Description | |-------|------|-------------| | `uuid` | string | Transaction (invoice) UUID for this deposit | | `order_id` | string | Your static wallet `order_id` | | `amount` | decimal (8 dp) | Crypto amount received | | `currency` | string | Crypto received (matches the wallet's `currency`) | | `amount_usd` | decimal (8 dp) | USD value at the time of receipt | | `exchange_rate` | decimal | Crypto / USD rate used | | `payer_currency` | string | Same as `currency` for static wallets | | `payer_amount` | decimal (8 dp) | Same as `amount` for static wallets | | `network` | string | Blockchain network | | `address` | string | Static wallet address | | `payment_status` | string | Always `paid` for statics | | `txid` | string | Blockchain transaction hash | | `payment_amount` | decimal (8 dp) | Same as `amount` | | `merchant_amount` | decimal (18 dp) | **Amount after fee deduction** — use this for crediting | | `created_at` | string (ISO 8601) | When the deposit was received | | `sign` | string (hex) | HMAC-SHA256 signature of the payload | ## Best practices - **Unique `order_id`** — Use a unique `order_id` for each user or order - **Idempotency** — Check `txid` before processing to avoid duplicate credits - **Verify signatures** — ALWAYS verify the `sign` signature before crediting funds - **Use `merchant_amount`** — Credit users based on `merchant_amount`, not `payment_amount` --- # Balance > Get the list of merchant accounts with their balances per currency. The Balance endpoint returns all merchant accounts in the project with their available balances, USD equivalents, and any locked amounts. ## Get balance Returns the list of merchant accounts. The request takes no body — the signature is computed over an empty string. ### Response fields Each account in `result` includes: | Field | Type | Description | |-------|------|-------------| | `uuid` | string | Account UUID | | `status` | string | Account status (`active`, `disabled`) | | `currency_code` | string | Account currency | | `balance` | decimal | Available balance | | `balance_usd` | decimal | Available balance in USD | | `locked_balance` | decimal | Amount locked due to AML | ### Response example ```json { "state": 0, "result": [ { "uuid": "abc123-def456-...", "status": "active", "currency_code": "USDT", "balance": "1250.50", "balance_usd": "1250.50", "locked_balance": "0.00" }, { "uuid": "def456-abc789-...", "status": "active", "currency_code": "BTC", "balance": "0.01320000", "balance_usd": "1251.20", "locked_balance": "0.00000000" } ] } ``` --- # Exchange Rates > Public endpoint for fetching current exchange rates between fiat and cryptocurrencies. The exchange rates endpoint returns a matrix of current exchange rates between all supported currencies — both fiat (USD, EUR, RUB, etc.) and crypto (BTC, ETH, USDT, etc.). > **INFO:** This is a **public endpoint**. It does not require authentication and does not require `project` or `sign` headers. ## Get exchange rates `GET /v1/exchange-rates` ### Response example ```json { "state": 0, "result": { "USD": { "USD": "1.00000000", "EUR": "0.86090000", "RUB": "78.32190000", "BTC": "0.00001055", "USDT": "1.00000000" }, "BTC": { "USD": "94786.69000000", "EUR": "81589.12000000", "ETH": "29.02345678", "USDT": "94786.69000000" } } } ``` ### How to read the result The response is a nested object: `result[FROM][TO]` is the exchange rate for 1 unit of `FROM` to `TO`. - `result["USD"]["RUB"] = 78.32` means 1 USD = 78.32 RUB - `result["BTC"]["USD"] = 94786.69` means 1 BTC = $94,786.69 ## PHP example ```php true ]); $response = json_decode(curl_exec($ch), true); if ($response['state'] === 0) { $rates = $response['result']; $rubAmount = bcmul(100, $rates['USD']['RUB'], 2); echo "100 USD = {$rubAmount} RUB\n"; } ``` ## cURL example ```bash curl https://api.2328.io/api/v1/exchange-rates ``` > **WARNING:** Rates are updated frequently but are not guaranteed for trading. Always re-fetch rates just before creating a payment or payout to minimize slippage. ---