Guide
Integration flows
Redirect, iframe, self-hosted or a standing address — what each costs to build, with the code for each.
Which one do you want
The three session flows take the same money through the same contracts and settle identically; they differ in who builds the payment UI and where the player is while they pay. A wallet address is different in kind: no session at all, just an address the player sends to.
| Redirect | Iframe | Self-hosted | Wallet address | |
|---|---|---|---|---|
| Player stays on your site | no | yes | yes | yes |
| You build the payment UI | no | no | yes | an address and a QR code |
| You talk to the player's wallet | no | no | yes | no — they send from anywhere |
| Signed calls you make | one, to create the link | one, to create the link | every step | one per player and network |
| Amount agreed up front | optional | optional | optional | no — any amount, any time |
| Networks | EVM | EVM | EVM | EVM and Tron |
| Work to integrate | an afternoon | an afternoon | days | an afternoon |
| Webhook | payment.completed | payment.completed | payment.completed | deposit.confirmed |
Redirect
One signed call gives you a URL. Send the player there; we render the whole payment, talk to their wallet, and send them back when it is over.
- Create the link with
POST /v1/hpp/sessions, passingreturn_url. - Redirect the player to the
urlin the response. - Take them back. After a terminal step we send them to your
return_urlwith the outcome appended:https://casino.example/cashier/return?status=completed https://casino.example/cashier/return?status=failed - Credit from the webhook, not from the redirect. A player can close the tab before it happens, and a query string in a browser is not something to move money on.
The whole backend side — a form posts the order id, the player lands on the payment page:
func startDeposit(client *time2pay.Client) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, _ := json.Marshal(map[string]string{
"player_id": playerID(r),
"external_order_id": r.FormValue("order_id"),
"return_url": "https://casino.example/cashier/return",
})
link, err := client.Call(r.Context(), "POST", "/v1/hpp/sessions", string(body))
if err != nil {
http.Error(w, "cashier unavailable", http.StatusBadGateway)
return
}
http.Redirect(w, r, link["url"].(string), http.StatusSeeOther)
}
}$link = $client->call('POST', '/v1/hpp/sessions', [
'player_id' => $playerId,
'external_order_id' => $_POST['order_id'],
'return_url' => 'https://casino.example/cashier/return',
]);
header('Location: ' . $link['url'], true, 303);
exit;export async function startDeposit(request, env, playerId) {
const client = createClient({ baseUrl: env.BASE_URL, keyId: env.API_KEY_ID, secret: env.API_KEY_SECRET });
const form = await request.formData();
const link = await client.call("POST", "/v1/hpp/sessions", {
player_id: playerId,
external_order_id: form.get("order_id"),
return_url: "https://casino.example/cashier/return",
});
return Response.redirect(link.url, 303);
}@app.post("/deposit")
def start_deposit():
link = client.call("POST", "/v1/hpp/sessions", {
"player_id": current_player_id(),
"external_order_id": request.form["order_id"],
"return_url": "https://casino.example/cashier/return",
})
return redirect(link["url"], code=303)app.post("/deposit", express.urlencoded({ extended: false }), async (req, res) => {
const link = await client.call("POST", "/v1/hpp/sessions", {
player_id: req.user.id,
external_order_id: req.body.order_id,
return_url: "https://casino.example/cashier/return",
});
res.redirect(303, link.url);
});Iframe
The hosted page in a modal on your own site. Same link as the redirect flow, opened by a small SDK that reports lifecycle events to the parent window.
Your backend creates the link and hands it to the page — the same call as the redirect, returning JSON instead of a redirect:
func depositLink(client *time2pay.Client) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var in struct {
OrderID string `json:"order_id"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
body, _ := json.Marshal(map[string]string{"player_id": playerID(r), "external_order_id": in.OrderID})
link, err := client.Call(r.Context(), "POST", "/v1/hpp/sessions", string(body))
if err != nil {
http.Error(w, "cashier unavailable", http.StatusBadGateway)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"url": link["url"]})
}
}$in = json_decode(file_get_contents('php://input'), true) ?? [];
$link = $client->call('POST', '/v1/hpp/sessions', [
'player_id' => $playerId,
'external_order_id' => (string) ($in['order_id'] ?? ''),
]);
header('Content-Type: application/json');
echo json_encode(['url' => $link['url']]);export async function depositLink(request, env, playerId) {
const client = createClient({ baseUrl: env.BASE_URL, keyId: env.API_KEY_ID, secret: env.API_KEY_SECRET });
const { order_id } = await request.json();
const link = await client.call("POST", "/v1/hpp/sessions", { player_id: playerId, external_order_id: order_id });
return Response.json({ url: link.url });
}@app.post("/deposit-link")
def deposit_link():
link = client.call("POST", "/v1/hpp/sessions", {
"player_id": current_player_id(),
"external_order_id": request.get_json(force=True)["order_id"],
})
return {"url": link["url"]}app.post("/deposit-link", express.json(), async (req, res) => {
const link = await client.call("POST", "/v1/hpp/sessions", {
player_id: req.user.id,
external_order_id: req.body.order_id,
});
res.json({ url: link.url });
});Your page opens it:
<script src="https://time2pay.tech/hpp/sdk.js"></script>
<script>
async function deposit(orderId) {
const { url } = await fetch("/deposit-link", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ order_id: orderId }),
}).then((r) => r.json());
CryptoCashier.open({
url,
onCompleted: () => showPending(),
onFailed: () => showRetry(),
onClose: () => {},
});
}
</script>
The hosted page posts these events to the parent window:
{ "source": "crypto-cashier", "type": "cashier:completed" }
| Event | Meaning |
|---|---|
cashier:ready | The page loaded and is ready for the player. |
cashier:completed | The deposit reached a successful terminal step. |
cashier:failed | The deposit ended unsuccessfully. |
Self-hosted
Your backend drives the session and your frontend renders it. Each step comes back as a small UI
schema — headings, buttons, inputs — plus, when the player has to sign something, an opaque
wallet_action you forward to their wallet untouched. The API secret never reaches a
browser because every call goes through your own backend.
- Open a session with
POST /v1/cashier/initand remember itssessionagainst the player. Prefill what you already know and the matching steps are skipped. - Render the step from
components, and send the action it asks for toPOST /v1/cashier/step— through your backend, which adds the session id and the signature. - Forward wallet actions to
window.ethereum.requestexactly as given, then submit the resulting transaction hash with thesubmit_actionthe step named. - Poll with the
pollaction while the step isprocessing, and credit from the webhook.
Backend: open the session
body, _ := json.Marshal(map[string]string{
"player_id": player.ID,
"external_order_id": order.ID,
"network": "eth",
})
step, err := client.Call(ctx, "POST", "/v1/cashier/init", string(body))
if err != nil {
return err
}
if err := saveCashierSession(ctx, player.ID, step["session"].(string)); err != nil {
return err
}
return json.NewEncoder(w).Encode(step)$step = $client->call('POST', '/v1/cashier/init', [
'player_id' => $playerId,
'external_order_id' => $orderId,
'network' => 'eth',
]);
$_SESSION['cashier_session'] = $step['session'];
header('Content-Type: application/json');
echo json_encode($step);const step = await client.call("POST", "/v1/cashier/init", {
player_id: playerId,
external_order_id: orderId,
network: "eth",
});
await saveCashierSession(playerId, step.session);
return Response.json(step);step = client.call("POST", "/v1/cashier/init", {
"player_id": current_player_id(),
"external_order_id": order_id,
"network": "eth",
})
session["cashier_session"] = step["session"]
return stepconst step = await client.call("POST", "/v1/cashier/init", {
player_id: req.user.id,
external_order_id: orderId,
network: "eth",
});
req.session.cashierSession = step.session;
res.json(step);Backend: proxy the steps
The browser never talks to us. It posts {action, value} to your backend, which looks
up the player's session, signs the call and passes the step back. Pass our error code through so
the page can word it.
func cashierStep(client *time2pay.Client, sessionOf func(r *http.Request) (string, error)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session, err := sessionOf(r)
if err != nil {
http.Error(w, "no open deposit", http.StatusNotFound)
return
}
var in struct {
Action string `json:"action"`
Value string `json:"value"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
body, _ := json.Marshal(map[string]string{"session": session, "action": in.Action, "value": in.Value})
step, err := client.Call(r.Context(), "POST", "/v1/cashier/step", string(body))
var apiErr *time2pay.Error
if errors.As(err, &apiErr) {
w.WriteHeader(apiErr.Status)
_ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]string{"code": apiErr.Code}})
return
}
if err != nil {
http.Error(w, "try again", http.StatusBadGateway)
return
}
_ = json.NewEncoder(w).Encode(step)
}
}<?php
require __DIR__ . '/Time2payClient.php';
session_start();
$client = new Time2payClient(getenv('BASE_URL'), getenv('API_KEY_ID'), getenv('API_KEY_SECRET'));
$in = json_decode(file_get_contents('php://input'), true) ?? [];
header('Content-Type: application/json');
try {
echo json_encode($client->call('POST', '/v1/cashier/step', [
'session' => $_SESSION['cashier_session'],
'action' => (string) ($in['action'] ?? ''),
'value' => (string) ($in['value'] ?? ''),
]));
} catch (Time2payError $e) {
http_response_code($e->status);
echo json_encode(['error' => ['code' => $e->errorCode()]]);
}export async function cashierStep(request, env, sessionOf) {
const client = createClient({ baseUrl: env.BASE_URL, keyId: env.API_KEY_ID, secret: env.API_KEY_SECRET });
const session = await sessionOf(request);
const { action, value = "" } = await request.json();
try {
return Response.json(await client.call("POST", "/v1/cashier/step", { session, action, value }));
} catch (e) {
if (!(e instanceof Time2payError)) return new Response("try again", { status: 502 });
return Response.json({ error: { code: e.code } }, { status: e.status });
}
}@app.post("/cashier/step")
def cashier_step():
body = request.get_json(force=True)
try:
return client.call("POST", "/v1/cashier/step", {
"session": session["cashier_session"],
"action": str(body.get("action", "")),
"value": str(body.get("value", "")),
})
except Time2payError as e:
return {"error": {"code": e.code}}, e.statusapp.post("/cashier/step", express.json(), async (req, res) => {
try {
res.json(await client.call("POST", "/v1/cashier/step", {
session: req.session.cashierSession,
action: String(req.body.action ?? ""),
value: String(req.body.value ?? ""),
}));
} catch (e) {
if (!(e instanceof Time2payError)) return res.status(502).end();
res.status(e.status).json({ error: { code: e.code } });
}
});Frontend: render and talk to the wallet
Plain browser JavaScript; render is yours — it draws components. The
wallet request is forwarded exactly as the step gave it, including the chain switch when
chain_id is set.
async function send(action, value = "") {
const res = await fetch("/cashier/step", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, value }),
});
return render(await res.json());
}
async function connectWallet() {
const [address] = await window.ethereum.request({ method: "eth_requestAccounts" });
return send("wallet_connected", address);
}
async function runWalletAction(step) {
const { method, params, chain_id: chainId } = step.wallet_action;
if (chainId) {
await window.ethereum.request({ method: "wallet_switchEthereumChain", params: [{ chainId }] });
}
const txHash = await window.ethereum.request({ method, params });
return send(step.submit_action, txHash);
}
The step machine
The wallet is asked for before the coin. Which coins are worth offering is a question about the
player's wallet: token_select carries the balance the connected wallet holds in each
one, and greys out the coins it cannot open a deposit with. Options on that step may therefore
arrive with note, disabled and reason set — render a
disabled option, do not drop it, or the player is told a coin you accept is unsupported.
A player may also send change_wallet (no value) from token_select,
enter_amount or awaiting_approval to return to
connect_wallet and pay from a different address. It is refused from
awaiting_payment onwards, where the order is already bound to the wallet that
approved it. A coin the player chose is re-chosen with the new wallet; a coin you prefilled is
kept.
| Step | Action to send | Value | Moves to |
|---|---|---|---|
network_select | select_network | the value of the option the player picked | connect_wallet |
connect_wallet | wallet_connected | 0x + 40 hex chars | token_select, or straight past it when you prefilled token |
token_select | select_token | the value of the option the player picked | enter_amount, or awaiting_approval / awaiting_payment when the amount was prefilled |
enter_amount | submit_amount | positive decimal | awaiting_approval, or awaiting_payment |
awaiting_approval | approval_submitted | 0x + 64 hex chars | awaiting_payment |
awaiting_payment | signature_submitted | 0x + 64 hex chars | processing |
processing | poll | empty | processing, completed, failed, reverted |
awaiting_payment. Never assume the step order —
render whatever step the response names.
transferFrom reverts, because the allowance is not visible on-chain yet — and
approval_submitted is refused with tx_reverted or unavailable
until the allowance is readable.
Terminal steps
completed, failed and reverted all end the session, and all
three carry an order status of completed or failed —
branch on the status, and use the step only to word the screen. reverted is a
payment that was mined and rejected by the chain: the player's tokens never moved and only their
gas was spent, which is the opposite of what a generic failure message implies.
Wallet address
Ask for the player's address once per network with
POST /v1/wallet/address,
show it with the network name and a QR code, and credit each
deposit.confirmed webhook as it arrives. The call is
idempotent, so make it on every cashier visit instead of caching. Details on the
Wallet page.
- Create the link with
POST /v1/hpp/sessions, passingreturn_urland the player'slanguage. - Redirect the player to the
urlin the response. - Take them back. After a terminal step we send them to your
return_urlwith the outcome appended:https://merchant.example/payments/return?status=completed https://merchant.example/payments/return?status=failed - Credit from the webhook, not from the redirect. A player can close the tab before it happens, and a query string in a browser is not something to move money on.
The page is shown in the language you send on the create call — en,
ru or es. Send it: the redirect carries nothing about the player, so
a session created without one continues in English no matter what language your casino was in.
An unsupported code is not an error, it simply falls back.
Iframe
The hosted page in a modal on your own site. Same link as the redirect flow, opened by a small SDK that reports lifecycle events to the parent window.
<script src="https://time2pay.tech/hpp/sdk.js"></script>
<script>
CryptoCashier.open({
url, // from POST /v1/hpp/sessions
onCompleted: () => location.reload(),
onFailed: () => showRetry(),
onClose: () => {}
});
</script>
The page posts these events to the parent window:
{ "source": "crypto-cashier", "type": "cashier:completed" }
| Event | Meaning |
|---|---|
cashier:ready | The page loaded and is ready for the player. |
cashier:completed | The deposit reached a successful terminal step. |
cashier:failed | The deposit ended unsuccessfully. |