EN

Merchant API documentation

Base URL: https://snapex.pro/api/v1/merchant

API key and first request

  1. Get a key in the cabinet

    Partner cabinet β†’ Merchant β†’ β€œGet API key”. The key looks like smk_abcdefghij_plus48chars and is shown once. If it is lost, issue a new one: the previous key becomes invalid immediately.

  2. Add one header

    Authorization: Bearer your_key.

  3. Verify access

    Request the balance. A JSON response with data means the key was accepted.

request
curl -sS https://snapex.pro/api/v1/merchant/balances \
  -H "Authorization: Bearer smk_abcdefghij_yourkey" \
  -H "Accept: application/json"
response
{
  "data": [
    {
      "asset_id": "usdt",               // always usdt β€” there is no other balance
      "available_atomic": "15000000",   // 15 USDT in minor units (6 decimals)
      "available": "15",                // the same value in decimal form
      "held_atomic": "0",               // reserved for unfinished payouts
      "held": "0"
    }
  ]
}

If the request failed

Response Cause
401 unauthorized The key is missing, contains a space, or is truncated. Bearer must be followed by the full smk_… identifier.
401 key_inactive The key was revoked: a new key was issued, but the request still uses the previous one.

Five rules

Most integration errors come from breaking one of these rules.

1. Send amounts as strings

Pass either amount β€” a decimal string: "0.001", "15" β€” or amount_atomic β€” an integer in minor units: "100000" for 0.001 BTC. Use exactly one field. Responses always include both forms: amount sits next to amount_atomic.

example
"amount": "0.001"        // valid
"amount_atomic": "100000" // also valid
// both at once β€” 422

2. Take asset_id only from /assets

Allowed codes: usdt, btc, xmr. Arbitrary values and blockchain-explorer ids are rejected with 422.

example
"asset_id": "btc"   // correct
"asset_id": "BTC"   // error
"asset_id": "bitcoin" // error

3. Idempotency-Key on every creating request

A unique attempt id. If the connection drops and the result is unknown, repeat the request with the same key: the original result is returned and a second operation is not created. Required for POST /invoices, POST /payments, POST /payouts/preview, POST /payouts.

example
Idempotency-Key: order-42-payment   // first attempt β†’ 201, created
Idempotency-Key: order-42-payment   // retry β†’ 200, same resource
Idempotency-Key: order-42-payment   // different amount β†’ 409 conflict

4. Empty fields are omitted

Null and empty strings are not included. An unpaid payment has no actual_amount, credited_amount, or fee. Check whether the key exists, not its value.

example
// unpaid payment:
{ "payment_id": "pay_…", "status": "awaiting_deposit" }
// credited_amount is simply absent

5. Do not close the order on expired

expired does not mean the transfer will never arrive. The address is monitored for about 6 more hours, and a late transfer is credited. Final statuses with a credit: confirmed, confirmed_underpaid, confirmed_overpaid.

Scenarios

Each scenario covers the full cycle: request order, response shape, and field meaning. Comments in JSON examples are for explanation and are not present in real responses.

Accepting payments

Customer pays BTC for a 60 USD order

Order order-42. You need about 60 USDT; at a BTC price of about 60,000 the expected customer amount is 0.001 BTC.

1
Get the list of available assets

The response returns the asset code and decimals β€” the number of digits after the decimal point.

request
curl -sS https://snapex.pro/api/v1/merchant/assets \
  -H "Authorization: Bearer smk_abcdefghij_yourkey" \
  -H "Accept: application/json"

Key points

GET /assets
No parameters are required.
Authorization
The only required header in the API.
response
{
  "data": [
    {
      "asset_id": "btc",       // ← use this in later requests
      "symbol": "BTC",         // for display to the user
      "network": "bitcoin",    // used in payouts
      "decimals": 8,           // 1 BTC = 100000000 atomic units
      "price_usd": "60000"     // indicative, not for quoting
    },
    {
      "asset_id": "usdt",
      "symbol": "USDT",
      "network": "TRON",
      "decimals": 6,
      "price_usd": "1.00"
    }
  ]
}
2
Lock the rate for 10 minutes

Locks the rate while the customer is on the payment page. No payment or address is created on this step: only a quote is calculated. The response is the USDT amount to be credited for 0.001 BTC.

request
curl -sS -X POST https://snapex.pro/api/v1/merchant/payments/credit-preview \
  -H "Authorization: Bearer smk_abcdefghij_yourkey" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"asset_id":"btc","amount":"0.001"}'

Key points

