time2pay Developer Docs

API reference

Deposits

Taking money in, both ways: driving the flow yourself or handing the player a link.

Before you start

Two ways to take a deposit through a session, with complete code on Choose a flow. Both settle the same way and both notify you the same way; what differs is who renders the payment. Sessions run on EVM networks.

  • Self-hosted — your backend drives every step, your frontend renders it.
  • Hosted page — one signed call gives you a link; we render the rest.

A third way needs no session at all: a standing wallet address per player, held by the platform. It is the only option on networks without a payment router, such as Tron.

Everything below is signed as described in Signing requests, except where an endpoint is marked no signature — those are called by the hosted page in the player's browser, authenticated by the one-time token in its URL. You never call them yourself.

In the hosted flow that leaves you exactly one call: POST /v1/hpp/sessions for the link. /p/{token} is the page that link opens — the address you redirect to or set as an iframe src — and /hpp/sdk.js is the script that opens it in a modal for you. /v1/hpp/step and /v1/hpp/status are what that page calls on itself while the player pays; they are documented so nothing about the flow is hidden, not because an integration has to touch them.

external_order_id is your idempotency key. Send it. A create repeated with the same reference and the same parameters returns the order you already have; without it, a retry after a timeout opens a second session against the same deposit.

Deposits · self-hosted

Server-to-server deposit flow. Your backend drives every step and your frontend renders the returned UI schema. The API secret never reaches a browser.

POST /v1/cashier/init signed

Open a deposit session

Starts a session and returns its first step. Everything in the body is optional, but sending external_order_id is what makes a retry safe.

Prefilled values are validated here, because the steps that would normally check them are the ones being skipped. With no prefill the session starts at network_select; with network at connect_wallet, and the coin picker follows once a wallet is connected; with both at connect_wallet and no picker; add amount and the player's only remaining action is connecting a wallet. See prefilling.

Request body

FieldTypeRequiredDescription
player_id string no Your player reference. Echoed back in the settlement webhook.
external_order_id string no Your order reference, and the idempotency key: repeating a create with the same reference and the same parameters returns the original session instead of opening a second one. Reusing it with different parameters is a conflict.
callback_url string (uri) no Per-session override of the webhook target configured for your merchant. Empty falls back to the merchant default.
amount string no Optional prefill in whole units, e.g. "125.50". Skips the enter_amount step.
network string no Optional prefill of the network, when you already know which one the player is paying on. Skips the network_select step.
token string no Optional prefill of the asset. Requires network; sending both starts the session at connect_wallet.
language string no The language the step views are written in, as an ISO 639-1 code. A region is ignored, so "en-GB" is English. Unsupported or missing falls back to English rather than failing the call. One of: en, ru, es

Request

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":"deposit-1001","amount":"125.50","network":"eth","token":"USDT"}'
res, err := client.Call(ctx, "POST", "/v1/cashier/init", `{
	"player_id": "player-123",
	"external_order_id": "deposit-1001",
	"amount": "125.50",
	"network": "eth",
	"token": "USDT"
}`)
$res = $client->call('POST', '/v1/cashier/init', [
    'player_id' => 'player-123',
    'external_order_id' => 'deposit-1001',
    'amount' => '125.50',
    'network' => 'eth',
    'token' => 'USDT',
]);
const res = await client.call("POST", "/v1/cashier/init", {
    "player_id": "player-123",
    "external_order_id": "deposit-1001",
    "amount": "125.50",
    "network": "eth",
    "token": "USDT"
});
res = client.call("POST", "/v1/cashier/init", {
    "player_id": "player-123",
    "external_order_id": "deposit-1001",
    "amount": "125.50",
    "network": "eth",
    "token": "USDT",
})
const res = await client.call("POST", "/v1/cashier/init", {
    "player_id": "player-123",
    "external_order_id": "deposit-1001",
    "amount": "125.50",
    "network": "eth",
    "token": "USDT"
});

The client is the one on Signing requests: it signs, sends, and turns an error into an exception carrying its code.

Response 200

The session's first step.

{
  "step": "connect_wallet",
  "session": "sess_9f2c1e",
  "components": [
    {
      "type": "heading",
      "text": "Connect a wallet"
    },
    {
      "type": "wallet_connect",
      "action": "wallet_connected"
    }
  ]
}

