psfp5 Developer Docs

Merchant integration reference

psfp5 Payments API

Документация описывает фактический wire contract сервиса: H2H cashier API, Hosted Payment Page, подписи HMAC, статусы, DTO, browser callbacks и полный ERC-20 flow от создания сессии до settlement через Chain Listener.

Base URL https://<cashier-domain>
Auth HMAC-SHA256 S2S
MVP network Ethereum / Sepolia
Tokens USDT, 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.

HeaderRequiredDescription
X-Api-KeyyesPublic key id issued to merchant.
X-TimestampyesUnix seconds. Rejected outside ±5 minutes.
X-NonceyesFresh unique value per request (e.g. 16 random bytes hex). Rejected if reused within the 5-minute window (anti-replay).
X-SignatureyesLowercase hex HMAC-SHA256 of canonical string.

Canonical string

<timestamp>\n<nonce>\n<METHOD>\n<path>\n<hex(sha256(body))>

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:

OperationMETHODpathbody
Deposit — create sessionPOST/v1/cashier/initrequest JSON
Deposit — step / statusPOST/v1/cashier/step, /v1/cashier/statusrequest JSON
Payout — createPOST/v1/payoutsrequest JSON
Payout — authorize / statusPOST/v1/payouts/authorize, /v1/payouts/statusrequest JSON
BalanceGET/v1/balanceempty — hash of zero bytes: e3b0c44298fc1c14…b855
HPP — create sessionPOST/v1/hpp/sessionsrequest 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.

Responses

Step response

{
  "step": "awaiting_payment",
  "session": "sess_abc",
  "components": [
    { "type": "heading", "text": "Крок 2 з 2: оплата" },
    { "type": "status", "status": "info", "text": "Підпишіть транзакцію оплати у гаманці." }
  ],
  "wallet_action": {
    "method": "eth_sendTransaction",
    "chain_id": "0xaa36a7",
    "params": [
      { "from": "0x...", "to": "0x...", "data": "0x...", "value": "0x0", "chainId": "0xaa36a7" }
    ]
  },
  "submit_action": "signature_submitted"
}
FieldTypeDescription
stepstringCurrent step id.
sessionstringOpaque cashier session id.
componentsarraySmall server-driven UI schema.
wallet_actionobject|nullOpaque wallet RPC to forward to window.ethereum.request.
submit_actionstring|nullAction to submit with wallet tx hash after wallet resolves.

Error envelope

{
  "error": {
    "code": "validation",
    "message": "invalid wallet address"
  }
}

Endpoints

Health

GET/healthz
{ "status": "ok" }

H2H Cashier API

POST/v1/cashier/initHMAC

Creates a direct cashier session. Body is optional, but merchant references should be sent when available.

{
  "player_id": "player-123",
  "external_order_id": "deposit-1001",
  "callback_url": "https://merchant.example/webhooks/psfp5"
}
FieldRequiredDescription
player_idnoYour player reference; echoed back in the settlement webhook.
external_order_idnoYour order reference; echoed back in the settlement webhook.
callback_urlnoPer-session override of the webhook target configured for your merchant in the admin panel. Empty falls back to the merchant default.
POST/v1/cashier/stepHMAC
{
  "session": "sess_abc",
  "action": "select_network",
  "value": "eth"
}
POST/v1/cashier/statusHMAC
{ "session": "sess_abc" }

Hosted Payment Page API

POST/v1/hpp/sessionsHMAC

Creates a hosted payment session and returns a one-time bearer token link.

{
  "player_id": "player-123",
  "external_order_id": "deposit-1001",
  "return_url": "https://merchant.example/payments/return",
  "amount": "250.75",
  "network": "eth",
  "token": "USDT",
  "callback_url": "https://merchant.example/webhooks/psfp5"
}
FieldRequiredDescription
player_id, external_order_idnoYour references; echoed back in the settlement webhook.
return_urlnoBrowser redirect target after a terminal step (see Callbacks).
amountnoPrefill: fixes the deposit amount so the hosted page skips the amount step and the player is not asked again.
network, tokennoPrefill: preselects the network/token so the hosted page resumes past those steps.
callback_urlnoPer-session override of the merchant's configured webhook target.
{
  "session": "sess_abc",
  "token": "raw-token-returned-once",
  "url": "https://pay.example.com/p/raw-token-returned-once",
  "expires_at": "2026-07-04T12:30:00Z"
}
GET/p/:tokenpublic

