time2pay Developer Docs

Guide

Webhooks

How you find out a payment settled when nobody is polling.

How delivery works

When a deposit or a payout settles, we POST a signed JSON body to your callback URL — configured per merchant in the admin panel, or overridden per session (or per payout) with callback_url.

Deliveries are queued to a persistent outbox and sent off the request path, so a slow or failing endpoint on your side never blocks a settlement. Any 2xx counts as delivered. Anything else is retried with exponential backoff — 30s → 1m → 2m → … → 1h, up to 8 attempts — before the delivery is marked failed. An operator can queue a fresh one from the admin panel at any point.

An API key is required to receive webhooks at all. Every callback is signed with your key's secret, and a merchant without a key is not sent one — an unsigned settlement notice is unauthenticated, and anyone who learned your callback URL could forge it. Issue a key and queued callbacks go out on the next attempt.

Events

EventSent whenWhat you do
payment.completedA session deposit reached the required confirmation depth on-chain.Credit the order's amount to the player, once per external_order_id.
payment.failedA session deposit settled as failed.Close the order; credit nothing.
deposit.confirmedA transfer to a player's wallet address is final on-chain.Credit credited, once per deposit_id.
payout.completedA payout's relay transaction confirmed.Mark the withdrawal paid.
payout.failedA payout's relay transaction reverted or settled as failed.Nothing left your wallet: return the funds to the player's balance, or pay again under a new external_id.

Headers

HeaderMeaning
X-Webhook-EventThe event name, e.g. payment.completed.
X-Webhook-IdId of this delivery. Stable across retries of it.
X-Idempotency-KeyStable key for the logical event, derived from the session or payout plus the event. Identical across retries and across an operator resend. Deduplicate on this one.
X-Webhook-TimestampUnix seconds when this attempt was signed. Part of the signed value.
X-Webhook-SignatureLowercase hex HMAC-SHA256. Omitted only when signing is disabled in dev.
X-Webhook-Key-IdWhich of your keys signed it.

Verifying the signature

Webhooks are signed with your API key's secret — the same one you sign requests to us with. The canonical string is simpler than the request one: no nonce, no method, no path, just the timestamp and the exact body.

canonical = "<X-Webhook-Timestamp>\n<raw request body>"
expected  = hex(HMAC-SHA256(API_KEY_SECRET, canonical))

Accept only when expected equals X-Webhook-Signature (compared in constant time) and the timestamp is within 5 minutes of your clock. Every retry is signed afresh, so a legitimate delivery is never older than that; an old one is a replay.

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"math"
	"strconv"
	"time"
)

func VerifyWebhook(secret, timestamp, signature string, rawBody []byte, now time.Time) bool {
	ts, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil || math.Abs(float64(now.Unix()-ts)) > 300 {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(timestamp + "\n"))
	mac.Write(rawBody)
	expected := hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(expected), []byte(signature))
}
<?php

function verifyWebhook(string $secret, string $timestamp, string $signature, string $rawBody, ?int $now = null): bool
{
    $now ??= time();
    if (!ctype_digit($timestamp) || abs($now - (int) $timestamp) > 300) {
        return false;
    }
    $expected = hash_hmac('sha256', $timestamp . "\n" . $rawBody, $secret);
    return hash_equals($expected, $signature);
}
const encoder = new TextEncoder();

function fromHex(hex) {
  const pairs = hex.match(/^(?:[0-9a-f]{2})+$/) ? hex.match(/../g) : [];
  return new Uint8Array(pairs.map((b) => parseInt(b, 16)));
}

export async function verifyWebhook(secret, timestamp, signature, rawBody, now = Date.now()) {
  if (!/^\d+$/.test(timestamp ?? "") || Math.abs(now / 1000 - Number(timestamp)) > 300) return false;
  const signed = new Uint8Array([...encoder.encode(timestamp + "\n"), ...new Uint8Array(rawBody)]);
  const key = await crypto.subtle.importKey(
    "raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"],
  );
  return crypto.subtle.verify("HMAC", key, fromHex(signature ?? ""), signed);
}
import hashlib
import hmac
import time


