GET
post=0. This is the API default. CryptPays fields are appended once to the callback query and the raw body is empty.
Callbacks
Receive payment state changes, verify the exact signed request, process each lifecycle idempotently, and acknowledge successful handling.
Overview
CryptPays sends signed callbacks for payment lifecycle events. Receive the exact request bytes, verify its timestamp and RSA signature, then parse the payload, apply an idempotent lifecycle transition, and return HTTP 200 after successful handling.
Preserve the method, host, exact path and query, and raw body.
Check freshness and X-CryptPay-Signature before parsing.
Validate the order and coin, then advance the UUID lifecycle idempotently.
Return exactly HTTP 200 after processing succeeds.
Delivery
Configure delivery when calling /create. The method used to call /create is unrelated to the callback method: POST /create with post=0 still configures GET callbacks.
post=0. This is the API default. CryptPays fields are appended once to the callback query and the raw body is empty.
post=1, json=0. Fields are sent only in an application/x-www-form-urlencoded raw body.
post=1, json=1. Fields are sent only in the application/json raw body. Recommended for new integrations.
A callback such as https://merchant.example/callback?order_id=123 keeps safe merchant query values. For GET, CryptPays appends its payload after them. For POST, CryptPays fields stay in the body and are not duplicated into the URL.
order_id, invoice_id, or reference.See Create Parameters for transport configuration.
Lifecycle
Pending is optional and informational. The confirmed sent event is the merchant fulfillment stage. Both stages use the same UUID.
Optional · default off
result="pending" and pending=1. It is emitted only after the deposit meets the configured minimum and normal fee/network-cost eligibility succeeds. If pricing or cost evidence is unavailable, pending is withheld while deposit evidence remains available for processing and recovery. It uses the individual deposit amount, zero forwarded amount, zero fee percentage, and zero fee_coin. Price and conversion fields are absent. JSON includes txid_out: null; GET and form omit that null field. Solana reports confirmations=0.
Do not fulfill an order from this event. A pending delivery failure does not stop confirmation or forwarding, so a confirmed callback can arrive even when pending was never received successfully.
Fulfillment event
result="sent" and pending=0. Verify the signature, validate the expected order and coin, process the confirmed UUID once, then return HTTP 200.
If pending was enabled, this event reuses its UUID. No separate merchant callback with result="done" follows.
Pending JSON example
{
"uuid": "9ab63121-41f8-4c56-9c28-c78e9b44b487",
"address_in": "0x2222222222222222222222222222222222222222",
"address_out": "0x1111111111111111111111111111111111111111",
"txid_in": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"txid_out": null,
"confirmations": 0,
"value": "100.000000000000000000",
"value_coin": "100.000000000000000000",
"value_forwarded": "0.000000000000000000",
"value_forwarded_coin": "0.000000000000000000",
"fee": "0.00",
"fee_coin": "0.000000000000000000",
"coin": "bep20_usdt",
"result": "pending",
"pending": 1
}Confirmed JSON example
{
"uuid": "9ab63121-41f8-4c56-9c28-c78e9b44b487",
"address_in": "0x2222222222222222222222222222222222222222",
"address_out": "0x1111111111111111111111111111111111111111",
"txid_in": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"txid_out": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"confirmations": 12,
"value": "100.000000000000000000",
"value_coin": "100.000000000000000000",
"value_forwarded": "98.300000000000000000",
"value_forwarded_coin": "98.300000000000000000",
"fee": "1.50",
"fee_coin": "1.700000000000000000",
"price": "1.00000000",
"coin": "bep20_usdt",
"result": "sent",
"pending": 0
}Wire contract
JSON POST preserves the types below. GET and form transports encode scalar values as strings. Money is always decimal text; parse it with decimal-safe arithmetic, never binary floating point.
| Field | Type | Pending | Confirmed | Description |
|---|---|---|---|---|
uuid | String | Present | Same value | Stable callback lifecycle identifier. |
address_in | String | Present | Present | Customer deposit address. |
address_out | String | Present | Present | Merchant payout address. |
txid_in | String | Present | Present | Incoming customer transaction hash. Bare hash for UTXO. |
vout | Integer | UTXO only | UTXO only | Incoming UTXO output index, separate from txid_in. |
txid_out | String or null | JSON null; omitted by GET/form | Forward hash | CryptPays forwarding transaction identifier when available. |
confirmations | Integer | Current incoming count; Solana 0 | Incoming count; Solana 1 | Customer-deposit confirmations/finality, never outbound confirmations. |
value | Decimal string | Deposit amount | Deposit amount | Alias of value_coin. |
value_coin | Decimal string | Deposit amount | Deposit amount | Exact principal for this independent payment. |
value_forwarded | Decimal string | Zero | Forwarded amount | Alias of value_forwarded_coin. |
value_forwarded_coin | Decimal string | Zero | Forwarded amount | Amount actually forwarded to the merchant. |
fee | Decimal string | "0.00" | Actual percentage | Contracted CryptPays percentage for this payment. |
fee_coin | Decimal string | Zero | Total deduction | Everything withheld from the payment before merchant forwarding. |
price | Decimal string | Omitted | Conditional | Confirmed-time USDT quote; omitted when unavailable. |
coin | String | Present | Present | Canonical CryptPays ticker for the deposited asset. |
result | String | pending | sent | Merchant-visible lifecycle stage. |
pending | Integer | 1 | 0 | Machine-friendly lifecycle flag. |
value_coin_convert | JSON-encoded string | Omitted | Conditional | USDT conversion of value_coin when convert=1 and price exists. |
value_forwarded_coin_convert | JSON-encoded string | Omitted | Conditional | USDT conversion of forwarded value under the same conditions. |
value and value_coin currently carry equivalent values, as do value_forwarded and value_forwarded_coin. New integrations should prefer the explicit *_coin names.
Money
The confirmed callback is an immutable accounting snapshot for one deposit.
value_coin=value_forwarded_coin+fee_coinvalue_coinThe exact independent customer deposit.
value_forwarded_coinThe amount actually forwarded to address_out.
fee_coinCryptPays commission plus recovered miner, network, gas, and applicable Solana rent/ATA costs.
fee remains the actual contracted percentage applied to the payment. It may differ from the generic /info fee_percent because callback-domain and volume-tier pricing can apply. Do not interpret fee_coin as commission alone.
Delivery success
CryptPays considers only status code 200 successful. The response body is ignored.
*ok*Success because status is 200*ok*FailureDelivery recovery
Attempt 1 is immediate. Each later delay is measured from the preceding failed attempt, not from the original callback time.
After attempt 6 fails, automatic delivery stops. The same bounded policy covers non-200 responses, timeouts, connection failures, DNS failures, and signing failures. A signing failure sends no HTTP request. A destination that becomes unsafe is blocked and delivery stops rather than falling back to an unsigned or unsafe request.
UUID, payment accounting, price, conversion values, and merchant-visible stage.
X-CryptPay-Timestamp and X-CryptPay-Signature.
Confirmed retries continue to send result="sent", pending=0. A pending retry cannot be delivered after the lifecycle advances to confirmed. If the confirmed sequence is exhausted, an operator may manually retry the same confirmed snapshot; no merchant-triggered resend endpoint exists.
Duplicate safety
Retries are legitimate duplicate deliveries. Signature freshness proves request authenticity; it does not replace business idempotency.
One lifecycle
(uuid=X, pending=1)(uuid=X, pending=0)(uuid=X, pending=0)Do not discard every later event merely because its UUID was seen during pending. Track (uuid, pending) or enforce the allowed transition pending → sent. Fulfill the confirmed UUID transactionally only once.
sent events for that UUID as idempotent duplicates.coin before fulfillment.For BTC, LTC, BCH, DOGE, and ZEC, use the pair (txid_in, vout) as the on-chain deposit identity because one transaction can contain multiple outputs. The callback UUID remains the preferred lifecycle identifier.
Authenticity
CryptPays callbacks retain the compatibility headers shown below. Header names are case-insensitive.
X-CryptPay-SignatureStandard base64 encoding of the signature over the canonical bytes.
X-CryptPay-TimestampAccept only numeric timestamps where abs(now - timestamp) ≤ 300.
There is no unsigned fallback. If signing cannot be completed, CryptPays withholds the HTTP request.
https://api.cryptpays.com/api/pubkeyReturns the current PEM public key as text/plain. Retrieve and cache it appropriately; if verification unexpectedly fails, refresh the key according to your operational policy, but never bypass verification.
Byte-exact format
CryptPays signs these six components joined by one LF byte ("\n"), with no additional LF after the raw body.
v1{timestamp}{METHOD}{host}{path_and_query}{raw_body}| Component | Exact value |
|---|---|
timestamp | Exact decimal value from X-CryptPay-Timestamp. |
METHOD | Uppercase request method. |
host | Lowercase hostname only—no scheme and no port. |
path_and_query | Exact transmitted request target beginning with /. Do not decode, normalize, sort, or rebuild it. |
raw_body | Exact transmitted body bytes. Empty for GET. |
Illustrative canonical JSON POST
v1
1720000000
POST
merchant.example
/payments/callback?order_id=123
{"uuid":"9ab63121-41f8-4c56-9c28-c78e9b44b487","result":"sent","pending":0}If your application is behind Nginx, Cloudflare, a load balancer, or another reverse proxy, ensure it sees the original signed hostname and request target. Host rewrites, path changes, query reordering, percent-escape normalization, or query stripping will cause verification to fail. Use the original request-target representation provided by your server or framework.
Verification example
Fetch /api/pubkey out of band and cache it as cryptpay-public.pem. This handler verifies raw bytes before parsing.
<?php
function callbackHeader(string $name): ?string
{
foreach (getallheaders() as $headerName => $value) {
if (strcasecmp($headerName, $name) === 0) {
return trim((string) $value);
}
}
return null;
}
function rejectCallback(int $status = 401): never
{
http_response_code($status);
exit;
}
$timestamp = callbackHeader('X-CryptPay-Timestamp');
$signatureBase64 = callbackHeader('X-CryptPay-Signature');
if ($timestamp === null || !ctype_digit($timestamp) ||
abs(time() - (int) $timestamp) > 300 || $signatureBase64 === null) {
rejectCallback();
}
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? '');
$hostHeader = $_SERVER['HTTP_HOST'] ?? '';
$host = parse_url('http://' . $hostHeader, PHP_URL_HOST);
$requestTarget = $_SERVER['REQUEST_URI'] ?? '';
$input = file_get_contents('php://input');
if (!is_string($host) || $host === '' || $requestTarget === '' || $input === false) {
rejectCallback(400);
}
$host = strtolower($host); // hostname only; parse_url removes the port
$rawBody = $method === 'GET' ? '' : $input;
$canonical = implode("\n", [
'v1', $timestamp, $method, $host, $requestTarget, $rawBody,
]);
$signature = base64_decode($signatureBase64, true);
$publicKeyPem = file_get_contents(__DIR__ . '/cryptpay-public.pem');
$publicKey = is_string($publicKeyPem)
? openssl_pkey_get_public($publicKeyPem)
: false;
if ($signature === false || $publicKey === false ||
openssl_verify($canonical, $signature, $publicKey, OPENSSL_ALGO_SHA256) !== 1) {
rejectCallback();
}
// Parse only after verification.
$contentType = strtolower($_SERVER['CONTENT_TYPE'] ?? '');
if ($method === 'GET') {
$payload = $_GET;
} elseif (str_contains($contentType, 'application/json')) {
$payload = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
} else {
parse_str($rawBody, $payload);
}
// Transactionally validate order/reference + coin, allow pending -> sent,
// and fulfill a confirmed UUID only once before acknowledging it.
processVerifiedCallbackIdempotently($payload);
http_response_code(200); // Body is optional and ignored by CryptPays.Verification example
Install the raw-body middleware before JSON or form parsers. The example uses built-in node:crypto for verification and a cached PEM file.
import express from 'express';
import { readFileSync } from 'node:fs';
import { verify } from 'node:crypto';
const app = express();
const publicKeyPem = readFileSync('./cryptpay-public.pem', 'utf8');
// Register before express.json() or express.urlencoded().
app.all('/payments/callback', express.raw({ type: () => true }), async (req, res) => {
const timestamp = req.get('X-CryptPay-Timestamp') ?? '';
const signatureBase64 = req.get('X-CryptPay-Signature') ?? '';
if (!/^\d+$/.test(timestamp) ||
Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > 300 ||
!/^[A-Za-z0-9+/]+={0,2}$/.test(signatureBase64) ||
signatureBase64.length % 4 !== 0) {
return res.sendStatus(401);
}
const method = req.method.toUpperCase();
const hostHeader = req.get('host') ?? '';
let host;
try {
host = new URL(`http://${hostHeader}`).hostname.toLowerCase();
} catch {
return res.sendStatus(400);
}
const requestTarget = req.originalUrl; // exact path + query
const rawBody = method === 'GET'
? Buffer.alloc(0)
: (Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0));
const canonical = Buffer.concat([
Buffer.from(`v1\n${timestamp}\n${method}\n${host}\n${requestTarget}\n`, 'utf8'),
rawBody,
]);
const signature = Buffer.from(signatureBase64, 'base64');
if (!verify('RSA-SHA256', canonical, publicKeyPem, signature)) {
return res.sendStatus(401);
}
// Parse only after verification.
const contentType = (req.get('content-type') ?? '').toLowerCase();
let payload;
if (method === 'GET') {
payload = Object.fromEntries(
new URL(requestTarget, `http://${hostHeader}`).searchParams,
);
} else if (contentType.includes('application/json')) {
payload = JSON.parse(rawBody.toString('utf8'));
} else {
payload = Object.fromEntries(new URLSearchParams(rawBody.toString('utf8')));
}
// Persist lifecycle/idempotency transactionally; do not use an in-memory Set.
await processVerifiedCallbackIdempotently(payload);
return res.sendStatus(200);
});Confirmed snapshot
price is an optional USDT quote captured when the confirmed accounting snapshot is created. It is immutable across automatic retries and operator resend, and delivery performs no new price lookup.
price and both conversion fields are omitted—not sent as fake zero or null values.convert=1 adds conversion fields only when a valid confirmed price exists.{
"price": "150.00000000",
"value_coin_convert": "{\"USDT\":\"300.00\"}",
"value_forwarded_coin_convert": "{\"USDT\":\"295.50\"}"
}Chain-specific edges
The main lifecycle and accounting fields are chain-neutral. Handle confirmation and incoming transaction identity differences at the edges.
txid_in is the bare incoming transaction hash. vout is a separate integer output index. Use (txid_in, vout) for on-chain deposit identity; confirmations describe the incoming customer deposit, never the outbound forward.
Pending/not final uses confirmations=0; confirmed/final uses confirmations=1. Slot distance is not exposed. Every independent deposit has its own UUID and callback lifecycle.
Across families, build around uuid, coin, result, pending, value_coin, value_forwarded_coin, fee, fee_coin, confirmations, txid_in, and txid_out, plus vout for UTXO.
Production checklist
coin.result="done" event.