ExpressPay API

Вебхуки

Мы сообщаем о завершении платежа HTTP-запросом на ваш адрес. Адрес задаётся в боте, в разделе «API для бизнеса».

Требования к адресу

События

СобытиеКогда
payment.succeededПлатёж прошёл, списание окончательное
payment.failedПлатёж не прошёл, USDT вернулись на баланс
POST /your/webhook HTTP/1.1
Content-Type: application/json
X-ExpressPay-Event: payment.succeeded
X-ExpressPay-Delivery: whd_3f1c9a2e7b444c1e
X-ExpressPay-Payment-Id: pay_9f3c1a2b4d5e
X-ExpressPay-Signature: t=1789483018,v1=5f2a...c91

{
  "id": "evt_1789483018420",
  "event": "payment.succeeded",
  "created_at": "2026-09-15T10:22:48+00:00",
  "data": { ...объект платежа, как в GET /v1/payments/{id}... }
}

Проверка подписи

Подпись считается по строке "{t}.{сырое тело запроса}" алгоритмом HMAC-SHA256 на вашем секрете вебхуков (виден в боте). Сравнивайте подписи функцией постоянного времени и отбрасывайте запросы старше пяти минут — метка времени внутри подписи защищает от повторной отправки перехваченного запроса.

PHP

function verify(string $body, string $header, string $secret): bool {
    parse_str(str_replace(',', '&', $header), $parts);   // t=…, v1=…
    $t = $parts['t'] ?? '';
    if (abs(time() - (int)$t) > 300) return false;
    $expected = hash_hmac('sha256', $t . '.' . $body, $secret);
    return hash_equals($expected, $parts['v1'] ?? '');
}

Python

import hashlib, hmac, time

def verify(body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    if abs(time.time() - int(parts["t"])) > 300:
        return False
    expected = hmac.new(
        secret.encode(), f'{parts["t"]}.'.encode() + body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])

Node.js

import crypto from "node:crypto";

function verify(body, header, secret) {
  const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
  const expected = crypto.createHmac("sha256", secret)
                         .update(`${parts.t}.${body}`).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
Подпись считается по сырому телу Берите тело запроса до разбора JSON. Если сначала распарсить, а потом сериализовать обратно, порядок ключей и пробелы изменятся, и подпись не сойдётся.

Повторные доставки

Если ваш сервер не ответил 2xx, мы повторяем доставку девять раз с нарастающими паузами: сразу, через 10 и 30 секунд, 2, 5 и 15 минут, 1, 3 и 6 часов. После этого доставка помечается неудачной, а мы пишем вам в бот.

Если подряд не доставляются три события, отправка вебхуков приостанавливается, чтобы не копить очередь в никуда. Статусы в это время по-прежнему доступны через GET /v1/payments/{id}. Включить обратно — кнопкой «Проверить вебхук» в боте.

Что учесть в обработчике