def verify_webhook(secret, timestamp, signature, raw_body, now=None):
    now = time.time() if now is None else now
    if not (timestamp or "").isdigit() or abs(now - int(timestamp)) > 300:
        return False
    expected = hmac.new(secret.encode(), timestamp.encode() + b"\n" + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature or "")
import crypto from "node:crypto";

export function verifyWebhook(secret, timestamp, signature, rawBody, now = Date.now()) {
  if (!/^\d+$/.test(timestamp ?? "") || Math.abs(now / 1000 - Number(timestamp)) > 300) return false;
  const expected = crypto.createHmac("sha256", secret)
    .update(timestamp + "\n")
    .update(rawBody)
    .digest("hex");
  return typeof signature === "string" && signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
Verify against the raw body. Re-serializing the parsed JSON changes the bytes — key order, spacing, escaping — and the signature will never match.

Test vector

The delivery in Payloads below, signed with the secret test-secret. Your verifier should accept it when its clock reads 1785933667 (pass now explicitly), and reject it with any single byte of the body changed, or when the clock is more than 300 seconds later.

secret    = test-secret
timestamp = 1785933667
signature = 94470414565125a881752a41731333ffa067d57f81513dbcd95d5dc02e6b2495

The endpoint

Verify, record, answer. The handler only stores the event — keyed by X-Idempotency-Key, so a repeat is a no-op — and answers 204; the processing below runs afterwards, from your own queue or a worker reading that table. A slow handler turns into retries.

CREATE TABLE time2pay_webhooks (
    idempotency_key text PRIMARY KEY,
    body            text NOT NULL,
    received_at     timestamptz NOT NULL DEFAULT now(),
    processed_at    timestamptz
);
func webhookHandler(secret string, recordOnce func(ctx context.Context, key string, raw []byte) error) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
		if err != nil {
			http.Error(w, "unreadable body", http.StatusBadRequest)
			return
		}
		if !VerifyWebhook(secret, r.Header.Get("X-Webhook-Timestamp"), r.Header.Get("X-Webhook-Signature"), raw, time.Now()) {
			http.Error(w, "bad signature", http.StatusUnauthorized)
			return
		}
		if err := recordOnce(r.Context(), r.Header.Get("X-Idempotency-Key"), raw); err != nil {
			http.Error(w, "try again", http.StatusServiceUnavailable)
			return
		}
		w.WriteHeader(http.StatusNoContent)
	}
}
<?php

require __DIR__ . '/webhook.php';

$raw = file_get_contents('php://input');
$verified = verifyWebhook(
    getenv('API_KEY_SECRET'),
    $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '',
    $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '',
    $raw,
);
if (!$verified) {
    http_response_code(401);
    exit;
}

$pdo = new PDO(getenv('DATABASE_DSN'), getenv('DATABASE_USER'), getenv('DATABASE_PASSWORD'));
$insert = $pdo->prepare(
    'INSERT INTO time2pay_webhooks (idempotency_key, body) VALUES (?, ?) ON CONFLICT (idempotency_key) DO NOTHING'
);
$insert->execute([$_SERVER['HTTP_X_IDEMPOTENCY_KEY'], $raw]);
http_response_code(204);
import { verifyWebhook } from "./webhook.js";

export default {
  async fetch(request, env) {
    const raw = new Uint8Array(await request.arrayBuffer());
    const verified = await verifyWebhook(
      env.API_KEY_SECRET,
      request.headers.get("X-Webhook-Timestamp"),
      request.headers.get("X-Webhook-Signature"),
      raw,
    );
    if (!verified) return new Response("bad signature", { status: 401 });

    await env.recordOnce(request.headers.get("X-Idempotency-Key"), new TextDecoder().decode(raw));
    return new Response(null, { status: 204 });
  },
};
import os