asset_id: "btc"
Code from the previous step.
amount: "0.001"
Expected BTC amount from the customer. Sent as a string, not a number.
Idempotency-Key
Optional: no operation is created. You may send it to reuse the same quote.
response
{
  "data": {
    "credit_lock_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
                                       // ← save for the next step
    "asset_id": "btc",
    "expected_amount": "0.001",        // expected amount from the customer
    "expected_amount_atomic": "100000",
    "credit_usdt": "59.4",             // ← amount that will be credited
    "credit_usdt_atomic": "59400000",
    "expires_at": "2026-08-11T17:20:00+00:00"
                                       // ← after this time credit-preview is invalid
  }
}

Show the customer expected_amount β€” how much BTC to send.

3
Create the payment and get the address

This step creates the payment and returns the deposit address. Do not send the amount in this request β€” it is already set in credit-preview in step 2.

request
curl -sS -X POST https://snapex.pro/api/v1/merchant/payments \
  -H "Authorization: Bearer smk_abcdefghij_yourkey" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -H "Idempotency-Key: order-42-payment" \
  -d '{
    "credit_lock_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "external_id": "order-42"
  }'

Key points

credit_lock_id
Id from the credit-preview response. If the lock has expired, the API returns 422 β€” request a new preview.
external_id: "order-42"
Your order number. Used to find the payment later and to avoid two payments for one order.
Idempotency-Key
Required. Use a stable unique value, for example the order number with a suffix.
response
{
  "data": {
    "payment_id": "pay_01HZX...",      // ← id for status checks
    "external_id": "order-42",         // order number from the request
    "asset_id": "btc",
    "expected_amount": "0.001",
    "credit_usdt": "59.4",
    "status": "awaiting_deposit",      // waiting for the transfer
    "channel": "api",
    "deposit_address": "bc1qexampledepositaddress000000000",
                                       // ← address to show the customer
    "expires_at": "2026-08-11T18:00:00+00:00",
    "created_at": "2026-08-11T17:00:00+00:00"
  }
}

If the network requires a memo, the response includes deposit_memo. A transfer without the memo will not be credited β€” show both fields.

4
Wait for payment

Poll the payment every 10–30 seconds. Status follows awaiting_deposit β†’ detected β†’ processing β†’ confirmed.

request
curl -sS https://snapex.pro/api/v1/merchant/payments/pay_01HZX... \
  -H "Authorization: Bearer smk_abcdefghij_yourkey" \
  -H "Accept: application/json"

Key points

GET /payments/{payment_id}
Use payment_id from the previous response.
When
Recommended interval is 10–30 seconds. The limit is 120 requests per minute per key; do not poll faster.
response
{
  "data": {
    "payment_id": "pay_01HZX...",
    "status": "detected",              // transfer seen on the network,
                                       // not enough confirmations yet
    "asset_id": "btc",
    "expected_amount": "0.001",
    "actual_amount": "0.001",          // ← amount that actually arrived
    "deposit_address": "bc1qexampledepositaddress000000000",
    "created_at": "2026-08-11T17:00:00+00:00"
  }
}
5
Payment confirmed

confirmed means funds are credited to the balance. The order can be closed.

request
curl -sS https://snapex.pro/api/v1/merchant/payments/pay_01HZX... \
  -H "Authorization: Bearer smk_abcdefghij_yourkey" \
  -H "Accept: application/json"

Key points

status: confirmed
Final status; it will not change.
credited_amount
USDT actually credited. On an exact payment it matches credit_usdt.
response
{
  "data": {
    "payment_id": "pay_01HZX...",
    "external_id": "order-42",
    "status": "confirmed",             // ← credit completed
    "asset_id": "btc",
    "expected_amount": "0.001",
    "actual_amount": "0.001",          // expected amount arrived
    "credit_usdt": "59.4",
    "credited_amount": "59.4",         // ← amount credited to the balance
    "fee": "0.6",                      // fee, already included in credited_amount
    "deposit_address": "bc1qexampledepositaddress000000000",
    "created_at": "2026-08-11T17:00:00+00:00"
  }
}

Reconcile on credited_amount, not expected_amount: on underpayment the values differ.

Note

  • Do not create a payment in advance: the rate lock lasts 10 minutes, and the address is issued for a specific amount.
  • Do not close the order on detected: funds are not credited yet.

Customer pays in USDT

The flow is the same as the BTC scenario; only asset_id changes. A shorter example is below.

1
Lock the amount

There is no conversion, so credit_usdt is almost the amount minus the fee.

request
curl -sS -X POST https://snapex.pro/api/v1/merchant/payments/credit-preview \
  -H "Authorization: Bearer smk_abcdefghij_yourkey" \
  -H "Content-Type: application/json" \
  -d '{"asset_id":"usdt","amount":"100"}'

Key points

asset_id: "usdt"
The only difference from the BTC scenario.
amount: "100"
The customer sends 100 USDT on TRON.
response
{
  "data": {
    "credit_lock_id": "b2c3d4e5-...",
    "asset_id": "usdt",
    "expected_amount": "100",
    "credit_usdt": "99",         // ← 1 USDT fee
    "expires_at": "2026-08-11T17:20:00+00:00"
  }
}
2
Create the payment