Hosted page rendered by the Cashier service. Invalid tokens still render the page; token validation happens inside token-scoped API calls.

POST/v1/hpp/statustoken
{ "token": "raw-token-from-url" }
POST/v1/hpp/steptoken
{
  "token": "raw-token-from-url",
  "action": "submit_amount",
  "value": "250.75"
}
GET/hpp/sdk.jspublic

Payment Flow

  1. Init session. H2H calls /v1/cashier/init; HPP calls /v1/hpp/sessions.
  2. Select network. Client submits select_network with value such as eth.
  3. Select token. Client submits select_token, currently USDT or USDC.
  4. Connect wallet. Renderer requests accounts and submits wallet_connected with EVM address.
  5. Enter amount. Client submits submit_amount with positive decimal token amount.
  6. Approve. Server returns wallet_action for ERC-20 approve(router, amount). Submit resulting tx hash with approval_submitted.
  7. Pay. Server returns wallet_action for PaymentRouter.pay(token, merchant, amount, orderRef, maxFeeBps). Submit tx hash with signature_submitted.
  8. Processing. Chain Listener observes confirmations and settles the session to completed or failed.
StepExpected actionValueNext
network_selectselect_networkethtoken_select
token_selectselect_tokenUSDT, USDCconnect_wallet
connect_walletwallet_connected0x + 40 hex charsenter_amount
enter_amountsubmit_amountpositive decimalawaiting_approval
awaiting_approvalapproval_submitted0x + 64 hex charsawaiting_payment
awaiting_paymentsignature_submitted0x + 64 hex charsprocessing
processingpoll or status pollingemptyprocessing, 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.

{
  "external_id": "withdrawal-5001",
  "network": "eth",
  "token": "USDT",
  "amount": "125.50",
  "recipient": "0xRecipientWalletAddress000000000000000000"
}
{
  "payout": "pay_abc123",
  "status": "pending",
  "network": "eth",
  "token": "USDT",
  "amount": "125.50",
  "recipient": "0xRecipientWalletAddress000000000000000000",
  "typed_data": { "domain": { }, "types": { }, "message": { } }
}
POST/v1/payouts/authorizeHMAC

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.

{
  "payout": "pay_abc123",
  "signature": "0x<65-byte EIP-712 signature>"
}
POST/v1/payouts/statusHMAC

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.

{ "payout": "pay_abc123" }
{
  "payout": "pay_abc123",
  "status": "submitted",
  "network": "eth",
  "token": "USDT",
  "amount": "125.50",
  "recipient": "0xRecipientWalletAddress000000000000000000",
  "tx_hash": "0x…"
}
POST/v1/payouts/dev/confirmdev-only

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

  1. 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.
  2. Create. Merchant backend calls POST /v1/payouts and receives the payout id plus the EIP-712 typed_data.
  3. Sign. Merchant signs typed_data with their wallet (eth_signTypedData_v4). The platform never sees the merchant's private key.
  4. Authorize. Merchant submits the signature to POST /v1/payouts/authorize; the platform verifies it recovers to the merchant wallet.
  5. Relay. The dispatcher broadcasts the signed router call; the payout moves to submitted with a tx_hash.
  6. 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

StatusSourceDescription
pendingcreateOrder built and awaiting the merchant's signature.
authorizedauthorizeSignature verified; awaiting relay to the chain.
submitteddispatcherRouter call broadcast; awaiting on-chain confirmations.
completedconfirmerPayout reached required confirmation depth.
failedconfirmer/dev simulatorRelay or on-chain execution was unsuccessful.

Payout Allowance (ERC-20 Approve)

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

ParameterValue
From (tx sender)Your settlement wallet — the same address payouts are signed with (source of the order).
ContractThe token being paid out (USDT or USDC contract address).
spenderThe 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).