from flask import Flask, request

from webhook import verify_webhook

app = Flask(__name__)


@app.post("/time2pay/webhook")
def time2pay_webhook():
    raw = request.get_data()
    verified = verify_webhook(
        os.environ["API_KEY_SECRET"],
        request.headers.get("X-Webhook-Timestamp"),
        request.headers.get("X-Webhook-Signature"),
        raw,
    )
    if not verified:
        return "bad signature", 401
    record_once(request.headers["X-Idempotency-Key"], raw)
    return "", 204
import express from "express";
import { verifyWebhook } from "./webhook.mjs";

const app = express();

app.post("/time2pay/webhook", express.raw({ type: "application/json" }), async (req, res) => {
  const verified = verifyWebhook(
    process.env.API_KEY_SECRET,
    req.get("X-Webhook-Timestamp"),
    req.get("X-Webhook-Signature"),
    req.body,
  );
  if (!verified) return res.sendStatus(401);

  await recordOnce(req.get("X-Idempotency-Key"), req.body.toString("utf8"));
  res.sendStatus(204);
});

Processing events

One switch over event. Each branch must be idempotent on its own business key — external_order_id, deposit_id, external_id — because an operator resend or your own reprocessing can hand you the same event twice. Ledger is your code.

import (
	"context"
	"encoding/json"
)

type webhookEvent struct {
	Event           string `json:"event"`
	SessionID       string `json:"session_id"`
	ExternalOrderID string `json:"external_order_id"`
	DepositID       string `json:"deposit_id"`
	PayoutID        string `json:"payout_id"`
	ExternalID      string `json:"external_id"`
	PlayerID        string `json:"player_id"`
	Token           string `json:"token"`
	Amount          string `json:"amount"`
	Credited        string `json:"credited"`
	TxHash          string `json:"tx_hash"`
	FailureReason   string `json:"failure_reason"`
}

func processEvent(ctx context.Context, ledger Ledger, raw []byte) error {
	var e webhookEvent
	if err := json.Unmarshal(raw, &e); err != nil {
		return err
	}
	switch e.Event {
	case "payment.completed":
		return ledger.CreditDeposit(ctx, e.ExternalOrderID, e.Token, e.Amount)
	case "payment.failed":
		return ledger.FailDeposit(ctx, e.ExternalOrderID)
	case "deposit.confirmed":
		return ledger.CreditWalletDeposit(ctx, e.DepositID, e.PlayerID, e.Token, e.Credited)
	case "payout.completed":
		return ledger.CompleteWithdrawal(ctx, e.ExternalID, e.TxHash)
	case "payout.failed":
		return ledger.FailWithdrawal(ctx, e.ExternalID, e.FailureReason)
	}
	return nil
}
function processEvent(Ledger $ledger, string $raw): void
{
    $e = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
    match ($e['event']) {
        'payment.completed' => $ledger->creditDeposit($e['external_order_id'], $e['token'], $e['amount']),
        'payment.failed' => $ledger->failDeposit($e['external_order_id']),
        'deposit.confirmed' => $ledger->creditWalletDeposit($e['deposit_id'], $e['player_id'], $e['token'], $e['credited']),
        'payout.completed' => $ledger->completeWithdrawal($e['external_id'] ?? $e['payout_id'], $e['tx_hash']),
        'payout.failed' => $ledger->failWithdrawal($e['external_id'] ?? $e['payout_id'], $e['failure_reason'] ?? ''),
        default => null,
    };
}
export async function processEvent(ledger, raw) {
  const e = JSON.parse(raw);
  switch (e.event) {
    case "payment.completed":
      return ledger.creditDeposit(e.external_order_id, e.token, e.amount);
    case "payment.failed":
      return ledger.failDeposit(e.external_order_id);
    case "deposit.confirmed":
      return ledger.creditWalletDeposit(e.deposit_id, e.player_id, e.token, e.credited);
    case "payout.completed":
      return ledger.completeWithdrawal(e.external_id ?? e.payout_id, e.tx_hash);
    case "payout.failed":
      return ledger.failWithdrawal(e.external_id ?? e.payout_id, e.failure_reason ?? "");
  }
}
import json