Same step as before. The address is a TRON address (starts with T).

request
curl -sS -X POST https://snapex.pro/api/v1/merchant/payments \
  -H "Authorization: Bearer smk_abcdefghij_yourkey" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-43-payment" \
  -d '{"credit_lock_id":"b2c3d4e5-...","external_id":"order-43"}'

Key points

Idempotency-Key
Use a new Idempotency-Key for a new order. Do not reuse the key from order-42.
response
{
  "data": {
    "payment_id": "pay_01HZY...",
    "external_id": "order-43",
    "asset_id": "usdt",
    "expected_amount": "100",
    "credit_usdt": "99",
    "status": "awaiting_deposit",
    "deposit_address": "TXYZexampletronaddress000000000",
                                 // ← TRON address, TRC20
    "expires_at": "2026-08-11T18:00:00+00:00"
  }
}

Tell the customer the network is TRC20. USDT sent on another network will not be credited to this address.

Customer sent less than requested

0.001 BTC was expected, 0.0009 arrived β€” for example the customer wallet deducted the network fee from the amount.

1
Check the payment

If less than expected_amount arrived but the amount is still enough to credit, status is confirmed_underpaid and credited_amount is below the quote. If the amount is too small, nothing is credited.

request
curl -sS https://snapex.pro/api/v1/merchant/payments/pay_01HZX... \
  -H "Authorization: Bearer smk_abcdefghij_yourkey" \
  -H "Accept: application/json"

Key points

Meaning
Compare credited_amount, not credit_usdt. The first is what was credited; the second is the quote for an exact payment.
response
{
  "data": {
    "payment_id": "pay_01HZX...",
    "external_id": "order-42",
    "status": "confirmed_underpaid",   // ← underpaid, but funds credited
    "asset_id": "btc",
    "expected_amount": "0.001",        // expected amount
    "actual_amount": "0.0009",         // amount that actually arrived
    "credit_usdt": "59.4",             // quote for an exact payment
    "credited_amount": "53.4",         // ← amount actually credited
    "fee": "0.6",
    "created_at": "2026-08-11T17:00:00+00:00"
  }
}

Next steps are yours: request a top-up, fulfill the order in part, or cancel it. The API returns the amount actually credited.

Note

  • Do not decide β€œpaid or not” by comparing actual_amount with expected_amount. Compare credited_amount with the USDT you need.

Customer did not pay in time

expires_at has passed and no transfer arrived. The payment becomes expired; address monitoring continues.

1
Status expired

The payment window has closed. The address is still monitored for about 6 hours.

request
curl -sS https://snapex.pro/api/v1/merchant/payments/pay_01HZX... \
  -H "Authorization: Bearer smk_abcdefghij_yourkey" \
  -H "Accept: application/json"

Key points

status: expired
The payment window has expired.
credited_amount
No funds have arrived yet.
response
{
  "data": {
    "payment_id": "pay_01HZX...",
    "status": "expired",               // window expired
    "asset_id": "btc",
    "expected_amount": "0.001",
    "deposit_address": "bc1qexampledepositaddress000000000",
    "expires_at": "2026-08-11T18:00:00+00:00"
    // actual_amount and credited_amount are absent β€” no transfer arrived
  }
}
2
Customer paid late

If the transfer arrives in the extra monitoring window, the payment still moves to confirmed. Do not delete the order on expired: mark it overdue and keep polling.

request
curl -sS https://snapex.pro/api/v1/merchant/payments/pay_01HZX... \
  -H "Authorization: Bearer smk_abcdefghij_yourkey" \
  -H "Accept: application/json"

Key points

expired β†’ confirmed
Normal case: the transfer arrived after expires_at.
response
{
  "data": {
    "payment_id": "pay_01HZX...",
    "status": "confirmed",             // ← credited after expires_at
    "actual_amount": "0.001",
    "credited_amount": "59.4",
    "created_at": "2026-08-11T17:00:00+00:00"
  }
}

Note

  • Do not issue a new address immediately after expired: the customer may already be sending to the previous one. We still wait for the transfer for another 6 hours.

Invoices

A USD invoice with a hosted pay page. The customer picks a network; you are credited in USDT.

A 15 USD invoice paid in USDT

Order order-15. The merchant creates a USD invoice and sends the customer a pay page link.

1
Create the invoice

The invoice locks the USD amount, who pays the fee, and returns pay_url. No address yet.

request
curl -sS -X POST https://snapex.pro/api/v1/merchant/invoices \
  -H "Authorization: Bearer smk_abcdefghij_yourkey" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -H "Idempotency-Key: order-15-invoice" \
  -d '{
    "amount_usd": "15.00",
    "fee_on": "merchant",
    "external_id": "order-15"
  }'

