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.
Add one header
Authorization: Bearer your_key.
Verify access
Request the balance. A JSON response with data means the key was accepted.
{
"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.
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.
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.
{
"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.
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.
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.
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.
{
"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.
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.
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.
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.
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.
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
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.
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.
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.
{
"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
Read the raw request body as bytes, before JSON parsing. Re-serialized JSON will not match the signature.
Compute HMAC-SHA256 over β{Webhook-Timestamp}.{raw body}β with your whsec_β¦ secret as the key and hex-encode it.
Compare βv1=β + hex with the Webhook-Signature header using a constant-time function: hash_equals, crypto.timingSafeEqual, hmac.compare_digest.
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);
Node.js
const crypto = require('crypto');
const express = require('express');
const app = express();
const secret = process.env.SNAPEX_WEBHOOK_SECRET; // whsec_β¦ from the cabinet
// raw body, before JSON parsing
app.post('/hooks/snapex', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.get('Webhook-Timestamp') || '';
const signature = Buffer.from(req.get('Webhook-Signature') || '');
const expected = Buffer.from('v1=' + crypto.createHmac('sha256', secret)
.update(timestamp + '.').update(req.body).digest('hex'));
const ok = /^\d+$/.test(timestamp)
&& Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300
&& signature.length === expected.length
&& crypto.timingSafeEqual(signature, expected);
if (!ok) return res.sendStatus(400);
const event = JSON.parse(req.body.toString('utf8'));
// skip if this Webhook-Id was already processed; do the heavy work in a queue
res.sendStatus(200);
});
Python
import hashlib, hmac, os, time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["SNAPEX_WEBHOOK_SECRET"].encode() # whsec_β¦ from the cabinet
@app.post("/hooks/snapex")
def snapex_webhook():
body = request.get_data() # raw body, before JSON parsing
timestamp = request.headers.get("Webhook-Timestamp", "")
signature = request.headers.get("Webhook-Signature", "")
expected = "v1=" + hmac.new(SECRET, timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
if not (timestamp.isascii() and timestamp.isdigit()) \
or abs(time.time() - int(timestamp)) > 300 \
or not hmac.compare_digest(expected.encode(), signature.encode()):
abort(400)
event = request.get_json()
# skip if this Webhook-Id was already processed; do the heavy work in a queue
return "", 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