Errors

400 unsupported Malformed JSON, an unknown network or token, a token sent without a network, or an amount that is not a positive number. Codes: validation, unsupported.
{
  "error": {
    "code": "unsupported",
    "message": "unsupported token \"DAI\" on \"eth\""
  }
}
401 unauthorized The signature, key, timestamp or nonce did not check out: a missing header, an unknown key id, a timestamp more than 5 minutes from ours, a signature that does not match, or a nonce already used inside the window. Do not retry without rebuilding the request — a retry needs a new timestamp, a new nonce and a new signature.
{
  "error": {
    "code": "unauthorized",
    "message": "invalid signature"
  }
}
403 forbidden The credential is valid but the caller's IP is not in the allowlist configured for your merchant. Not retryable.
{
  "error": {
    "code": "forbidden",
    "message": "client IP not allowed for this merchant"
  }
}
409 conflict This external_order_id already belongs to a session created with different parameters. Repeating a create with identical parameters is safe and returns the original session; changing the amount, player or token under a reference you already used is not, and is refused rather than silently resolved.
{
  "error": {
    "code": "conflict",
    "message": "external_order_id \"deposit-1001\" already belongs to session sess_9f2c1e with different parameters"
  }
}
503 unavailable A dependency we need to answer safely is degraded, so the request was refused rather than served wrongly. Transient: retry with backoff, using a fresh timestamp, nonce and signature.
{
  "error": {
    "code": "unavailable",
    "message": "cannot verify request nonce"
  }
}
POST /v1/cashier/step signed

Advance a deposit session

Submits the action the current step asked for and returns the next step. The action name and the shape of its value come from the step you are on — see the step machine.

Request body

FieldTypeRequiredDescription
session string yes Session id returned by init.
action string yes The action the current step expects, e.g. select_network.
value string no The action's value. Empty for actions that carry none, such as poll.

Request

curl -X POST "$BASE_URL/v1/cashier/step" \
  -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 '{"session":"sess_9f2c1e","action":"select_network","value":"eth"}'
res, err := client.Call(ctx, "POST", "/v1/cashier/step", `{
	"session": "sess_9f2c1e",
	"action": "select_network",
	"value": "eth"
}`)
$res = $client->call('POST', '/v1/cashier/step', [
    'session' => 'sess_9f2c1e',
    'action' => 'select_network',
    'value' => 'eth',
]);
const res = await client.call("POST", "/v1/cashier/step", {
    "session": "sess_9f2c1e",
    "action": "select_network",
    "value": "eth"
});
res = client.call("POST", "/v1/cashier/step", {
    "session": "sess_9f2c1e",
    "action": "select_network",
    "value": "eth",
})
const res = await client.call("POST", "/v1/cashier/step", {
    "session": "sess_9f2c1e",
    "action": "select_network",
    "value": "eth"
});

The client is the one on Signing requests: it signs, sends, and turns an error into an exception carrying its code.

Response 200

The step the session moved to.

{
  "step": "awaiting_payment",
  "session": "sess_9f2c1e",
  "components": [
    {
      "type": "status",
      "status": "info",
      "text": "Sign the payment in your wallet."
    }
  ],
  "wallet_action": {
    "method": "eth_sendTransaction",
    "chain_id": "0xaa36a7",
    "params": [
      {
        "from": "0x…",
        "to": "0x…",
        "data": "0x…",
        "value": "0x0"
      }
    ]
  },
  "submit_action": "signature_submitted"
}

Errors

