Документация описывает фактический wire contract сервиса: H2H cashier API,
Hosted Payment Page, подписи HMAC, статусы, DTO, browser callbacks и полный
ERC-20 flow от создания сессии до settlement через Chain Listener.
Base URLhttps://<cashier-domain>
AuthHMAC-SHA256 S2S
MVP networkEthereum / Sepolia
TokensUSDT, USDC
Merchant backend
HMAC
Cashier API
wallet_action
Player wallet
event
Chain Listener
Текущая реализация: merchant получает outcome через
server-to-server webhook (подписанный, с retry),
а также через polling статуса, HPP redirect или iframe postMessage.
Integration Modes
H2H direct
Казино держит API key на своем backend, подписывает server-to-server запросы и рендерит JSON UI schema своим generic renderer.
Browser never sees API secret.
Merchant UI owns styling and wallet forwarding.
Uses /v1/cashier/init, /step, /status.
Hosted Payment Page
Merchant backend создает payment link, а игрок платит на странице Cashier domain: redirect или modal iframe.
Server creates link via /v1/hpp/sessions.
Browser calls token-scoped /v1/hpp/*.
Optional /hpp/sdk.js opens modal iframe.
Auth & Signatures
Every merchant-facing endpoint uses the same API key and the same signature scheme:
deposits (/v1/cashier/*), payouts (/v1/payouts/*), balance
(/v1/balance) and HPP session creation. Merchant identity is derived from the verified
API key, not from the request body. The key pair (API_KEY_ID + API_KEY_SECRET)
is issued by the platform operator from the admin panel — one active key per merchant; issuing a new
one rotates (revokes) the previous. The same secret also signs the
webhooks we send to you, so a single credential pair covers both directions.
Header
Required
Description
X-Api-Key
yes
Public key id issued to merchant.
X-Timestamp
yes
Unix seconds. Rejected outside ±5 minutes.
X-Nonce
yes
Fresh unique value per request (e.g. 16 random bytes hex). Rejected if reused within the 5-minute window (anti-replay).
METHOD is upper-case. path is only URL path, for example
/v1/cashier/init. Body hash is calculated over the exact bytes sent.
nonce is the same value you send in X-Nonce — generate a new one
for every request; the server rejects a repeated nonce within the freshness window.
What goes into the canonical string, per operation
The scheme never changes — only METHOD, path and the body bytes differ:
Operation
METHOD
path
body
Deposit — create session
POST
/v1/cashier/init
request JSON
Deposit — step / status
POST
/v1/cashier/step, /v1/cashier/status
request JSON
Payout — create
POST
/v1/payouts
request JSON
Payout — authorize / status
POST
/v1/payouts/authorize, /v1/payouts/status
request JSON
Balance
GET
/v1/balance
empty — hash of zero bytes: e3b0c44298fc1c14…b855
HPP — create session
POST
/v1/hpp/sessions
request JSON
Inbound webhooks are the reverse direction and use a simpler canonical string
(timestamp\nbody, no nonce/method/path) — see
verifying webhooks.
import crypto from "node:crypto";
function sign(secret, ts, nonce, method, path, body) {
const bodyHash = crypto.createHash("sha256").update(body).digest("hex");
const canonical = [ts, nonce, method.toUpperCase(), path, bodyHash].join("\n");
return crypto.createHmac("sha256", secret).update(canonical).digest("hex");
}
// const nonce = crypto.randomBytes(16).toString("hex"); // fresh per request; send as X-Nonce
nonce, _ := auth.NewNonce() // fresh per request; send as X-Nonce
sig := auth.Sign(
[]byte(os.Getenv("API_KEY_SECRET")),
time.Now().Unix(),
nonce,
http.MethodPost,
"/v1/hpp/sessions",
body,
)
# Build X-Signature in your backend, then send:
curl -X POST "$BASE_URL/v1/cashier/init" \
-H "Content-Type: application/json" \
-H "X-Api-Key: $API_KEY_ID" \
-H "X-Timestamp: $TS" \
-H "X-Nonce: $NONCE" \
-H "X-Signature: $SIG" \
-d '{"player_id":"player-123","external_order_id":"dep-1001"}'
Auth can be disabled only for local development with AUTH_ENABLED=false.
Production must use real API_KEY_ID, API_KEY_SECRET, and optionally ALLOWED_CIDRS.
Select network. Client submits select_network with value such as eth.
Select token. Client submits select_token, currently USDT or USDC.
Connect wallet. Renderer requests accounts and submits wallet_connected with EVM address.
Enter amount. Client submits submit_amount with positive decimal token amount.
Approve. Server returns wallet_action for ERC-20 approve(router, amount). Submit resulting tx hash with approval_submitted.
Pay. Server returns wallet_action for PaymentRouter.pay(token, merchant, amount, orderRef, maxFeeBps). Submit tx hash with signature_submitted.
Processing. Chain Listener observes confirmations and settles the session to completed or failed.
Step
Expected action
Value
Next
network_select
select_network
eth
token_select
token_select
select_token
USDT, USDC
connect_wallet
connect_wallet
wallet_connected
0x + 40 hex chars
enter_amount
enter_amount
submit_amount
positive decimal
awaiting_approval
awaiting_approval
approval_submitted
0x + 64 hex chars
awaiting_payment
awaiting_payment
signature_submitted
0x + 64 hex chars
processing
processing
poll or status polling
empty
processing, completed, failed
Renderer should wait until the approval transaction is mined before sending the payment transaction.
Otherwise transferFrom can revert because allowance is not visible yet.
Payouts (Withdrawals)
Payouts are non-custodial: the platform never holds merchant funds and never
signs on the merchant's behalf. We only build an EIP-712 payout order; the merchant signs
it with their own wallet and we relay the signed order to the on-chain router. Payouts are recorded
in a dedicated payouts ledger, separate from deposits (cashier sessions), so withdrawals
and deposits are never mixed.
All payout endpoints are signed with your API key like any other request (see
Auth & Signatures). Note the two signatures play different roles: the
HMAC request signature authenticates the API call, while the
EIP-712 wallet signature authorizes moving the funds — a leaked API key alone can
never move money.
Prerequisite: payouts are pulled from your settlement wallet via ERC-20
transferFrom, so the wallet must first grant the PayoutRouter an
ERC-20 allowance (approve). Without it every payout fails on-chain.
Endpoints
POST/v1/payoutsHMAC
Creates a payout order. The merchant is derived from the API key; external_id is the
merchant's own idempotency key (at most one payout per (merchant, external_id)). The
source wallet and router are resolved from merchant config server-side — never sent by the client.
The response contains typed_data, the EIP-712 payload to sign with
eth_signTypedData_v4.
Submits the merchant's EIP-712 signature over the order. The platform verifies the signature
recovers to the configured merchant wallet, moves the payout to authorized, and the
relay dispatcher broadcasts router.payout(order, sig) off the request path.
Read-only view of a payout; safe to poll while it is relayed and confirmed. Only your own payouts are visible — another merchant's id returns not_found.
Present only in DEV_MODE. Forces settlement of a submitted payout (success defaults to true) so the flow can be exercised without a live chain.
{ "payout": "pay_abc123", "success": true }
Payout flow
Approve (one-time). The settlement wallet grants the PayoutRouter an ERC-20 allowance for the payout token — see Payout allowance. Done once per token, not per payout.
Create. Merchant backend calls POST /v1/payouts and receives the payout id plus the EIP-712 typed_data.
Sign. Merchant signs typed_data with their wallet (eth_signTypedData_v4). The platform never sees the merchant's private key.
Authorize. Merchant submits the signature to POST /v1/payouts/authorize; the platform verifies it recovers to the merchant wallet.
Relay. The dispatcher broadcasts the signed router call; the payout moves to submitted with a tx_hash.
Confirm. The confirmer observes on-chain confirmations and settles the payout to completed or failed.
A payout order is signable for 30 minutes (its EIP-712 deadline).
Calling /v1/payouts/authorize after the deadline returns 410 expired —
create a new payout with a fresh external_id. Repeating POST /v1/payouts
with the same external_id is idempotent: it returns the existing payout with its
original order rebuilt, never a second order.
Payout statuses
Status
Source
Description
pending
create
Order built and awaiting the merchant's signature.
The PayoutRouter never holds funds: when a signed payout order is relayed, the router pulls the
tokens straight from your settlement wallet with ERC-20 transferFrom.
A transferFrom only succeeds if that wallet has previously granted the router an
allowance with a standard ERC-20 approve(router, cap). This is a
one-time on-chain transaction per token (not per payout), sent from the settlement
wallet itself.
What to approve
Parameter
Value
From (tx sender)
Your settlement wallet — the same address payouts are signed with (source of the order).
Contract
The token being paid out (USDT or USDC contract address).
spender
The PayoutRouter address for your merchant/network. It is returned in every payout's typed_data.domain.verifyingContract, and is also visible in the admin panel merchant config.
amount (cap)
Your choice: a working cap you top up periodically, or unlimited (MaxUint256). The cap only limits the router's total pull; each payout still requires your EIP-712 signature.
import { ethers } from "ethers";
const erc20 = new ethers.Contract(TOKEN_ADDRESS, [
"function approve(address spender, uint256 amount) returns (bool)",
"function allowance(address owner, address spender) view returns (uint256)",
], settlementWalletSigner);
// Cap of 100,000 USDT (6 decimals). Use ethers.MaxUint256 for unlimited.
await erc20.approve(PAYOUT_ROUTER_ADDRESS, ethers.parseUnits("100000", 6));
// Check the remaining allowance at any time:
await erc20.allowance(SETTLEMENT_WALLET, PAYOUT_ROUTER_ADDRESS);
# approve a 100,000 USDT cap (6 decimals) from the settlement wallet
cast send $TOKEN_ADDRESS \
"approve(address,uint256)" $PAYOUT_ROUTER_ADDRESS 100000000000 \
--rpc-url $RPC_URL --private-key $SETTLEMENT_WALLET_KEY
# check remaining allowance
cast call $TOKEN_ADDRESS \
"allowance(address,address)(uint256)" $SETTLEMENT_WALLET $PAYOUT_ROUTER_ADDRESS \
--rpc-url $RPC_URL
1. Open the token contract (USDT/USDC) on Etherscan → "Write Contract".
2. Connect the settlement wallet.
3. Call approve(spender, amount):
spender = PayoutRouter address (typed_data.domain.verifyingContract)
amount = cap in base units (USDT/USDC: 6 decimals)
4. Confirm the transaction and wait for it to mine.
Operational notes
Insufficient allowance fails the payout on-chain: the relay transaction reverts and the payout settles as failed. Keep the allowance (and the wallet's token balance) above your expected payout volume.
USDT quirk: USDT reverts on approve when changing a non-zero allowance to another non-zero value. To change the cap, first approve(router, 0), then approve the new cap. USDC does not have this restriction.
Revocation: you can cut the platform off entirely at any time with approve(router, 0) — the router is immutable (no owner, no upgrade) and can never move funds you did not sign for, allowance or not.
Allowance is per token, per router, per network. Approving USDT does not cover USDC; a new router address needs a fresh approve.
Security model recap: the ERC-20 allowance sets the ceiling the router may ever pull;
each individual payout still requires your EIP-712 signature over
{token, merchant, recipient, amount, nonce, deadline}. Both are required — neither the
platform nor a stolen API key can move funds with only one of them.
Balance
Read the on-chain balance of the merchant's settlement wallet across the configured
tokens. The wallet is resolved from merchant config server-side; the merchant is derived from the API
key. Read-only. Balances are read live from chain (ERC-20 balanceOf) and require the
Cashier API to have an RPC endpoint configured — otherwise each entry carries an error.
GET/v1/balanceHMAC
Sign it like any other H2H request (canonical uses GET, the path, and the empty-body hash).
amount is the human-decimal balance; raw is the integer base-unit value. A
per-token error field appears instead of amount when that token's read failed
(one token failing never fails the whole response). The same figures are shown per merchant in the
admin panel (Merchants → Баланс).
Status Codes
HTTP and machine error codes
HTTP
error.code
Meaning
400
validation
Malformed JSON, missing session/token, invalid wallet, amount, or tx hash.
400
invalid_action
Action does not match current step, or terminal flow was advanced.
400
unsupported
Unsupported network, token, or chain family.
401
unauthorized
Missing auth headers, unknown API key, stale timestamp, invalid signature.
403
forbidden
Client IP is not in the credential allowlist.
404
not_found
Session, HPP token, merchant config, or dev endpoint was not found.
409
conflict
Session already exists.
410
expired
Hosted payment token TTL elapsed.
500
internal
Unexpected internal, storage, or infrastructure error.
Payment/order statuses
Status
Source
Description
pending
session
Session created, no payment tx submitted yet.
processing
session
Payment tx submitted; listener waits for confirmations.
Settlement marked unsuccessful. Real reverted-payment detection is a future extension.
Callbacks
H2H status polling
For direct H2H integration, merchant backend/frontend polls POST /v1/cashier/status.
This endpoint is read-only and safe to poll while the listener settles in the background.
HPP redirect
If return_url was provided in /v1/hpp/sessions, hosted page redirects after a terminal step:
When a deposit session or a payout (withdrawal) settles, the platform
delivers a signed webhook to the merchant's callback URL (configured per merchant in the
admin panel). Delivery is enqueued to a persistent outbox and sent off the request path by a
dispatcher, so a slow or failing merchant endpoint never blocks settlement. A delivery is considered
successful on any 2xx response; otherwise it is retried with exponential backoff
(30s → 1m → 2m → … → 1h) up to 8 attempts before being marked failed.
An operator can re-enqueue a fresh delivery with Resend callback in the admin panel
(available on both the deposit and the withdrawal detail).
Per-delivery id; stable across retries of that delivery attempt.
X-Idempotency-Key
Stable key for the logical event (derived from session + event). Identical across retries and across a settlement and a later operator resend of the same event. Deduplicate on this to credit a deposit exactly once.
X-Webhook-Timestamp
Unix seconds when the attempt was signed; part of the signed value.
X-Webhook-Signature
Lowercase hex HMAC-SHA256 (omitted when signing is disabled in dev).
X-Webhook-Key-Id
Present when the webhook is signed with your merchant API key secret (the same credential you sign requests with); carries that key's id. Absent on the legacy shared-secret path.
A payout webhook (payout.completed / payout.failed) carries the
payout instead of the session — payout_id and recipient in place of
session_id / player_id / order_ref. external_id is
your own payout reference. The X-Idempotency-Key is derived from payout_id + event,
so it is stable across retries and an operator resend — deduplicate on it to process a payout exactly once.
The signature binds the timestamp and the exact body, so a captured payload cannot be replayed with a
different clock. When your merchant has an API key issued from the admin panel, webhooks are signed with
that key's secret — the same API_KEY_SECRET you use to sign requests to us —
and X-Webhook-Key-Id names the key. Without one, the platform-wide WEBHOOK_SECRET
is used. Recompute and compare in constant time:
import crypto from "node:crypto";
// req: the incoming webhook request (raw body BEFORE any JSON parsing)
function verifyWebhook(req, rawBody) {
// Signed with your merchant API key when X-Webhook-Key-Id is present,
// with the shared platform secret otherwise.
const secret = req.headers["x-webhook-key-id"]
? process.env.API_KEY_SECRET
: process.env.WEBHOOK_SECRET;
const ts = req.headers["x-webhook-timestamp"];
const signature = req.headers["x-webhook-signature"] || "";
const canonical = `${ts}\n${rawBody}`;
const expected = crypto.createHmac("sha256", secret).update(canonical).digest("hex");
return expected.length === signature.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
Set WEBHOOK_SECRET on the Cashier API service to enable signing. If it is empty, webhooks are
sent unsigned (X-Webhook-Signature omitted) — dev only, never in production.
On-Chain Settlement
PaymentRouter is non-custodial: it pulls fee and merchant payout directly from payer via ERC-20
transferFrom. The contract does not hold balances.
Contract method
Current backend use
Description
pay(token, merchant, amount, orderRef, maxFeeBps)
yes
Used after prior ERC-20 approve. Works with USDT-style tokens. maxFeeBps caps the fee the payer authorizes.
payWithPermit(...)
contract only
Supported by contract for EIP-2612 tokens, not generated by backend yet.
orderRef is keccak256(sessionID). Listener matches by orderRef first,
then falls back to transaction hash. It scans only confirmed blocks and applies amount-based confirmation depth.