{
  "wallet": "0xMerchantSettlementWallet00000000000000000",
  "balances": [
    { "network": "eth", "token": "USDT", "address": "0x…", "decimals": 6, "amount": "1250.5", "raw": "1250500000" },
    { "network": "eth", "token": "USDC", "address": "0x…", "decimals": 6, "amount": "0" }
  ]
}

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

HTTPerror.codeMeaning
400validationMalformed JSON, missing session/token, invalid wallet, amount, or tx hash.
400invalid_actionAction does not match current step, or terminal flow was advanced.
400unsupportedUnsupported network, token, or chain family.
401unauthorizedMissing auth headers, unknown API key, stale timestamp, invalid signature.
403forbiddenClient IP is not in the credential allowlist.
404not_foundSession, HPP token, merchant config, or dev endpoint was not found.
409conflictSession already exists.
410expiredHosted payment token TTL elapsed.
500internalUnexpected internal, storage, or infrastructure error.

Payment/order statuses

StatusSourceDescription
pendingsessionSession created, no payment tx submitted yet.
processingsessionPayment tx submitted; listener waits for confirmations.
completedlistenerPaymentProcessed reached required confirmation depth.
failedlistener/dev simulatorSettlement 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:

https://merchant.example/payments/return?status=completed
https://merchant.example/payments/return?status=failed

Iframe postMessage

When embedded through /hpp/sdk.js, the hosted page sends lifecycle events to the parent window.

{
  "source": "crypto-cashier",
  "type": "cashier:completed"
}

Event types: cashier:ready, cashier:completed, cashier:failed.

<script src="https://pay.example.com/hpp/sdk.js"></script>
<script>
CryptoCashier.open({
  url,
  onCompleted: () => location.reload(),
  onFailed: () => alert("Payment failed"),
  onClose: () => {}
});
</script>

Server-to-server webhooks

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).

Events

EventWhen
payment.completedDeposit session reached required confirmation depth on-chain.
payment.failedDeposit session settled as failed.
payout.completedPayout relay tx confirmed successfully on-chain.
payout.failedPayout relay tx reverted / settled as failed.

Request headers

HeaderDescription
X-Webhook-EventEvent name, e.g. payment.completed.
X-Webhook-IdPer-delivery id; stable across retries of that delivery attempt.
X-Idempotency-KeyStable 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-TimestampUnix seconds when the attempt was signed; part of the signed value.
X-Webhook-SignatureLowercase hex HMAC-SHA256 (omitted when signing is disabled in dev).
X-Webhook-Key-IdPresent 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.

Payload

{
  "event": "payment.completed",
  "session_id": "sess_abc",
  "merchant_id": "demo",
  "player_id": "player-123",
  "external_order_id": "dep-1001",
  "network": "eth",
  "token": "USDT",
  "amount": "125.50",
  "status": "completed",
  "tx_hash": "0x…",
  "order_ref": "0x…",
  "occurred_at": "2026-07-05T20:55:06Z"
}

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.

{
  "event": "payout.completed",
  "payout_id": "po_abc",
  "merchant_id": "demo",
  "external_id": "wd-1001",
  "network": "eth",
  "token": "USDT",
  "amount": "50.00",
  "recipient": "0x…",
  "status": "completed",
  "tx_hash": "0x…",
  "occurred_at": "2026-07-05T20:55:06Z"
}

Verifying the signature

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:

secret    = X-Webhook-Key-Id present ? API_KEY_SECRET : WEBHOOK_SECRET
canonical = "<X-Webhook-Timestamp>\n<raw request body>"
expected  = hex(HMAC-SHA256(secret, canonical))
// accept only if expected === X-Webhook-Signature
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 methodCurrent backend useDescription
pay(token, merchant, amount, orderRef, maxFeeBps)yesUsed after prior ERC-20 approve. Works with USDT-style tokens. maxFeeBps caps the fee the payer authorizes.
payWithPermit(...)contract onlySupported by contract for EIP-2612 tokens, not generated by backend yet.

PaymentProcessed event

PaymentProcessed(
  bytes32 indexed orderRef,
  address indexed merchant,
  address indexed token,
  address payer,
  uint256 amount,
  uint256 fee
)

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.