Key points

amount_usd
Order amount as a string. This endpoint takes USD only β€” not amount_atomic.
fee_on
merchant β€” you pay the fee, the customer sees 15.00. customer β€” the customer pays the fee.
Idempotency-Key
Required. A retry with the same key returns the same invoice.
response
{
  "data": {
    "invoice_id": "INV-8K2M1Q0P",
    "amount_usd": "15.00",
    "fee_on": "merchant",
    "charge_usd": "15.00",
    "credit_usd": "13.85",
    "status": "open",
    "pay_url": "https://pay.snapex.pro/0123456789abcdef0123456789abcdef",
    "methods": [
      { "asset_id": "usdt.trc20", "symbol": "USDT", "network": "TRC20" },
      { "asset_id": "usdt.erc20", "symbol": "USDT", "network": "ERC20" },
      { "asset_id": "usdt.bep20", "symbol": "USDT", "network": "BEP20" }
    ]
  }
}

The key needs invoices.write or payments.write. Test contour: environment=test.

2
The customer picks a network

This step issues a unique address. The method can change until a transfer arrives.

request
curl -sS -X POST https://snapex.pro/api/v1/merchant/invoices/INV-8K2M1Q0P/methods \
  -H "Authorization: Bearer smk_abcdefghij_yourkey" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-15-trc20" \
  -d '{"asset_id":"usdt.trc20"}'

Key points

asset_id: "usdt.trc20"
A code from methods: usdt.trc20, usdt.erc20 or usdt.bep20. Other coins come from the same list.
payment.address
Show only this address and amount. Memo and Tag are not used.
response
{
  "data": {
    "invoice_id": "INV-8K2M1Q0P",
    "status": "awaiting",
    "selected_asset_id": "usdt.trc20",
    "payment": {
      "asset_id": "usdt.trc20",
      "amount": "15 USDT",
      "address": "TExampleUniqueAddressForThisAttempt",
      "status": "awaiting"
    }
  }
}

You can skip this call: the customer picks the network on pay_url.

3
The invoice is paid

The first completed transfer closes the invoice. A later transfer to another address is credited separately.

request
curl -sS https://snapex.pro/api/v1/merchant/invoices/INV-8K2M1Q0P \
  -H "Authorization: Bearer smk_abcdefghij_yourkey" \
  -H "Accept: application/json"

Key points

status: paid
Final status. You can close the order.
invoice.paid
The invoice.paid webhook arrives at the URL set in the cabinet. Signature check β€” see the β€œWebhooks” section.
response
{
  "data": {
    "invoice_id": "INV-8K2M1Q0P",
    "status": "paid",
    "credit_usd": "13.85",
    "paid_at": "2026-09-25T12:00:10+00:00"
  }
}

An underpay does not close the invoice: status underpaid, same address waits for the rest (USDT β€” 24 hours). If the rest does not arrive in time, the invoice ends as partially_paid.

Note

  • Do not name internal routes in the customer UI. The pay page shows only address, amount and network.
  • There is no refund API. A second completed transfer is a separate credit, not a reversal of the first.

Payouts

Outbound transfers from your USDT balance.

Send the customer 15 USDT as BTC

amount is how much is debited from your balance, not how much the recipient gets. The recipient amount is returned by preview.

1
Check available balance

Use available, not available plus held. Held is reserved for unfinished payouts and cannot be spent.

request
curl -sS https://snapex.pro/api/v1/merchant/balances \
  -H "Authorization: Bearer smk_abcdefghij_yourkey" \
  -H "Accept: application/json"

Key points

available
Available balance for debit.
held
Reserved for payouts in progress.
response
{
  "data": [
    {
      "asset_id": "usdt",
      "available": "74.4",       // ← available to spend
      "available_atomic": "74400000",
      "held": "0",               // nothing reserved
      "held_atomic": "0"
    }
  ]
}
2
Quote the payout

Preview returns how much BTC the recipient gets for 15 USDT and the fee. Nothing is debited yet: only the quote is locked.

request
curl -sS -X POST https://snapex.pro/api/v1/merchant/payouts/preview \
  -H "Authorization: Bearer smk_abcdefghij_yourkey" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -H "Idempotency-Key: wd-77-preview" \
  -d '{
    "destination_asset_id": "btc",
    "destination_network": "bitcoin",
    "destination_address": "bc1qexampledestinationaddress000000",
    "amount": "15"
  }'

Key points

