time2pay Developer Docs

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.

RedirectIframeSelf-hostedWallet address
Player stays on your sitenoyesyesyes
You build the payment UInonoyesan address and a QR code
You talk to the player's walletnonoyesno — they send from anywhere
Signed calls you makeone, to create the linkone, to create the linkevery stepone per player and network
Amount agreed up frontoptionaloptionaloptionalno — any amount, any time
NetworksEVMEVMEVMEVM and Tron
Work to integratean afternoonan afternoondaysan afternoon
Webhookpayment.completedpayment.completedpayment.completeddeposit.confirmed
If you are not sure, take redirect. It is one signed call and a webhook, and you can move to self-hosted later without changing your credentials, your webhook handling or your ledger — the settlement side is identical. Taking USDT on Tron? That is the wallet address.

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.

  1. Create the link with POST /v1/hpp/sessions, passing return_url.
  2. Redirect the player to the url in the response.
  3. Take them back. After a terminal step we send them to your return_url with the outcome appended:
    https://casino.example/cashier/return?status=completed
    https://casino.example/cashier/return?status=failed
  4. 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" }
EventMeaning
cashier:readyThe page loaded and is ready for the player.
cashier:completedThe deposit reached a successful terminal step.
cashier:failedThe deposit ended unsuccessfully.
Treat these events as UI hints, exactly like the redirect. They come from a browser, so they tell you what to show, never what to credit. The webhook is the one that moves the ledger.

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.

  1. Open a session with POST /v1/cashier/init and remember its session against the player. Prefill what you already know and the matching steps are skipped.
  2. Render the step from components, and send the action it asks for to POST /v1/cashier/step — through your backend, which adds the session id and the signature.
  3. Forward wallet actions to window.ethereum.request exactly as given, then submit the resulting transaction hash with the submit_action the step named.
  4. Poll with the poll action while the step is processing, 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 step
const 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.status
app.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.

StepAction to sendValueMoves to
network_selectselect_networkthe value of the option the player pickedconnect_wallet
connect_walletwallet_connected0x + 40 hex charstoken_select, or straight past it when you prefilled token
token_selectselect_tokenthe value of the option the player pickedenter_amount, or awaiting_approval / awaiting_payment when the amount was prefilled
enter_amountsubmit_amountpositive decimalawaiting_approval, or awaiting_payment
awaiting_approvalapproval_submitted0x + 64 hex charsawaiting_payment
awaiting_paymentsignature_submitted0x + 64 hex charsprocessing
processingpollemptyprocessing, completed, failed, reverted
The approval step is skipped when it is not needed. Before asking for one we read the allowance the wallet has already granted the router; a player who approved enough in an earlier deposit goes straight to awaiting_payment. Never assume the step order — render whatever step the response names.
Wait for the approval transaction to be mined before sending the payment transaction. Otherwise 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.

  1. Create the link with POST /v1/hpp/sessions, passing return_url and the player's language.
  2. Redirect the player to the url in the response.
  3. Take them back. After a terminal step we send them to your return_url with the outcome appended:
    https://merchant.example/payments/return?status=completed
    https://merchant.example/payments/return?status=failed
  4. 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" }
EventMeaning
cashier:readyThe page loaded and is ready for the player.
cashier:completedThe deposit reached a successful terminal step.
cashier:failedThe deposit ended unsuccessfully.
Treat these events as UI hints, exactly like the redirect. They come from a browser, so they tell you what to show, never what to credit. The webhook is the one that moves the ledger.