400 invalid_action An action that does not belong to the current step, a terminal session being advanced, a malformed value such as an address or transaction hash of the wrong shape, or a payer wallet that does not hold enough of the token to cover the deposit. Codes: validation, invalid_action, insufficient_funds, insufficient_allowance, tx_reverted.
{
  "error": {
    "code": "invalid_action",
    "message": "action \"submit_amount\" is not valid at step \"connect_wallet\""
  }
}
401 unauthorized The signature, key, timestamp or nonce did not check out: a missing header, an unknown key id, a timestamp more than 5 minutes from ours, a signature that does not match, or a nonce already used inside the window. Do not retry without rebuilding the request — a retry needs a new timestamp, a new nonce and a new signature.
{
  "error": {
    "code": "unauthorized",
    "message": "invalid signature"
  }
}
403 forbidden The credential is valid but the caller's IP is not in the allowlist configured for your merchant. Not retryable.
{
  "error": {
    "code": "forbidden",
    "message": "client IP not allowed for this merchant"
  }
}
404 not_found No such session for your merchant. Another merchant's session id is indistinguishable from one that does not exist.
{
  "error": {
    "code": "not_found",
    "message": "session not found"
  }
}
503 unavailable A dependency we need to answer safely is degraded, so the request was refused rather than served wrongly. Transient: retry with backoff, using a fresh timestamp, nonce and signature.
{
  "error": {
    "code": "unavailable",
    "message": "cannot verify request nonce"
  }
}
POST /v1/cashier/status signed

Read the current step

Read-only and safe to poll while the chain listener settles in the background. Each poll is a separate signed request and therefore needs its own nonce.

Request body

FieldTypeRequiredDescription
session string yes

Request

curl -X POST "$BASE_URL/v1/cashier/status" \
  -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 '{"session":"sess_9f2c1e"}'
res, err := client.Call(ctx, "POST", "/v1/cashier/status", `{
	"session": "sess_9f2c1e"
}`)
$res = $client->call('POST', '/v1/cashier/status', [
    'session' => 'sess_9f2c1e',
]);
const res = await client.call("POST", "/v1/cashier/status", {
    "session": "sess_9f2c1e"
});
res = client.call("POST", "/v1/cashier/status", {
    "session": "sess_9f2c1e",
})
const res = await client.call("POST", "/v1/cashier/status", {
    "session": "sess_9f2c1e"
});

The client is the one on Signing requests: it signs, sends, and turns an error into an exception carrying its code.

Response 200

Where the session stands now.

{
  "step": "processing",
  "session": "sess_9f2c1e",
  "components": [
    {
      "type": "status",
      "status": "info",
      "text": "Confirmations: 5 of 12"
    }
  ]
}

Errors

401 unauthorized The signature, key, timestamp or nonce did not check out: a missing header, an unknown key id, a timestamp more than 5 minutes from ours, a signature that does not match, or a nonce already used inside the window. Do not retry without rebuilding the request — a retry needs a new timestamp, a new nonce and a new signature.
{
  "error": {
    "code": "unauthorized",
    "message": "invalid signature"
  }
}
403 forbidden The credential is valid but the caller's IP is not in the allowlist configured for your merchant. Not retryable.
{
  "error": {
    "code": "forbidden",
    "message": "client IP not allowed for this merchant"
  }
}
404 not_found No such session for your merchant.
{
  "error": {
    "code": "not_found",
    "message": "session not found"
  }
}
503 unavailable A dependency we need to answer safely is degraded, so the request was refused rather than served wrongly. Transient: retry with backoff, using a fresh timestamp, nonce and signature.
{
  "error": {
    "code": "unavailable",
    "message": "cannot verify request nonce"
  }
}

Deposits · hosted page

Your backend creates a payment link; the player pays on a page we host, reached by redirect or in an iframe. Only the link-creating call is signed — the browser calls are scoped to the one-time token in the URL.

POST /v1/hpp/sessions signed

Create a payment link

The only signed call in the hosted flow. Returns a one-time link to redirect the player to or load in an iframe.

The token is returned once and stored only as a hash, so it can never be read back. A repeat of the same order therefore carries a new token on the same session: a caller retrying a create never received the first link, so replacing it costs nothing.

Request body

FieldTypeRequiredDescription
player_id string no Your player reference. Echoed back in the settlement webhook.
external_order_id string no Your order reference, and the idempotency key: a repeat with the same parameters returns the same session carrying a fresh link. See the 409 response for the cases that are refused.
return_url string (uri) no Where the hosted page sends the player after a terminal step.
amount string no Optional prefill: fixes the deposit amount so the hosted page never asks for it.
network string no Optional prefill of the network, when you already know which one the player is paying on.
token string no Optional prefill of the asset. Requires network.
callback_url string (uri) no Per-session override of the merchant's configured webhook target.
language string no The language the hosted page is shown in, as an ISO 639-1 code. A region is ignored, so "en-GB" is English. Send the language your player is playing in: the page is reached by a redirect that carries nothing about them, so this is the only thing that decides it. Never a reason to refuse a deposit — an unsupported or missing language opens the page in English. One of: en, ru, es