amount: "15"
USDT debit from your balance. This is not the recipient amount.
destination_asset_id
Recipient asset. Code from GET /assets.
destination_network
Recipient network. For BTC β€” bitcoin. Take it from network in GET /assets.
destination_address
Recipient address. Verify it before sending: the transfer cannot be cancelled.
Idempotency-Key
Required. Use a separate key for preview.
response
{
  "data": {
    "preview_id": "c3d4e5f6-7890-abcd-ef12-34567890abcd",
                                     // ← id for the next step
    "from_asset_id": "usdt",
    "from_amount": "15",             // amount debited
    "to_asset_id": "btc",
    "to_amount": "0.000221",         // ← amount the recipient gets
    "fee_amount": "0.000019",        // fee in BTC, already included
    "expires_at": "2026-08-11T17:20:00+00:00"
                                     // ← execute before this time
  }
}

Show to_amount to the user as the amount they will receive. If the amount is not acceptable, do not execute the preview: it expires at expires_at.

3
Execute the payout

This step debits and sends. Repeat the address and network in the request β€” this prevents substitution between steps.

request
curl -sS -X POST https://snapex.pro/api/v1/merchant/payouts \
  -H "Authorization: Bearer smk_abcdefghij_yourkey" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -H "Idempotency-Key: wd-77-execute" \
  -d '{
    "preview_id": "c3d4e5f6-7890-abcd-ef12-34567890abcd",
    "destination_network": "bitcoin",
    "destination_address": "bc1qexampledestinationaddress000000",
    "external_id": "wd-77"
  }'

Key points

Idempotency-Key: wd-77-execute
A new key, different from the preview key. Reusing the preview key returns 409.
destination_network / address
Must match the preview exactly, otherwise 422.
external_id
Your operation id for later lookup.
response
{
  "data": {
    "payout_id": "payout_01HZX...",  // ← id for tracking
    "external_id": "wd-77",
    "asset_id": "usdt",
    "debit_amount": "15",            // debited from the balance
    "destination_asset_id": "btc",
    "destination_network": "bitcoin",
    "destination_address": "bc1qexampledestinationaddress000000",
    "destination_amount": "0.000221",// amount the recipient gets
    "fee": "0.000019",
    "status": "reserved",            // funds reserved, sending started
    "channel": "api",
    "created_at": "2026-08-11T17:10:00+00:00"
  }
}
4
Wait until it completes

Status chain: reserved β†’ submitting β†’ processing β†’ completed. Poll every 15–30 seconds.

request
curl -sS https://snapex.pro/api/v1/merchant/payouts/payout_01HZX... \
  -H "Authorization: Bearer smk_abcdefghij_yourkey" \
  -H "Accept: application/json"

Key points

status: completed
Funds delivered. Final status.
status: failed
Transfer failed; the reserve is usually released back to the balance.
status: manual_review
Result is ambiguous. Wait for review; do not create another payout.
response
{
  "data": {
    "payout_id": "payout_01HZX...",
    "external_id": "wd-77",
    "debit_amount": "15",
    "destination_amount": "0.000221",
    "status": "completed",           // ← delivered
    "created_at": "2026-08-11T17:10:00+00:00"
  }
}

Note

  • amount is the debit, not the recipient amount.
  • Payouts have a separate limit: 5 requests per minute and 30 per hour. Do not call preview in a loop.
  • If the preview expired (expires_at), POST /payouts returns 422. Request a new preview.

Send USDT to an external TRON wallet

The difference is that destination_asset_id is also usdt. The fee is minimal in this case.

1
Quote the payout

Debit and destination assets match, so to_amount is almost amount minus the network fee.

request
curl -sS -X POST https://snapex.pro/api/v1/merchant/payouts/preview \
  -H "Authorization: Bearer smk_abcdefghij_yourkey" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: wd-78-preview" \
  -d '{
    "destination_asset_id": "usdt",
    "destination_network": "TRON",
    "destination_address": "TXYZexampletronaddress000000000",
    "amount": "50"
  }'

Key points

destination_network: "TRON"
Take the value from network in GET /assets; case matters.
amount: "50"
50 USDT will be debited.
response
{
  "data": {
    "preview_id": "d4e5f6a7-...",
    "from_asset_id": "usdt",
    "from_amount": "50",           // debit amount
    "to_asset_id": "usdt",
    "to_amount": "48.9",           // ← amount received (after network fee)
    "fee_amount": "1.1",
    "expires_at": "2026-08-11T17:25:00+00:00"
  }
}
2
Execute the payout

Same sequence as in the previous scenario.

request
curl -sS -X POST https://snapex.pro/api/v1/merchant/payouts \
  -H "Authorization: Bearer smk_abcdefghij_yourkey" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: wd-78-execute" \
  -d '{
    "preview_id": "d4e5f6a7-...",
    "destination_network": "TRON",
    "destination_address": "TXYZexampletronaddress000000000",
    "external_id": "wd-78"
  }'
response
{
  "data": {
    "payout_id": "payout_01HZZ...",
    "debit_amount": "50",
    "destination_asset_id": "usdt",
    "destination_amount": "48.9",
    "status": "reserved",
    "created_at": "2026-08-11T17:26:00+00:00"
  }
}

