# 2328.io API Documentation
> Integration reference for payments, direct-address H2H flows, static wallets, payouts, conversions, webhooks, and operational edge cases.
# 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.
## Choose the right integration pattern
| Requirement | Recommended pattern | Why |
|-------------|---------------------|-----|
| Let the customer choose how to pay | Hosted checkout | Create a payment and redirect to `result.url`; 2328.io presents currently available directions. |
| Keep the customer inside your own checkout | Direct-address **H2H** invoice | Send `to_currency` and `network` when creating the payment; render the returned `address`, `payer_amount`, and `qr`. |
| Charge exactly `25 USDT` or `0.001 BTC` | Crypto-denominated invoice | Put the cryptocurrency in `currency` and the exact decimal amount in `amount`. |
| Give each user a reusable deposit address | Static wallet | The address is permanent and can receive many independent deposits. |
| Normalize incoming assets into one balance currency | Auto-convert | Configure project rules in the dashboard and consume the `convert` result when conversion completes. |
| Exchange an existing merchant balance | Manual convert | Preview with `/v1/convert/price`, then execute with `/v1/convert`. |
| Send funds to a blockchain address | Payout | Use the separate Payout API key, calculate first, and reconcile the payout status. |
> **INFO:** Hosted checkout and H2H are two presentations of the same Payment API. H2H does not create a weaker or unsigned payment: the backend still creates the invoice, 2328.io still owns the address and status, and signed webhooks remain authoritative for settlement.
## Integration invariants
These rules apply to every production integration:
- **Backend only** — keep API keys out of browsers, mobile applications, logs, analytics, and support screenshots.
- **Decimal strings** — send and store money as strings. Never round cryptocurrency or exchange rates with binary floating-point arithmetic.
- **Immutable idempotency keys** — generate `order_id` before the first request and persist the complete request with it. A retry with the same `order_id` can return the original object rather than applying changed fields.
- **Webhook-first settlement** — redirects, client polling, transaction hashes supplied by users, and HTTP timeouts are not proof of payment.
- **Verify, deduplicate, then mutate** — verify the HMAC, claim an idempotency record atomically, update the order/balance once, and return HTTP 200 quickly.
- **Reconciliation** — periodically query payment, static-wallet, and payout status so a lost webhook cannot leave permanent disagreement.
- **Dynamic availability** — validate currency/network pairs with `/v1/directions`; a supported asset can still have one deposit or withdrawal direction temporarily disabled.
- **Explicit status policy** — decide how your product handles partial payment, overpayment, expiry, AML lock, conversion fallback, and ambiguous upstream timeouts before going live.
## Recommended data to persist
For payments, store at minimum `uuid`, `order_id`, the original request body, `amount`, `currency`, `payer_currency`, `payer_amount`, `network`, `address`, `expires_at`, latest `payment_status`, `txid`, `payment_amount`, `merchant_amount`, the optional `convert` block, and the raw verified webhook payload.
For static wallets, keep the wallet `uuid`, address, currency, network, customer/account reference, status, and callback URL separately from deposit records. Each deposit needs its own transaction `uuid`, `txid`, status, received amount, merchant amount, and conversion result.
---
# 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
#### php
```php
str:
# ensure_ascii=False keeps non-ASCII characters (Cyrillic, Chinese, …)
# as-is. Without it, Python escapes them to \uXXXX and the signature
# diverges from PHP / Node / Go.
body = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
b64 = base64.b64encode(body.encode("utf-8")).decode()
return hmac.new(api_key.encode(), b64.encode(), hashlib.sha256).hexdigest()
```
#### go
```go
package sign
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
)
func ApiSign(data any, apiKey string) (string, error) {
// json.Encoder with SetEscapeHTML(false) — without it, Go escapes <, >, &
// to \u003c etc., which breaks compatibility with PHP / Node / Python.
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(data); err != nil {
return "", err
}
// Encoder appends a trailing newline — drop it.
body := bytes.TrimRight(buf.Bytes(), "\n")
b64 := base64.StdEncoding.EncodeToString(body)
h := hmac.New(sha256.New, []byte(apiKey))
h.Write([]byte(b64))
return hex.EncodeToString(h.Sum(nil)), nil
}
```
### 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 `""`:
#### curl
```curl
SIGN=$(printf '' | openssl dgst -sha256 -hmac "$API_KEY" -hex | awk '{print $NF}')
```
#### php
```php
$sign = hash_hmac('sha256', base64_encode(''), $apiKey);
```
#### js
```js
import { createHmac } from "crypto";
const sign = createHmac("sha256", apiKey)
.update(Buffer.from("").toString("base64"))
.digest("hex");
```
#### ts
```ts
import { createHmac } from "crypto";
const sign: string = createHmac("sha256", apiKey)
.update(Buffer.from("").toString("base64"))
.digest("hex");
```
#### python
```python
import hmac
import hashlib
import base64
sign = hmac.new(
api_key.encode(),
base64.b64encode(b"").decode().encode(),
hashlib.sha256,
).hexdigest()
```
#### go
```go
package sign
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
)
func EmptyBodySign(apiKey string) string {
b64 := base64.StdEncoding.EncodeToString([]byte(""))
h := hmac.New(sha256.New, []byte(apiKey))
h.Write([]byte(b64))
return hex.EncodeToString(h.Sum(nil))
}
```
## Full request example
#### curl
```curl
curl -X POST https://api.2328.io/api/v1/payment \
-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 '{"amount":"100.00","currency":"USD","order_id":"ORDER-123"}'
```
#### php
```php
'100.00',
'currency' => 'USD',
'order_id' => 'ORDER-123',
];
$body = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$sign = apiSign($body, $apiKey);
$ch = curl_init('https://api.2328.io/api/v1/payment');
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);
```
#### js
```js
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 data = {
amount: "100.00",
currency: "USD",
order_id: "ORDER-123",
};
const body = JSON.stringify(data);
const sign = apiSign(body, process.env.API_KEY);
const res = await fetch("https://api.2328.io/api/v1/payment", {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": "MyShop/1.0 (+https://myshop.example)",
project: process.env.PROJECT_UUID,
sign,
},
body,
});
const json = await res.json();
```
#### ts
```ts
import { createHmac } from "crypto";
function apiSign(body: string, apiKey: string): string {
const base64 = Buffer.from(body, "utf8").toString("base64");
return createHmac("sha256", apiKey).update(base64).digest("hex");
}
type CreatePaymentBody = {
amount: string;
currency: string;
order_id: string;
};
type CreatePaymentResponse = { state: number; result: unknown };
const data: CreatePaymentBody = {
amount: "100.00",
currency: "USD",
order_id: "ORDER-123",
};
const body = JSON.stringify(data);
const sign = apiSign(body, process.env.API_KEY!);
const res = await fetch("https://api.2328.io/api/v1/payment", {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": "MyShop/1.0 (+https://myshop.example)",
project: process.env.PROJECT_UUID!,
sign,
},
body,
});
const json = (await res.json()) as CreatePaymentResponse;
```
#### python
```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()
data = {
"amount": "100.00",
"currency": "USD",
"order_id": "ORDER-123",
}
body = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
sign = api_sign(body, API_KEY)
r = httpx.post(
"https://api.2328.io/api/v1/payment",
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
```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
}
func main() {
data := struct {
Amount string `json:"amount"`
Currency string `json:"currency"`
OrderID string `json:"order_id"`
}{
Amount: "100.00",
Currency: "USD",
OrderID: "ORDER-123",
}
body, err := marshalCanonical(data)
if err != nil {
panic(err)
}
sign := ApiSign(body, apiKey)
req, _ := http.NewRequest("POST",
"https://api.2328.io/api/v1/payment",
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()
}
```
> **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 |
| `ZEC` | Zcash |
| `XRP` | XRP Ledger |
| `XMR` | Monero |
## 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 |
| `GRAM` | TON |
| `AVAX` | AVAX-C |
| `POL` | POL-MATIC |
| `SOL` | SOL |
| `DOGE` | DOGE |
| `ZEC` | ZEC |
| `XRP` | XRP |
| `XMR` | XMR |
`GRAM` is the canonical asset code for the TON native currency. The payment, static-wallet, and payout creation APIs currently accept legacy `TON` input and normalize it to `GRAM`; integrations should store and handle the canonical value returned by the API. The Polygon native asset is `POL`, while its network code is `POL-MATIC`. Never send `MATIC` as a network code.
Enabled directions are operational configuration and can change independently of this catalog. Query `/v1/directions` before presenting choices; treat this table as the valid code map, not a guarantee that every pair is currently enabled.
## Payment statuses
The `payment_status` field on payment info and webhook payloads can take 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.
### Status handling policy
| Status | Fulfill order? | Continue waiting? | Operational action |
|--------|----------------|-------------------|--------------------|
| `pending` / `check` | No | Yes, until expiry | Display pending state and reconcile normally. |
| `underpaid_check` | No by default | Yes, top-up can arrive | Store each txid idempotently and show the remaining-payment workflow. |
| `paid` | Yes, once | No | Fulfill atomically from the verified event. |
| `overpaid` | Yes, once | No | Fulfill and retain excess/actual amounts for merchant policy. |
| `underpaid` | Product-specific | No | Apply explicit partial-payment/manual-review policy. |
| `cancel` | No | No | Mark expired/cancelled, but escalate any later on-chain evidence. |
| `aml_lock` | No | No automatic fulfillment | Compliance/support review; do not release value automatically. |
Statuses describe the platform's view of the payment. They do not replace your local fulfillment state. Store both so a refunded, manually reviewed, or already fulfilled order cannot be corrupted by an older webhook.
The `/v1/payment/list` request filter currently accepts `pending`, `check`, `paid`, `underpaid_check`, `underpaid`, `overpaid`, and `cancel`. It does not accept `aml_lock` as a filter even though an AML-locked payment can be returned by other payment endpoints.
## 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
#### php
```php
{
if (!verifyWebhookSign(req.body, process.env.API_KEY)) {
return res.sendStatus(401);
}
const { order_id, payment_status, txid } = req.body;
if (payment_status === "paid" || payment_status === "overpaid") {
// Credit the order — check idempotency by order_id first
}
res.sendStatus(200);
});
```
#### python
```python
import json
import hmac
import hashlib
import base64
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
API_KEY = "YOUR_API_KEY"
def verify_webhook_sign(payload: dict, api_key: str) -> bool:
received = payload.pop("sign", "")
body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
b64 = base64.b64encode(body.encode("utf-8")).decode()
calculated = hmac.new(api_key.encode(), b64.encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(calculated, received)
@app.post("/webhook")
async def webhook(request: Request):
payload = await request.json()
if not verify_webhook_sign(payload, API_KEY):
raise HTTPException(401)
if payload["payment_status"] in ("paid", "overpaid"):
# Credit the order — check idempotency by order_id first
pass
return {"ok": True}
```
#### go
```go
package main
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"io"
"net/http"
)
func verifyWebhookSign(body []byte, apiKey string) (map[string]any, bool) {
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
return nil, false
}
received, _ := payload["sign"].(string)
delete(payload, "sign")
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.Encode(payload)
reencoded := bytes.TrimRight(buf.Bytes(), "\n")
b64 := base64.StdEncoding.EncodeToString(reencoded)
h := hmac.New(sha256.New, []byte(apiKey))
h.Write([]byte(b64))
calculated := hex.EncodeToString(h.Sum(nil))
return payload, hmac.Equal([]byte(calculated), []byte(received))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
payload, ok := verifyWebhookSign(body, apiKey)
if !ok {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
status, _ := payload["payment_status"].(string)
if status == "paid" || status == "overpaid" {
// Credit the order — check idempotency first
}
w.WriteHeader(http.StatusOK)
}
```
#### ruby
```ruby
require "json"
require "openssl"
require "base64"
require "sinatra"
API_KEY = "YOUR_API_KEY"
def verify_webhook_sign(payload, api_key)
received = payload.delete("sign") || ""
body = payload.to_json
b64 = Base64.strict_encode64(body)
calculated = OpenSSL::HMAC.hexdigest("SHA256", api_key, b64)
OpenSSL.fixed_length_secure_compare(calculated, received)
end
post "/webhook" do
payload = JSON.parse(request.body.read)
halt 401 unless verify_webhook_sign(payload, API_KEY)
if %w[paid overpaid].include?(payload["payment_status"])
# Credit the order — check idempotency by order_id first
end
status 200
end
```
> **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.
## Delivery and processing contract
Use the following order inside your webhook endpoint:
1. Read the request body without logging secrets or the full signature.
2. Identify whether it is a payment/static-wallet event or a payout event so you select the correct API key.
3. Remove `sign`, reproduce the documented JSON bytes, calculate HMAC-SHA256, and compare in constant time.
4. Validate required identifiers, decimal strings, and status values.
5. Atomically insert an inbox/idempotency record. If it already exists, return HTTP 200 without repeating side effects.
6. Commit the order/ledger mutation and enqueue non-critical email, analytics, or notifications.
7. Return HTTP 200 quickly.
Do not call slow third-party services while holding the idempotency transaction. A timeout after you commit but before returning can cause a retry; the duplicate must observe the committed inbox key and become a no-op.
### Recommended idempotency keys
| Event | Primary identity | Notes |
|-------|------------------|-------|
| Payment session | `uuid` + status/version evidence | The same invoice can emit multiple legitimate status changes. |
| Partial-payment top-up | invoice `uuid` + `txid` | More than one transfer can belong to the same underpaid invoice. |
| Static-wallet deposit | network + `txid` | `order_id` is reused by every deposit to that wallet. |
| Payout | payout `uuid` + status | Never create a second payout from webhook retry logic. |
If your schema has no event id, store the verified payload hash as additional audit evidence, but do not replace the business identities above with a timestamp.
## Ordering and reconciliation
Delivery is at-least-once and status messages can race. Implement monotonic business rules rather than “last request wins”:
- never move a fulfilled order back to `check` because an older event arrived late;
- allow `underpaid_check` to receive additional txids without repeating earlier credits;
- treat `paid` and `overpaid` as successful settlement states, while preserving their different amounts;
- keep `underpaid` as a final partial-payment outcome unless the authoritative API later reports another state;
- route `aml_lock` to review and do not let a generic retry worker fulfill it;
- query payment/payout info whenever the transition is impossible, missing context, or financially ambiguous.
Run scheduled reconciliation even when webhook delivery appears healthy. Compare your local terminal state and credited amount with `/v1/payment/info`, `/v1/static-wallet/transactions`, or `/v1/payout/status/{uuid}` and alert on differences instead of silently overwriting ledger history.
## Webhook endpoint security
- Require HTTPS and keep the callback publicly reachable; private/loopback callback targets are rejected during payment creation.
- Enforce a small request-body limit and JSON content type.
- Rate-limit before expensive work, but leave enough headroom for legitimate bursts and retries.
- Never authorize a webhook by source IP alone. Network allowlists are defense in depth; HMAC verification is mandatory.
- Redact `sign`, API keys, addresses when required by policy, and personal metadata from application logs.
- Keep both current and explicitly scheduled replacement keys available during a controlled rotation window; never guess which key signed an event.
- Return a generic error body on invalid signatures so the endpoint does not become a key or account oracle.
---
# 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.
## Documentation MCP server
AI agents that support the [Model Context Protocol](https://modelcontextprotocol.io) can connect directly to:
```
https://doc.2328.io/mcp
```
The endpoint is public, read-only, and requires no merchant API key. It serves documentation knowledge only; it cannot inspect balances, create payments, or move funds.
Generic remote-MCP configuration:
```json
{
"mcpServers": {
"2328-docs": {
"url": "https://doc.2328.io/mcp"
}
}
}
```
Exact configuration syntax varies by MCP client. Use Streamable HTTP and point the client at the URL above.
### MCP tools
| Tool | When an agent should use it |
|------|------------------------------|
| `list_docs` | Discover all documentation topics and resource URIs. |
| `search_docs` | Find section-level answers with ranked excerpts and direct resource links. |
| `get_doc` | Read the complete portable-Markdown version of one page. |
| `get_integration_guide` | Build a guided plan for hosted checkout, H2H, exact crypto invoices, static wallets, auto-convert, manual convert, or payouts. |
All tool results include structured JSON as well as human-readable text. Search results return `resource_link` items so agents can fetch only the pages they need instead of loading the whole corpus.
### MCP resources
| Resource | Purpose |
|----------|---------|
| `docs://2328/index` | Machine-readable service and document index. |
| `docs://2328/en/full` | Complete English integration reference with shared code snippets expanded. |
| `docs://2328/{locale}/{slug}` | One localized page; missing translations fall back to English. |
The resource template supports locale and slug completion, which lets compatible clients discover valid URIs without guessing.
### MCP prompts
- `integrate-2328` tells an agent to read the correct guide and produce implementation code, tests, configuration, and a go-live checklist.
- `review-2328-integration` asks an agent to review existing code for signing, tenant/project scoping, decimal handling, idempotency, webhook ordering, ambiguous timeouts, and reconciliation gaps.
### Recommended agent workflow
1. Use `get_integration_guide` for the selected integration pattern.
2. Read every linked page with MCP resources or `get_doc`.
3. Use `search_docs` for framework-specific or edge-case questions.
4. Implement signing and webhook tests before API calls.
5. Review the result with `review-2328-integration`.
6. Test small real transactions in a controlled environment; MCP documentation access does not prove production API connectivity.
> **WARNING:** The documentation MCP is an integration knowledge source, not a wallet MCP. Never paste API keys, payout keys, webhook secrets, customer personal data, or production payloads into tool arguments or prompts.
## 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 an enabled cryptocurrency. Use `/v1/directions` as the live source of truth. `TON` is accepted as a legacy input alias for canonical `GRAM`. | `USD`, `EUR`, `RUB`, `KZT`, `UAH`, `UZS`, `USDT`, `USDC`, `BTC`, `ETH`, `GRAM`, `SOL`, `TRX`, `BNB`, `POL`, `LTC`, `DASH`, `DOGE`, `ZEC`, `XRP`, `XMR` |
| `order_id` | string | yes | Your order ID, e.g. `ORDER-12345` (up to 128 chars) | |
| `to_currency` | string | no | Preselected enabled cryptocurrency; `TON` is normalized to `GRAM` | `USDT`, `USDC`, `BTC`, `ETH`, `GRAM`, `SOL`, `TRX`, `BNB`, `POL`, `LTC`, `DASH`, `DOGE`, `ZEC`, `XRP`, `XMR` |
| `network` | string | no\* | Canonical network code (required if `to_currency` is set or `currency` is a cryptocurrency) | `TRX-TRC20`, `ETH-ERC20`, `BSC-BEP20`, `AVAX-C`, `POL-MATIC`, `TON`, `SOL`, `BTC`, `LTC`, `DASH`, `DOGE`, `ZEC`, `XRP`, `XMR` |
| `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 | yes | Public HTTP(S) URL for webhook notifications, e.g. `https://your-site.com/webhook`. Private, loopback, and otherwise unsafe targets are rejected. | |
| `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.
> Use your project UUID and the endpoint-appropriate API key from the merchant dashboard.
#### Interactive request: `POST /v1/payment`
- `amount` (decimal, required)
- `currency` (enum, required): USD,EUR,RUB,KZT,UAH,UZS,USDT,USDC,BTC,ETH,GRAM,SOL,TRX,BNB,POL,LTC,DASH,DOGE,ZEC,XRP,XMR
- `order_id` (string, required)
- `to_currency` (enum): USDT,USDC,BTC,ETH,GRAM,SOL,TRX,BNB,POL,LTC,DASH,DOGE,ZEC,XRP,XMR
- `network` (enum): TRX-TRC20,ETH-ERC20,BSC-BEP20,AVAX-C,POL-MATIC,TON,SOL,BTC,LTC,DASH,DOGE,ZEC,XRP,XMR
- `url_return` (string)
- `url_success` (string)
- `url_callback` (string)
- `invite_code` (string)
- `fee_split` (decimal)
- `price_markup` (decimal)
- `description` (string)
- `ttl_seconds` (integer)
## Hosted checkout, H2H, and exact crypto amounts
The same endpoint supports three distinct invoice shapes. Pick one deliberately; do not mix their amount semantics.
### Hosted checkout with payer choice
Send `amount`, `currency`, `order_id`, and `url_callback`, but omit `to_currency` and `network`. The response contains `result.url`; `address`, `qr`, and sometimes payer fields remain `null` until the payer selects a direction on the hosted page.
```json
{
"amount": "125.00",
"currency": "EUR",
"order_id": "ORDER-2026-1042",
"url_callback": "https://merchant.example/webhooks/2328",
"url_return": "https://merchant.example/orders/ORDER-2026-1042"
}
```
### Direct-address H2H invoice
Send both `to_currency` and `network`. 2328.io creates the blockchain invoice during the API call, so a successful response can be rendered inside your checkout without redirecting the customer.
```json
{
"amount": "100.00",
"currency": "USD",
"to_currency": "USDT",
"network": "TRX-TRC20",
"order_id": "ORDER-2026-1043",
"url_callback": "https://merchant.example/webhooks/2328"
}
```
Render these values exactly as returned:
- `payer_amount` and `payer_currency` — the payment instruction;
- `network` and `address` — the only destination for this invoice;
- `qr` — a data URI for the same address;
- `expires_at` — the invoice deadline;
- `url` — a useful hosted fallback when the custom checkout cannot complete.
> **DANGER:** Never generate or substitute an address, reuse an address from another invoice, or calculate `payer_amount` from a public spot price. The API response is authoritative.
### Invoice for an exact crypto amount
Put the cryptocurrency in `currency` when the invoice itself is denominated in crypto:
```json
{
"amount": "25.000000",
"currency": "USDT",
"network": "TRX-TRC20",
"order_id": "ORDER-2026-1044",
"url_callback": "https://merchant.example/webhooks/2328"
}
```
The requested crypto value is preserved in `payer_currency` / `payer_amount`. The service can also maintain a USD valuation internally for accounting and rate fields; do not replace the exact crypto instruction with that valuation. Preserve returned decimal strings, including trailing precision.
For a cryptocurrency with only one supported network, the network may be selected automatically. Supplying `network` explicitly is still recommended for a deterministic integration. For multi-network assets such as stablecoins, always send it.
## Idempotency and retries
`order_id` is scoped to the authenticated merchant project and acts as the creation idempotency key. If a payment already exists, the API returns that session with `state: 0`.
> **WARNING:** A retry with the same `order_id` does **not** mean “update this invoice.” Changed amount, currency, callback, markup, TTL, or direction fields may be ignored because the existing session is returned. Persist the first request and reject conflicting retries in your own application.
Recommended creation algorithm:
1. Insert your local payment attempt and unique `order_id` in one database transaction.
2. Send the signed API request.
3. Persist the returned `uuid` and full response.
4. If the HTTP result is lost, retry the identical request or query `/v1/payment/info` by `order_id`.
5. Never create a second local order merely because the upstream request timed out.
## Payment edge cases
| Situation | Correct handling |
|-----------|------------------|
| `address` / `qr` is `null` | The payer direction has not been initialized. Redirect to `url`, or create a new correctly specified H2H invoice with a new `order_id`. |
| HTTP `400` validation error | Read the field-level `errors`; do not retry unchanged input. |
| HTTP `429` | Retry with jittered exponential backoff and keep the same `order_id`. |
| HTTP `503` / `direction_disabled` | Refresh `/v1/directions`; hide the direction temporarily or retry later. |
| Client request timeout | Treat the result as unknown. Query by `order_id` before creating anything else. |
| `underpaid_check` | Store the partial event and await a top-up or later status. Do not credit twice when more txids arrive. |
| `underpaid` | Final underpayment state. Apply your configured fulfillment/manual-review policy to the actual credited amount. |
| `overpaid` | Successful payment with excess funds. Fulfill idempotently and retain the actual amounts for reconciliation/refund policy. |
| `aml_lock` | Do not fulfill or release funds automatically; route to compliance/support workflow. |
| `cancel` | Invoice expired or was cancelled. Do not infer that a late on-chain transfer is impossible; reconcile any later event with support. |
The browser return URL is navigation only. A customer can open it without paying, close it after paying, or replay it later. Only a verified API/webhook state may settle the merchant order.
## 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.
#### Interactive request: `POST /v1/payment/info`
- `uuid` (string)
- `order_id` (string)
## Payment list
Get a list of all payments with filtering and pagination.
### Request parameters
| Field | Type | Required | Description | Values |
|-------|------|----------|-------------|--------|
| `status` | string | no | Filter by list-supported payment status (see [References](/docs/references)). `aml_lock` can appear in payment info/webhooks but is not accepted by this list filter. | `pending`, `check`, `paid`, `underpaid_check`, `underpaid`, `overpaid`, `cancel` |
| `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` | |
#### Interactive request: `POST /v1/payment/list`
- `status` (enum): pending,check,paid,underpaid_check,underpaid,overpaid,cancel
- `date_from` (string)
- `date_to` (string)
- `page` (integer)
- `per_page` (integer)
---
# 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 | Current deposit status. Normally `paid`; AML processing can produce `aml_lock`, which must not be credited automatically. |
| `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`
## Lifecycle and idempotency
A static wallet is a reusable deposit identity, not an invoice. It has no expected amount and no expiry. One address can produce any number of deposit transactions over its lifetime.
Creation is idempotent for the same merchant project, `order_id`, `currency`, and `network`: the existing wallet is returned. Keep that tuple stable and persist the returned wallet `uuid`; do not use a new `order_id` every time the same customer opens the deposit screen.
Deposit idempotency is different from wallet idempotency:
- `order_id` identifies the reusable wallet/customer mapping;
- wallet `uuid` identifies the permanent wallet record;
- webhook `uuid` identifies one detected deposit transaction;
- `txid` identifies the on-chain transfer and is the primary deduplication key for crediting.
Use a database uniqueness constraint for the processed chain/network/txid identity and claim it in the same transaction that credits the customer's internal balance.
## Enable and disable semantics
Disabling a wallet prevents the application from processing it as an active deposit target; it does not erase the address or its history and cannot stop a blockchain transfer already sent by a user.
> **DANGER:** Never tell users that funds sent to an inactive address are automatically returned. Blockchain transfers are irreversible. Disable only after removing the address from your UI, and keep an operational recovery procedure for late deposits.
Re-enabling preserves the same wallet identity and address. Do not create a replacement merely to change the label; labels are not settlement identifiers.
## Static wallet edge cases
| Situation | Correct handling |
|-----------|------------------|
| Duplicate creation request | Accept the returned existing wallet and verify its persisted tuple instead of expecting a new address. |
| Multiple deposits to one address | Create a separate local deposit row for every transaction `uuid`/`txid`; never mark the wallet itself as “paid.” |
| Duplicate webhook | Return HTTP 200 after finding the already committed txid; never credit again. |
| Confirmation delay or chain re-observation | Keep processing idempotent and reconcile from `/v1/static-wallet/transactions`. |
| Deposit below an auto-convert minimum | Expect source-currency credit with no completed `convert` block. |
| Auto-convert succeeds | Store source payment values and the target `convert` result separately. |
| Wrong token or wrong network | Do not fabricate a credit. Record evidence and escalate to support/recovery because recoverability is chain-specific. |
| Memo/tag-based chain | Display and validate every destination field returned by the platform; an address alone may be insufficient when a memo is required. |
| AML lock | Do not credit the end user until the authoritative status is released through the compliance process. |
| Wallet disabled after address display | Remove it from the UI immediately, but continue monitoring operational alerts for late transfers. |
## Reconciliation model
Run a periodic job that pages through `/v1/static-wallet/transactions`, upserts deposits by txid, and compares their `merchant_amount`, status, and optional conversion result with your internal ledger. Webhook delivery should make reconciliation fast, but reconciliation must make it complete.
---
# Convert API
> Convert between cryptocurrencies directly from your merchant balance — get a live quote and execute at market price.
The Convert API lets you swap between the currencies held in your merchant balance at the current market price — the same engine that powers the **Swap** tab in the merchant dashboard, now callable from your backend.
> **WARNING:** Convert endpoints are signed with your **regular API key** — the same one used for [Payment API](/docs/payments) requests, **not** the Payout API key. Executing a convert immediately debits and credits your merchant balance, so treat this key with the same care as any money-moving credential.
## Get conversion price
Returns an indicative quote for a conversion at the current market price — the effective rate and the resulting amounts. Nothing is debited or reserved; call it as often as you need before executing.
`POST /v1/convert/price`
> Use your project UUID and the endpoint-appropriate API key from the merchant dashboard.
#### Interactive request: `POST /v1/convert/price`
- `from_currency` (enum, required): BTC,ETH,USDT,USDC,TRX,BNB,GRAM,SOL,POL,LTC,DASH,DOGE,ZEC,XRP,XMR
- `to_currency` (enum, required): USDT,USDC,BTC,ETH,TRX,BNB,GRAM,SOL,POL,LTC,DASH,DOGE,ZEC,XRP,XMR
- `amount` (decimal, required)
- `amount_type` (enum, required): from,to
### Request parameters
| Field | Type | Required | Description | Values |
|-------|------|----------|-------------|--------|
| `from_currency` | string | yes | Uppercase source asset code. A usable route and balance must exist; the displayed catalog is not a guarantee of an active market. | `BTC`, `ETH`, `USDT`, `USDC`, `TRX`, `BNB`, `GRAM`, `SOL`, `POL`, `LTC`, `DASH`, `DOGE`, `ZEC`, `XRP`, `XMR` |
| `to_currency` | string | yes | Uppercase target asset code; must differ from `from_currency` and have a tradable direct or bridge route | `USDT`, `USDC`, `BTC`, `ETH`, `TRX`, `BNB`, `GRAM`, `SOL`, `POL`, `LTC`, `DASH`, `DOGE`, `ZEC`, `XRP`, `XMR` |
| `amount` | decimal | yes | Amount to convert, greater than `0` | |
| `amount_type` | string | yes | Which side `amount` refers to | `from`, `to` |
> **INFO:** `amount_type=from` spends exactly `amount` of `from_currency`. `amount_type=to` receives exactly `amount` of `to_currency`.
**🟢 200 OK** · `application/json`
```json
{
"state": 0,
"result": {
"success": true,
"from_currency": "BTC",
"to_currency": "USDT",
"amount_type": "from",
"from_amount": "0.01000000",
"to_amount": "947.86690000",
"effective_rate": "94786.69000000",
"from_amount_usd": "947.87",
"to_amount_usd": "947.87"
}
}
```
#### Response fields
| Field | Type | Description |
|-------|------|-------------|
| `success` | boolean | Whether the quote was computed successfully |
| `from_currency` | string | Source currency |
| `to_currency` | string | Target currency |
| `amount_type` | string | Echoes the request's `amount_type` |
| `from_amount` | string | Amount that would be debited in `from_currency` |
| `to_amount` | string | Amount that would be credited in `to_currency` |
| `effective_rate` | string | Rate applied to this quote — 1 unit of `from_currency` in `to_currency` (already includes the platform's pricing) |
| `from_amount_usd` | string \| null | USD equivalent of `from_amount` |
| `to_amount_usd` | string \| null | USD equivalent of `to_amount` |
- The quote is **indicative only** — the market price can move between the quote and the execute call.
- No balance is debited or reserved by this call.
## Execute convert
Executes a conversion at the current market price and updates your merchant balance. There is no separate "commit a quote" step — call this directly with the amount you want to convert.
`POST /v1/convert`
#### Interactive request: `POST /v1/convert`
- `from_currency` (enum, required): BTC,ETH,USDT,USDC,TRX,BNB,GRAM,SOL,POL,LTC,DASH,DOGE,ZEC,XRP,XMR
- `to_currency` (enum, required): USDT,USDC,BTC,ETH,TRX,BNB,GRAM,SOL,POL,LTC,DASH,DOGE,ZEC,XRP,XMR
- `amount` (decimal, required)
- `amount_type` (enum, required): from,to
> **INFO:** **Idempotency.** Repeating the exact same request (same `from_currency`, `to_currency`, `amount`, `amount_type`) within about a minute of the first call returns the existing conversion instead of creating a second one. Once that window passes, an identical request is treated as a new conversion — don't blindly retry on a timeout without checking the previous result first.
> **WARNING:** This endpoint is limited to **10 requests per minute** per caller — tighter than the general API rate limit — because every call moves real balance.
### Request parameters
| Field | Type | Required | Description | Values |
|-------|------|----------|-------------|--------|
| `from_currency` | string | yes | Uppercase source asset code. A usable route and balance must exist. | `BTC`, `ETH`, `USDT`, `USDC`, `TRX`, `BNB`, `GRAM`, `SOL`, `POL`, `LTC`, `DASH`, `DOGE`, `ZEC`, `XRP`, `XMR` |
| `to_currency` | string | yes | Uppercase target asset code; must differ from `from_currency` and have a tradable direct or bridge route | `USDT`, `USDC`, `BTC`, `ETH`, `TRX`, `BNB`, `GRAM`, `SOL`, `POL`, `LTC`, `DASH`, `DOGE`, `ZEC`, `XRP`, `XMR` |
| `amount` | decimal | yes | Amount to convert, greater than `0` | |
| `amount_type` | string | yes | Which side `amount` refers to | `from`, `to` |
**🟢 200 OK** · `application/json`
```json
{
"state": 0,
"result": {
"id": 12345,
"type": "manual",
"status": "completed",
"from_currency": "BTC",
"to_currency": "USDT",
"from_amount": "0.01000000",
"requested_from_amount": "0.01000000",
"refund_amount": null,
"to_amount": "947.86690000",
"exchange_rate": "94786.69000000",
"fee_amount": "0.00000000",
"from_amount_usd": "947.87",
"to_amount_usd": "947.87",
"processed_at": "2026-01-20T15:30:24Z",
"created_at": "2026-01-20T15:30:22Z"
}
}
```
#### Response fields
| Field | Type | Description |
|-------|------|-------------|
| `id` | int | Convert order ID assigned by the system |
| `type` | string | Always `manual` for this API |
| `status` | string | Current status (see Convert statuses below) |
| `from_currency` | string | Source currency |
| `to_currency` | string | Target currency |
| `from_amount` | string | Amount debited in `from_currency` |
| `requested_from_amount` | string \| null | Your originally requested source amount when `amount_type = from`. `null` when `amount_type = to` |
| `refund_amount` | string \| null | Portion of the pre-debited amount refunded back to you after a partial fill. `null` when the order filled completely |
| `to_amount` | string | Amount credited in `to_currency` |
| `exchange_rate` | string | Rate actually applied to this conversion, 1 unit of `from_currency` in `to_currency` (already includes the platform's pricing) |
| `fee_amount` | string | Platform fee charged on this conversion, denominated in `from_currency` or `to_currency` depending on trade direction. Already reflected in `exchange_rate` — shown for transparency |
| `from_amount_usd` | string \| null | USD equivalent of `from_amount` |
| `to_amount_usd` | string \| null | USD equivalent of `to_amount` |
| `processed_at` | string (ISO 8601) \| null | When the conversion finished executing. `null` while still processing |
| `created_at` | string (ISO 8601) | When the conversion order was created |
#### Convert statuses
| Status | Description |
|--------|-------------|
| `pending` | Created, not yet sent to the market |
| `processing` | Balance locked and the order placed on the market |
| `completed` | Fully executed — `to_amount` has been credited to your balance |
| `failed` | Could not execute — any pre-debited amount was automatically refunded |
| `partially_completed` | Only for currency pairs with no direct market (routed through an intermediate currency): the first leg completed but the second failed. You're credited the intermediate currency instead of `to_currency` — convert again from there to reach your original target |
## Errors
On failure the response has `state: 1` and an `error_code` — shared by both `/v1/convert/price` and `/v1/convert`:
**🔴 422 / 400** · `application/json`
```json
{
"state": 1,
"error_code": "amount_too_small",
"errors": {
"amount": "Amount is too small for this conversion. Please increase the amount and try again."
}
}
```
| `error_code` | HTTP status | Description |
|--------------|-------------|-------------|
| `validation_failed` | 422 | Invalid or missing parameters, or a business-rule rejection (e.g. insufficient balance) — see the `errors` field for details |
| `amount_too_small` | 422 | `amount` is below the minimum tradable size for this currency pair |
| `convert_unavailable` | 400 | The conversion couldn't be executed right now (market data unavailable or no route between the two currencies) — retry shortly |
| `internal_error` | 400 | Unexpected server error while processing the request |
## Automatic conversion of incoming payments
Auto-convert is a project setting for incoming invoice and static-wallet credits. It is configured in the merchant dashboard, not by adding fields to `/v1/payment`. Each rule selects one or more source currencies and a target currency.
When conversion completes, payment info and merchant webhooks can include:
```json
{
"payment_amount": "0.14800000",
"merchant_amount": "0.146520000000000000",
"payer_currency": "XMR",
"convert": {
"to_currency": "USDT",
"commission": "0.09000000",
"rate": "323.21000000",
"amount": "47.262015740000000000"
}
}
```
The amount domains are intentionally separate:
- `payment_amount` — what was detected on-chain in the source payment currency;
- `merchant_amount` — the net source amount attributable to the merchant before conversion;
- `convert.amount` — the amount credited in `convert.to_currency`;
- `convert.rate` and `convert.commission` — the executed conversion result, not a price you should recompute locally.
> **WARNING:** The absence of `convert` is meaningful: conversion may not have completed, may not be configured for that source, or may have fallen back to source-currency credit. Never invent a target amount from `/exchange-rates` or a public market price.
### Auto-convert failure and fallback
Conversion is downstream of receiving the blockchain payment. Market availability, minimum order sizes, precision limits, exchange timeouts, and insufficient executable liquidity can delay or prevent conversion.
- Deposits below the global/project minimum bypass the conversion pipeline and credit the source currency.
- Transient failures can be retried asynchronously.
- Large or untradeable deposits can fall back to a source-currency credit after the retry policy is exhausted.
- A payment can therefore be valid even when the desired target-currency conversion did not occur.
Your integration should persist the verified payment first, then reconcile the actual credited currency from payment info, the optional `convert` block, and merchant balances. Do not block acknowledgement of the payment webhook while waiting on your own analytics or notification systems.
### Auto-convert acceptance tests
Test at least: successful direct conversion, bridge/multi-hop conversion, dust below minimum, transient retry, fallback to source currency, underpayment, overpayment, duplicate webhook, missing `convert`, and reconciliation after an ambiguous timeout.
## Manual conversion edge cases
- `/v1/convert/price` is an indicative preview; market movement can change the execution result.
- `amount_type: from` fixes the source-side request, while `amount_type: to` requests a target-side amount. Do not swap the meaning when presenting confirmation UI.
- A pair without a direct market can be routed through an intermediate currency. If only one leg completes, `partially_completed` reports the intermediate credit.
- If an execute call times out, reconcile before retrying. A market order can execute even when its HTTP response is lost.
- Treat `failed` as a state to reconcile, not as permission to apply a local compensating balance entry; the platform owns debit/refund accounting.
---
# Directions
> Retrieve the list of available deposit and withdrawal directions with their current statuses and fees.
The endpoint returns all supported currency + network pairs with their current statuses, limits, and estimated withdrawal fees. Use it to dynamically build a currency selector UI and show users the withdrawal cost upfront.
## List directions
`GET /v1/directions`
No authentication is required beyond the standard project signature. For a GET request, sign an empty body:
`hash_hmac('sha256', base64_encode(''), $apiKey)`
> **INFO:** Sign the request with an empty body:
> `hash_hmac('sha256', base64_encode(''), $apiKey)`
### Request example
```bash
curl -X GET https://api.2328.io/api/v1/directions \
-H "project: YOUR_PROJECT_UUID" \
-H "sign: YOUR_HMAC_SIGNATURE"
```
### Response example
```json
{
"state": 0,
"result": [
{
"network": "Tron",
"network_code": "TRX-TRC20",
"currency": "USDT",
"deposit_status": "active",
"withdrawal_status": "active",
"min_deposit": "1.000000000000000000",
"max_deposit": "50000.000000000000000000",
"min_withdrawal": "5.000000000000000000",
"max_withdrawal": "50000.000000000000000000",
"deposit_fee_percent": "1.00",
"withdrawal_fee": "2.500000000000000000",
"withdrawal_fee_type": "fixed"
},
{
"network": "Bitcoin",
"network_code": "BTC",
"currency": "BTC",
"deposit_status": "inactive",
"withdrawal_status": "active",
"min_deposit": "0.000100000000000000",
"max_deposit": "2.000000000000000000",
"min_withdrawal": "0.000100000000000000",
"max_withdrawal": "2.000000000000000000",
"deposit_fee_percent": "1.00",
"withdrawal_fee": null,
"withdrawal_fee_type": "estimated"
}
]
}
```
### Response fields
| Field | Type | Description |
|-------|------|-------------|
| `network` | string | Network name (e.g. `Tron`, `Bitcoin`, `Ethereum`) |
| `network_code` | string | Network code (e.g. `TRX-TRC20`, `BTC`, `ETH-ERC20`; see [References](/docs/references#network-codes)) |
| `currency` | string | Currency code (e.g. `USDT`, `BTC`, `ETH`) |
| `deposit_status` | string | Deposit availability status (see below) |
| `withdrawal_status` | string | Withdrawal availability status (see below) |
| `min_deposit` | string \| null | Minimum deposit amount. `null` — no limit |
| `max_deposit` | string \| null | Maximum deposit amount. `null` — no limit |
| `min_withdrawal` | string \| null | Minimum withdrawal amount. `null` — no limit |
| `max_withdrawal` | string \| null | Maximum withdrawal amount. `null` — no limit |
| `deposit_fee_percent` | string | Effective deposit fee percentage for the authorized project and this direction. For example, `1.00` means 1% |
| `withdrawal_fee` | string \| null | Estimated withdrawal fee in the direction's currency. `null` — not available yet |
| `withdrawal_fee_type` | string | Fee type: `fixed`, `estimated`, or `unknown` (see below) |
### Direction statuses
| Status | Description |
|--------|-------------|
| `active` | Direction is fully operational |
| `inactive` | Temporarily unavailable (maintenance) |
| `disabled` | Not supported |
> **WARNING:** If `deposit_status` or `withdrawal_status` is anything other than `active`, the corresponding operation will be rejected with a [`direction_disabled`](#direction-disabled-error) error. Use this endpoint to hide unavailable directions in your UI before the user attempts an operation.
### Withdrawal fee types
| Type | Description |
|------|-------------|
| `fixed` | Fixed fee amount in the `withdrawal_fee` field |
| `estimated` | Estimated fee based on the current network fee. The final amount may differ slightly — use [`/v1/payout/calc`](/docs/payouts#calculate-payout) for a precise quote |
| `unknown` | No fee configured for this direction |
---
# 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"
}
]
}
```
> Use your project UUID and the endpoint-appropriate API key from the merchant dashboard.
---
# 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"]["EUR"] = 0.88` means 1 USD = 0.88 EUR
- `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.
## Crypto pair prices
`GET /v1/prices`
Public price feed for cryptocurrency pairs. No authentication required.
Returns all supported pairs — direct coin→USDT pairs and synthetic cross-pairs via USDT (e.g. TRX→TON). Price already includes the platform markup.
### Response example
```json
[
{"from": "BTC", "to": "USDT", "price": "105234.5"},
{"from": "ETH", "to": "USDT", "price": "3812.08"},
{"from": "USDT", "to": "USDC", "price": "1.0012"},
{"from": "TRX", "to": "TON", "price": "0.000834"}
]
```
### Response fields
| Field | Type | Description |
|-------|------|-------------|
| `from` | string | Source currency |
| `to` | string | Target currency |
| `price` | string | Rate: how many `to` units per 1 `from` unit |
### cURL example
```bash
curl https://api.2328.io/api/v1/prices
```