Request

curl -X POST "$BASE_URL/v1/hpp/sessions" \
  -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":"deposit-1001","return_url":"https://merchant.example/payments/return","amount":"250.75","network":"eth","token":"USDT","language":"ru"}'
res, err := client.Call(ctx, "POST", "/v1/hpp/sessions", `{
	"player_id": "player-123",
	"external_order_id": "deposit-1001",
	"return_url": "https://merchant.example/payments/return",
	"amount": "250.75",
	"network": "eth",
	"token": "USDT",
	"language": "ru"
}`)
$res = $client->call('POST', '/v1/hpp/sessions', [
    'player_id' => 'player-123',
    'external_order_id' => 'deposit-1001',
    'return_url' => 'https://merchant.example/payments/return',
    'amount' => '250.75',
    'network' => 'eth',
    'token' => 'USDT',
    'language' => 'ru',
]);
const res = await client.call("POST", "/v1/hpp/sessions", {
    "player_id": "player-123",
    "external_order_id": "deposit-1001",
    "return_url": "https://merchant.example/payments/return",
    "amount": "250.75",
    "network": "eth",
    "token": "USDT",
    "language": "ru"
});
res = client.call("POST", "/v1/hpp/sessions", {
    "player_id": "player-123",
    "external_order_id": "deposit-1001",
    "return_url": "https://merchant.example/payments/return",
    "amount": "250.75",
    "network": "eth",
    "token": "USDT",
    "language": "ru",
})
const res = await client.call("POST", "/v1/hpp/sessions", {
    "player_id": "player-123",
    "external_order_id": "deposit-1001",
    "return_url": "https://merchant.example/payments/return",
    "amount": "250.75",
    "network": "eth",
    "token": "USDT",
    "language": "ru"
});

The client is the one on Signing requests: it signs, sends, and turns an error into an exception carrying its code.

Response 200

The hosted link.

{
  "session": "sess_9f2c1e",
  "token": "E59YRa6MDSiUunx2oZbU1C3-P_jufO18n7ZRj_xeVBE",
  "url": "https://time2pay.tech/p/E59YRa6MDSiUunx2oZbU1C3-P_jufO18n7ZRj_xeVBE",
  "expires_at": "2026-07-28T18:12:25Z"
}

Errors

400 validation Malformed JSON, an unknown network or token, a token without a network, or a non-positive amount. Codes: validation, unsupported.
{
  "error": {
    "code": "validation",
    "message": "invalid amount"
  }
}
401 unauthorized The signature, key, timestamp or nonce did not check out: a missing header, an unknown key id, a timestamp more than 5 minutes from ours, a signature that does not match, or a nonce already used inside the window. Do not retry without rebuilding the request — a retry needs a new timestamp, a new nonce and a new signature.
{
  "error": {
    "code": "unauthorized",
    "message": "invalid signature"
  }
}
403 forbidden The credential is valid but the caller's IP is not in the allowlist configured for your merchant. Not retryable.
{
  "error": {
    "code": "forbidden",
    "message": "client IP not allowed for this merchant"
  }
}
409 conflict Either this external_order_id already belongs to a session created with different parameters, or the order it names is no longer pending — a payment is already on the chain for it, and handing out a new link would strand the player who is paying. Create a new order under a new reference.
{
  "error": {
    "code": "conflict",
    "message": "order \"deposit-1001\" is already processing"
  }
}
503 unavailable A dependency we need to answer safely is degraded, so the request was refused rather than served wrongly. Transient: retry with backoff, using a fresh timestamp, nonce and signature.
{
  "error": {
    "code": "unavailable",
    "message": "cannot verify request nonce"
  }
}
POST /v1/hpp/step no signature

Advance a hosted session (browser)

Called by the hosted page itself, authenticated by the token in the URL rather than by your API key. You do not need to call this — it is documented because it is what the page in the player's browser does.