Note

  • Confirm the recipient address is on TRON. A transfer to another network is not executed: the request is rejected or the funds will not arrive.

Insufficient balance for a payout

Balance is 10 USDT, payout of 15 is requested.

1
Preview rejects the request

Balance is checked at quote time, before any debit. The error can be shown to the user immediately.

request
curl -sS -X POST https://snapex.pro/api/v1/merchant/payouts/preview \
  -H "Authorization: Bearer smk_abcdefghij_yourkey" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: wd-79-preview" \
  -d '{
    "destination_asset_id": "btc",
    "destination_network": "bitcoin",
    "destination_address": "bc1q…",
    "amount": "15"
  }'

Key points

HTTP 422
Business rejection. The envelope matches validation errors.
details.amount
The error is bound to a field and can be shown on the form.
response
{
  "error": {
    "code": "validation_error",
    "message": "The request is invalid.",
    "details": {
      "amount": ["Insufficient balance."]
                              // ← rejection reason is bound to amount
    }
  }
}

Nothing is reserved. Retrying with the same Idempotency-Key is safe; top up the balance first.

Note

  • Account for held: if part of the funds is reserved for another payout, available will be lower than expected.

Operations

Payment history

The ledger is an operations journal: credits, debits, and reserves are stored as separate entries.

1
Fetch history page by page

Entries are returned newest first. Up to 100 records per request; the next page uses the cursor.

request
curl -sS "https://snapex.pro/api/v1/merchant/transactions?limit=50" \
  -H "Authorization: Bearer smk_abcdefghij_yourkey" \
  -H "Accept: application/json"

Key points

limit=50
Records per response: 1 to 100, default 25.
status=payment
Optional filter by entry type.
response
{
  "data": [
    {
      "id": "124",
      "direction": "credit",          // credit
      "type": "payment",              // source β€” deposit
      "amount": "59.4",
      "asset_id": "usdt",
      "channel": "api",
      "occurred_at": "2026-08-11T17:05:00+00:00"
    },
    {
      "id": "125",
      "direction": "reserve",         // reserve for a payout
      "type": "payout",
      "amount": "15",
      "asset_id": "usdt",
      "occurred_at": "2026-08-11T17:10:00+00:00"
    }
  ],
  "meta": {
    "next_cursor": "eyJpZCI6MTI1fQ",  // ← value for the cursor parameter
    "per_page": 50
  }
}
2
Next page

When next_cursor is null, there are no more records.

request
curl -sS "https://snapex.pro/api/v1/merchant/transactions?limit=50&cursor=eyJpZCI6MTI1fQ" \
  -H "Authorization: Bearer smk_abcdefghij_yourkey" \
  -H "Accept: application/json"

Key points

cursor
Value of meta.next_cursor from the previous response. An arbitrary value is not accepted.
response
{
  "data": [ /* more records */ ],
  "meta": {
    "next_cursor": null,              // ← last page
    "per_page": 50
  }
}

Note

  • direction reserve and release are a hold and a release, not an outbound transfer. Do not treat them as spend.

No response β€” repeat the same request

1
Repeat the same request

Use the same Idempotency-Key and the same parameters. The server treats it as the same operation.

request
curl -sS -X POST https://snapex.pro/api/v1/merchant/payouts \
  -H "Authorization: Bearer smk_abcdefghij_yourkey" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: wd-77-execute" \
  -d '{
    "preview_id": "c3d4e5f6-...",
    "destination_network": "bitcoin",
    "destination_address": "bc1qexampledestinationaddress000000",
    "external_id": "wd-77"
  }'

Key points

Idempotency-Key
A new key creates a new payout.
HTTP 200 / 201
The operation already existed; the original object is returned.
response
{
  "data": {
    "payout_id": "payout_01HZX...",   // ← same id as in the first response
    "external_id": "wd-77",
    "debit_amount": "15",
    "status": "processing",           // status may have changed while waiting
    "created_at": "2026-08-11T17:10:00+00:00"
  }
}

HTTP 201 β€” the operation was created in this request. HTTP 200 β€” it already existed. Both codes mean success.

2
If parameters changed

Repeating the same key with a different amount or address is rejected. This prevents parameter mismatch.

request
curl -sS -X POST https://snapex.pro/api/v1/merchant/payouts \
  -H "Authorization: Bearer smk_abcdefghij_yourkey" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: wd-77-execute" \
  -d '{
    "preview_id": "another-preview",
    "destination_network": "bitcoin",
    "destination_address": "bc1qanotheraddress"
  }'

Key points

HTTP 409
Conflict: the key is already bound to another operation.
What to do
If this is a new payout, use a new Idempotency-Key.
response
{
  "error": {
    "code": "conflict",
    "message": "Idempotency-Key was used with different parameters."
  }
}