def process_event(ledger, raw):
    e = json.loads(raw)
    kind = e["event"]
    if kind == "payment.completed":
        ledger.credit_deposit(e["external_order_id"], e["token"], e["amount"])
    elif kind == "payment.failed":
        ledger.fail_deposit(e["external_order_id"])
    elif kind == "deposit.confirmed":
        ledger.credit_wallet_deposit(e["deposit_id"], e["player_id"], e["token"], e["credited"])
    elif kind == "payout.completed":
        ledger.complete_withdrawal(e.get("external_id") or e["payout_id"], e["tx_hash"])
    elif kind == "payout.failed":
        ledger.fail_withdrawal(e.get("external_id") or e["payout_id"], e.get("failure_reason", ""))
export async function processEvent(ledger, raw) {
  const e = JSON.parse(raw);
  switch (e.event) {
    case "payment.completed":
      return ledger.creditDeposit(e.external_order_id, e.token, e.amount);
    case "payment.failed":
      return ledger.failDeposit(e.external_order_id);
    case "deposit.confirmed":
      return ledger.creditWalletDeposit(e.deposit_id, e.player_id, e.token, e.credited);
    case "payout.completed":
      return ledger.completeWithdrawal(e.external_id ?? e.payout_id, e.tx_hash);
    case "payout.failed":
      return ledger.failWithdrawal(e.external_id ?? e.payout_id, e.failure_reason ?? "");
  }
}

Payout events carry external_id when you sent one, and always payout_id. Batch items also carry batch_id and batch_index.

Payloads

A delivery on the wire, exactly as it arrives — the body is one line of JSON and it is those bytes that are signed, so this is what your handler has to hash before it parses anything:

POST /time2pay/webhook HTTP/1.1
Host: api.casino.example
Content-Type: application/json
X-Webhook-Event: payment.completed
X-Webhook-Id: 01J8ZQ8A1B2C3D4E5F6G7H8J9K
X-Idempotency-Key: 2775f41a05cbb6a87521c01b933169ff389fce4b11e3b852ed7c36e724221956
X-Webhook-Timestamp: 1785933667
X-Webhook-Key-Id: ak_7f2c9d41
X-Webhook-Signature: 94470414565125a881752a41731333ffa067d57f81513dbcd95d5dc02e6b2495

{"event":"payment.completed","session_id":"01J8ZQ7K4M2X9F3B6D0T5R8W1C","merchant_id":"acme-casino","player_id":"player-77421","external_order_id":"ORD-2026-000188","network":"eth","token":"USDT","amount":"250.000000","status":"completed","tx_hash":"0x9f2c4e1a7b3d5f8e0c6a2b4d7e9f1c3a5b7d9e1f2c4a6b8d0e2f4a6c8b0d2e4f","order_ref":"a7f3c9d1","occurred_at":"2026-08-05T12:41:07Z"}

A payout delivery carries the same headers with X-Webhook-Event: payout.completed, its own X-Idempotency-Key and X-Webhook-Id, and a timestamp and signature of its own. The bodies of each event are below, formatted.

Which fields are always there. A deposit event always carries event, session_id, merchant_id, status and occurred_at; a payout event the same five with payout_id in place of session_id; a wallet deposit event, deposit_id, player_id, the amounts and occurred_at. Everything else is omitted when empty rather than sent as null — payment.failed, for instance, usually arrives with no tx_hash at all. Read them as optional and your handler survives an event that never got that far.
POST payment.completed no signature

A deposit settled