Request body

FieldTypeRequiredDescription
token string yes The token from the page URL.
action string yes
value string no

Request

curl -X POST "$BASE_URL/v1/hpp/step" \
  -H "Content-Type: application/json" \
  -d '{"token":"E59YRa6MDSiUunx2oZbU1C3-P_jufO18n7ZRj_xeVBE","action":"submit_amount","value":"250.75"}'

Response 200

The step the session moved to.

{
  "step": "awaiting_approval",
  "session": "sess_9f2c1e"
}

Errors

400 validation A missing token, an action that does not belong to the current step, a malformed value, or a payer wallet that does not hold enough of the token to cover the deposit. Codes: validation, invalid_action, insufficient_funds.
{
  "error": {
    "code": "validation",
    "message": "token is required"
  }
}
404 not_found No session carries this token.
{
  "error": {
    "code": "not_found",
    "message": "session not found"
  }
}
410 expired The link's TTL has elapsed. The order is untouched — create a new link for it.
{
  "error": {
    "code": "expired",
    "message": "payment link has expired"
  }
}
POST /v1/hpp/status no signature

Read a hosted session (browser)

The polling call the hosted page makes while a payment settles. Token-scoped, like the step call.

Request body

FieldTypeRequiredDescription
token string yes

Request

curl -X POST "$BASE_URL/v1/hpp/status" \
  -H "Content-Type: application/json" \
  -d '{"token":"E59YRa6MDSiUunx2oZbU1C3-P_jufO18n7ZRj_xeVBE"}'

Response 200

Where the session stands now.

{
  "step": "completed",
  "session": "sess_9f2c1e"
}

Errors

404 not_found No session carries this token.
{
  "error": {
    "code": "not_found",
    "message": "session not found"
  }
}
410 expired The link's TTL has elapsed.
{
  "error": {
    "code": "expired",
    "message": "payment link has expired"
  }
}
GET /p/{token} no signature

The hosted payment page

The page itself. Redirect the player here, or load it as an iframe src. An invalid token still renders the page; the token is checked inside the calls the page makes.

Path parameters

NameTypeRequiredDescription
token string yes The token returned by the create call.

Request

curl -X GET "$BASE_URL/p/{token}"

Response 200

HTML.

GET /hpp/sdk.js no signature

The iframe modal SDK

A small script that opens the hosted page in a modal iframe and reports lifecycle events to the parent window. See the iframe flow for the snippet and the events it posts.

Request

curl -X GET "$BASE_URL/hpp/sdk.js"

Response 200

JavaScript.

Prefilling

network, token and amount are optional on both create calls and mean the same thing in each: when you already know what the player is depositing, the matching step is skipped and the session starts further along.

Prefilling token does more than skip a screen. The coin picker runs after the wallet connects and lists what that wallet holds, so a session without a prefilled token gives the player the coins they can actually pay with — and lets them pick a different one if they change wallets. Prefill the token only when the order really is denominated in it.

You sendSession starts at
nothingnetwork_select
networkconnect_wallet, then token_select
network + tokenconnect_wallet, and the coin picker is skipped
network + token + amountconnect_wallet, and the player is never asked for a coin or an amount

Prefilled values are validated when the session is created, because the steps that would normally check them are exactly the ones being skipped. An unknown network, a token that does not exist on that network, a token without a network, or an amount that is not a positive number are all rejected at create time rather than surfacing at settlement.

Deposit statuses

StatusSet byMeaning
pendingthe sessionCreated; no payment transaction submitted yet.
processingthe sessionPayment transaction submitted; waiting for confirmations.
completedthe chain listenerReached the required confirmation depth. This is when you credit.
failedthe chain listenerSettlement was unsuccessful.

Confirmation depth is amount-based: larger deposits wait for more blocks. The listener matches a payment by orderRef (which is keccak256(session_id)) and falls back to the transaction hash, so a deposit reconciles even if the browser never reported the hash.

Webhooks are the way to learn the outcome. If one never arrives — your endpoint was down through every retry — read the session with /v1/cashier/status and credit from its status, deduplicating on external_order_id exactly as the webhook handler does.