Note

  • Generate the key once and store it with the order. Generating a new key before every send disables duplicate protection.
  • On HTTP 429, retry with the same key after a few seconds.

Webhooks

When an invoice, deposit, payout, conversion or the balance changes, SnapEX sends a POST request to your URL. A webhook is a signal, not the source of truth: verify the signature and, for important decisions, re-read the object via the API.

Setup

  1. Set the URL in the cabinet

    Partner cabinet β†’ Invoices β†’ β€œWebhook” card: enter the URL and press β€œSave”. One URL per account; it receives every event type from the table below.

  2. Store the signing secret

    On the first save the cabinet shows the secret whsec_… once. Keep it on your server only. Changing the URL later keeps the same secret.

  3. URL requirements

    An absolute https:// URL with a public host: no login:password in the URL, no localhost, and the host must not resolve to a private or reserved IP. The URL is checked on save and again before every delivery attempt.

What arrives

Header Meaning
Webhook-Id Unique event id (UUID), equal to id in the body. It does not change on retries or manual replays β€” deduplicate by it.
Webhook-Timestamp Unix time in seconds when this attempt was sent. Every retry carries a fresh value and a fresh signature.
Webhook-Signature v1= followed by the lowercase hex HMAC-SHA256 of the string β€œ{Webhook-Timestamp}.{raw body}”. The key is the whole secret, whsec_ prefix included.
Content-Type Always application/json. The body is UTF-8 JSON.
request
POST /hooks/snapex HTTP/1.1
Content-Type: application/json
Webhook-Id: 6f1c2a8e-3b4d-4f5a-9c7e-1a2b3c4d5e6f
Webhook-Timestamp: 1790337610
Webhook-Signature: v1=2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881
body
{
  "id": "6f1c2a8e-3b4d-4f5a-9c7e-1a2b3c4d5e6f",   // = Webhook-Id
  "type": "invoice.paid",                          // event type, see the table below
  "created_at": "2026-09-25T12:00:10+00:00",       // when the event happened
  "data": {                                        // the object at the moment of the event
    "invoice_id": "INV-8K2M1Q0P",
    "environment": "live",
    "amount_usd": "15.00",
    "charge_usd": "15.00",
    "credit_usd": "13.85",
    "status": "paid",
    "external_id": "order-15",
    "paid_at": "2026-09-25T12:00:10+00:00"
  }
}

data depends on the type: invoice.* β€” the invoice in the same shape as GET /invoices/{id} (attempts is empty, the current attempt is in payment); payment.* β€” the deposit (payment_id, external_id, status, amounts); payout.* β€” the payout (payout_id, external_id, status, amounts); conversion.* β€” the conversion (conversion_id, status, amounts); balance.changed β€” asset_id, entry_type, available_atomic and held_atomic after the change.

Event types

Event Meaning
invoice.created Invoice created; no payment method selected yet (open).
invoice.awaiting A payment method was selected and the address issued (awaiting).
invoice.underpaid Part of the amount arrived; the same address waits for the rest (underpaid).
invoice.paid The full amount was paid and credited (paid, final). The only signal to ship the order.
invoice.partially_paid The customer paid only part, the top-up window ended, the received part was credited to you (partially_paid, final).
invoice.expired The invoice window ended without payment (expired, final).
invoice.extra_payment Another transfer arrived after the invoice was paid; it is credited separately.
payment.created A deposit was created.
payment.detected The transfer was seen on the network but not credited yet.
payment.credited The deposit was credited (one of the confirmed* statuses).
payment.expired The deposit window expired.
payout.created A payout was created and funds were reserved.
payout.processing The payout is being sent.
payout.completed Funds were delivered to the recipient.
payout.failed The payout failed.
conversion.completed The conversion completed.
conversion.failed The conversion failed fully or partially.
balance.changed A balance entry was recorded: credit, reserve or debit.

Ship the order only on invoice.paid β€” it is sent only when the full amount was paid. invoice.underpaid and invoice.partially_paid mean the amount is incomplete.

Invoices created with environment=test are simulated: they go through the same statuses and send the same webhooks (data.environment = "test"), but never change the real balance.

Delivery and retries

  • Success is any 2xx response. Any other code, a timeout or a network error is a failure. The response body is not interpreted.
  • Timeouts: 5 s to connect, 15 s for the whole request.
  • Redirects are not followed: 301/302 counts as a failure. Set the final URL.
  • Up to 5 attempts per event: the first one, then retries about 10 s, 1 min, 5 min and 15 min after each failure. After the fifth failure automatic delivery stops.
  • The same card lists β€œRecent deliveries”: event, HTTP code or error, time and attempt number. The β€œRetry” button sends the event again with the same Webhook-Id.
  • Delivery is at-least-once: one event may arrive several times, and events may arrive out of order (a retried event can come after a newer one).