Sent to your callback URL when a deposit session reaches the required confirmation depth, or settles as failed (event payment.failed). Signed with your API key secret. Deduplicate on X-Idempotency-Key.

Body

{
  "event": "payment.completed",
  "session_id": "01J8ZQ7K4M2X9F3B6D0T5R8W1C",
  "merchant_id": "acme-casino",
  "player_id": "player-77421",
  "external_order_id": "ORD-2026-000188",
  "network": "eth",
  "token": "USDT",
  "amount": "250.000000",
  "status": "completed",
  "tx_hash": "0x9f2c4e1a7b3d5f8e0c6a2b4d7e9f1c3a5b7d9e1f2c4a6b8d0e2f4a6c8b0d2e4f",
  "order_ref": "a7f3c9d1",
  "occurred_at": "2026-08-05T12:41:07Z"
}

Response 200

Any 2xx marks the delivery successful. Anything else is retried with backoff: 30s, 1m, 2m … 1h, up to 8 attempts.

POST deposit.confirmed no signature

A player funded their wallet address

Sent when a transfer to a player's wallet address is final on-chain. Tron is scanned only up to its solidified block, so this event means the funds cannot be reversed — there is no pending stage and no later reversal to handle. Signed with your API key secret. Deduplicate on deposit_id, which is stable for one transfer forever; a transaction carrying two transfers to the same address produces two events with different deposit_id values. Credit the player credited: it is amount, what arrived on chain, minus the processing fee for that token, which is in fee. Your merchant commission is not taken from the player and is not in this event; it is settled with you separately. A transfer at or below the processing fee is not credited and sends no event: the whole amount is kept as the fee. amount_raw is credited in base units.

Body

{
  "event": "deposit.confirmed",
  "deposit_id": "dep_9aa6260d-84ba-45a7-9cc0-1ac1acaa664a",
  "merchant_id": "acme-casino",
  "player_id": "player-1042",
  "address": "TFbu1gKVsrs6c96r3FyuD9aUs3Gdi1HmH3",
  "network": "tron",
  "token": "USDT",
  "amount": "50.5",
  "amount_raw": "49500000",
  "credited": "49.5",
  "fee": "1",
  "tx_hash": "3ac701fcbb54dab1f84fd9285cadd9beccdd0d72c6b2d950087f681a0db30bb1",
  "block": 86395740,
  "occurred_at": "2026-09-20T00:01:59Z"
}

Response 200

Any 2xx marks the delivery successful. Anything else is retried with backoff: 30s, 1m, 2m … 1h, up to 8 attempts.

POST payout.completed no signature

A payout settled

Sent when a payout's relay transaction confirms, or reverts (event payout.failed). Carries the payout in place of the session. An item of a batch also carries batch_id and batch_index.

Body

{
  "event": "payout.completed",
  "payout_id": "01J8ZR2N7P5Y1H4K8M3Q6V0X2D",
  "merchant_id": "acme-casino",
  "external_id": "WD-2026-004417",
  "network": "tron",
  "token": "USDT",
  "amount": "1200.000000",
  "recipient": "TQ5nX8wKcJd2vB7hR3mF9pLzY4tGaU6eWq",
  "status": "completed",
  "tx_hash": "1a3b5c7d9e1f2a4b6c8d0e2f4a6b8c0d2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2b",
  "occurred_at": "2026-08-05T12:43:22Z"
}

Response 200

Any 2xx marks the delivery successful.

Handling them safely

  1. Verify the signature first, before parsing or trusting anything in the body.
  2. Deduplicate on X-Idempotency-Key. You will receive the same logical event more than once — that is the retry policy working, not a bug.
  3. Answer fast. Return 2xx as soon as you have durably recorded the event, and do your own work afterwards.
  4. Credit on the webhook, not on a browser redirect or an iframe event. Those tell you what to show the player; this one tells you what happened.
  5. Reconcile what never arrived. A deposit or payout still open well after it should have settled can be read with the session or payout status endpoint and processed the same way.