Signature verification

  1. Read the raw request body as bytes, before JSON parsing. Re-serialized JSON will not match the signature.
  2. Compute HMAC-SHA256 over β€œ{Webhook-Timestamp}.{raw body}” with your whsec_… secret as the key and hex-encode it.
  3. Compare β€œv1=” + hex with the Webhook-Signature header using a constant-time function: hash_equals, crypto.timingSafeEqual, hmac.compare_digest.
  4. Reject a Webhook-Timestamp that differs from your clock by more than 5 minutes (recommended). Retries carry a fresh timestamp, so this does not break them.
PHP
<?php
$secret = getenv('SNAPEX_WEBHOOK_SECRET'); // whsec_… from the cabinet
$body = file_get_contents('php://input');  // raw body, before JSON parsing
$timestamp = $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '';

$expected = 'v1='.hash_hmac('sha256', $timestamp.'.'.$body, $secret);
if (! ctype_digit($timestamp)
    || abs(time() - (int) $timestamp) > 300
    || ! hash_equals($expected, $signature)) {
    http_response_code(400);
    exit;
}

$event = json_decode($body, true);
// skip if this Webhook-Id was already processed; do the heavy work in a queue
http_response_code(200);

Handler checklist

  • Verify the signature over the raw body with a constant-time comparison; reply 400 if it does not match.
  • Reject requests with a Webhook-Timestamp older than 5 minutes.
  • Store processed Webhook-Id values and ignore repeats. Do not rely on order: compare the status in the event with what you already have.
  • Reply 2xx quickly, well within 15 seconds, and do the heavy work asynchronously.
  • After a webhook you may re-read the object via the API to confirm it: GET /invoices/{id}, GET /payments/{id}, GET /payouts/{id}.

Statuses

The β€œfinal” badge means the status will not change. The only exception is expired deposits: a late transfer can still arrive.

Deposits

Status Meaning What to do
awaiting_deposit Address issued, waiting for the transfer Show the customer the address and amount
detected Transfer seen on the network, not credited yet Wait. Do not close the order
processing Converting to USDT Wait
confirmed Expected amount arrived, funds credited Close the order final
confirmed_underpaid Less than expected arrived, within the allowed range Check credited_amount and decide if it is enough final
confirmed_overpaid More than expected arrived, credited per credit-preview Close the order. Contact support about the excess final
confirmed_mismatch Kept for compatibility Not set on new payments final
manual_review Manual review required Wait for review; do not create a second payment
expired Payment window expired, address is monitored for ~6 h more Do not delete the order; keep polling
failed Provider-side failure Create a new payment final
cancelled Payment cancelled Create a new payment final
refunded Refunded by the provider Reconcile the balance; contact support if it does not match final

Invoices

Status Meaning What to do
open Invoice created, payment method not selected Give the customer pay_url or select asset_id yourself
awaiting Address issued, waiting for the transfer Show the address and amount; poll the status
underpaid Part of the amount arrived; same address Ask the customer to send the rest to this address
paid The first full transfer was credited Close the order final
partially_paid The customer paid only part, the top-up window ended, the received part was credited to you Do not ship as a paid order; settle the rest with the customer final
expired The invoice window ended Create a new invoice final

Payouts

Status Meaning What to do
reserved Funds reserved for the payout Wait
submitting Submitted to the provider Wait
processing Provider is sending on-chain Wait
completed Funds delivered to the recipient Done final
manual_review Result is ambiguous Do not create another payout; wait for review
failed Transfer failed; the reserve is usually released Check the balance; retry with a new Idempotency-Key if needed final

Errors

A successful response is always in data. An error is always in error. There are no other envelopes.

error shape
{
  "error": {
    "code": "validation_error",     // machine-readable code, see the table below
    "message": "The request is invalid.",
    "details": {                    // not always present
      "destination_address": ["The destination address field is required."]
                                    // key is the field that failed validation
    }
  }
}
HTTP code When What to do
401 unauthorized Key missing or invalid Check the Authorization: Bearer smk_… header in full, with no spaces or line breaks
401 key_inactive Key revoked The previous key was revoked. Use the current key from the cabinet
401 key_expired Key expired Issue a new key
404 not_found Id not found or belongs to another merchant Use payment_id or payout_id from your own response
409 conflict Idempotency-Key is bound to another operation, or external_id is already used For a new operation use a new key. For a retry use the same key and the same parameters
422 validation_error Invalid fields, expired credit-preview, insufficient balance, or missing Idempotency-Key See details for the field and the reason
429 rate_limited Rate limit exceeded Retry later with the same Idempotency-Key
500 internal_error Internal server error Retry later with the same Idempotency-Key. If it persists, contact support and include X-Request-Id