Webhook-уведомления
Получайте обновления статусов платежей и выплат в реальном времени через webhook'и с подписью HMAC.
Система 2328.io отправляет webhook на ваш url_callback каждый раз, когда меняется статус платежа. Это рекомендованный способ узнавать об успешных оплатах.
Формат запроса
- Метод:
POST - Content-Type:
application/json - Подпись: поле
signв теле запроса
Payload
Тело webhook'а повторяет формат ответа /v1/payment/info и дополнительно содержит tx_explorer_url и поле sign для проверки подписи.
Успешный платёж
{
"uuid": "db17d490-15b6-47b9-9015-91d1d8b119f2",
"order_id": "ORDER-12345",
"amount": "180.00000000",
"currency": "RUB",
"url": "https://go.2328.io/db17d490-15b6-47b9-9015-91d1d8b119f2",
"expires_at": "2026-05-09T16:56:58+03:00",
"created_at": "2026-05-09T15:56:58+03:00",
"payer_currency": "TON",
"payer_amount": "0.95256917",
"network": "TON",
"address": "UQA0RevhkCQx-EltyNgPPeG8dqtnCz7ZslOzMdNQlLxVaNBb",
"payment_status": "paid",
"txid": "41c2a327323480af8e705d05deb09c238a41779928832abef4bb77c862357b11",
"tx_explorer_url": "https://tonviewer.com/transaction/41c2a327323480af8e705d05deb09c238a41779928832abef4bb77c862357b11",
"payment_amount": "0.95256917",
"merchant_amount": "0.949711462490000000",
"amount_usd": "2.41324380",
"exchange_rate": "0.01340691",
"sign": "6f8c15b6e53b506d5bfa38ed3fb3b50697af73434262153c02e412541372f04d"
}Отменённый или неуспешный платёж
Если платёж не находится в финальном состоянии paid, поля txid, payment_amount и merchant_amount равны null:
{
"uuid": "48edaf2d-2c49-4638-8f86-88636f661c1f",
"order_id": "ORDER-12345",
"amount": "2800.00000000",
"currency": "RUB",
"url": "https://go.2328.io/48edaf2d-2c49-4638-8f86-88636f661c1f",
"expires_at": "2026-05-09T06:19:04+03:00",
"created_at": "2026-05-09T05:19:04+03:00",
"payer_currency": "ETH",
"payer_amount": "0.01620968",
"network": "ETH-ERC20",
"address": "0x37c20d6d96d130Bc5B33D832e43b8e16aACe0c59",
"payment_status": "cancel",
"txid": null,
"tx_explorer_url": null,
"payment_amount": null,
"merchant_amount": null,
"amount_usd": "37.53934800",
"exchange_rate": "0.01340691",
"sign": "40ce68ad9691ad54e684329d75ab5adaf5b01409a2d18d3e0110b8c1be605342"
}Описание полей
| Поле | Тип | Описание |
|---|---|---|
uuid | string | UUID платежа |
order_id | string | Ваш ID заказа |
amount | decimal (8 знаков) | Сумма в фиате currency |
currency | string | Фиатная валюта, в которой мерчант создал счёт |
url | string | URL хостед-чекаута |
expires_at | string (ISO 8601) | Когда истекает платёжная сессия |
created_at | string (ISO 8601) | Когда была создана платёжная сессия |
payer_currency | string | Криптовалюта, которой платит плательщик |
payer_amount | decimal (8 знаков) | Ожидаемая сумма в крипте |
network | string | Блокчейн-сеть |
address | string | Адрес депозита |
payment_status | string | Одно из: pending, check, paid, underpaid_check, underpaid, overpaid, cancel, aml_lock (см. References) |
txid | string | null | Хеш транзакции в блокчейне, появляется только после подтверждения оплаты |
tx_explorer_url | string | null | Ссылка на транзакцию в блокчейн-эксплорере. null, если txid отсутствует или перевод выполнен как внутренний P2P. |
payment_amount | decimal | null | Фактически оплаченная сумма, появляется только после оплаты |
merchant_amount | decimal (18 знаков) | null | Сумма, зачисленная мерчанту после комиссий |
amount_usd | decimal (8 знаков) | Сумма в USD на момент создания |
exchange_rate | decimal | Использованный курс крипто / фиат |
sign | string (hex) | Подпись HMAC-SHA256 payload'а |
Проверка подписи
Чтобы проверить подпись webhook'а:
- Извлеките поле
signиз payload'а - Удалите поле
signиз объекта - Закодируйте оставшиеся поля как JSON
- Закодируйте JSON в Base64
- Рассчитайте HMAC-SHA256 от строки Base64 с использованием API_KEY
- Сравните полученную подпись со значением
signфункцией постоянного времени
<?php
function verifyWebhookSign(array $data, string $apiKey): bool {
$receivedSign = $data['sign'] ?? '';
unset($data['sign']);
$json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$base64 = base64_encode($json);
$calculated = hash_hmac('sha256', $base64, $apiKey);
return hash_equals($calculated, $receivedSign);
}
$apiKey = 'YOUR_API_KEY';
$payload = json_decode(file_get_contents('php://input'), true);
if (!verifyWebhookSign($payload, $apiKey)) {
http_response_code(401);
exit;
}
switch ($payload['payment_status']) {
case 'paid':
case 'overpaid':
// Credit the order — check idempotency by order_id first
break;
case 'underpaid_check':
case 'underpaid':
case 'cancel':
break;
}
http_response_code(200);import crypto from "crypto";
import express from "express";
const app = express();
app.use(express.json());
function verifyWebhookSign(payload, apiKey) {
const { sign, ...rest } = payload;
const json = JSON.stringify(rest);
const base64 = Buffer.from(json).toString("base64");
const calculated = crypto
.createHmac("sha256", apiKey)
.update(base64)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(calculated),
Buffer.from(sign || ""),
);
}
app.post("/webhook", (req, res) => {
if (!verifyWebhookSign(req.body, process.env.API_KEY)) {
return res.sendStatus(401);
}
const { order_id, payment_status, txid } = req.body;
if (payment_status === "paid" || payment_status === "overpaid") {
// Credit the order — check idempotency by order_id first
}
res.sendStatus(200);
});import json
import hmac
import hashlib
import base64
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
API_KEY = "YOUR_API_KEY"
def verify_webhook_sign(payload: dict, api_key: str) -> bool:
received = payload.pop("sign", "")
body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
b64 = base64.b64encode(body.encode("utf-8")).decode()
calculated = hmac.new(api_key.encode(), b64.encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(calculated, received)
@app.post("/webhook")
async def webhook(request: Request):
payload = await request.json()
if not verify_webhook_sign(payload, API_KEY):
raise HTTPException(401)
if payload["payment_status"] in ("paid", "overpaid"):
# Credit the order — check idempotency by order_id first
pass
return {"ok": True}package main
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"io"
"net/http"
)
func verifyWebhookSign(body []byte, apiKey string) (map[string]any, bool) {
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
return nil, false
}
received, _ := payload["sign"].(string)
delete(payload, "sign")
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.Encode(payload)
reencoded := bytes.TrimRight(buf.Bytes(), "\n")
b64 := base64.StdEncoding.EncodeToString(reencoded)
h := hmac.New(sha256.New, []byte(apiKey))
h.Write([]byte(b64))
calculated := hex.EncodeToString(h.Sum(nil))
return payload, hmac.Equal([]byte(calculated), []byte(received))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
payload, ok := verifyWebhookSign(body, apiKey)
if !ok {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
status, _ := payload["payment_status"].(string)
if status == "paid" || status == "overpaid" {
// Credit the order — check idempotency first
}
w.WriteHeader(http.StatusOK)
}require "json"
require "openssl"
require "base64"
require "sinatra"
API_KEY = "YOUR_API_KEY"
def verify_webhook_sign(payload, api_key)
received = payload.delete("sign") || ""
body = payload.to_json
b64 = Base64.strict_encode64(body)
calculated = OpenSSL::HMAC.hexdigest("SHA256", api_key, b64)
OpenSSL.fixed_length_secure_compare(calculated, received)
end
post "/webhook" do
payload = JSON.parse(request.body.read)
halt 401 unless verify_webhook_sign(payload, API_KEY)
if %w[paid overpaid].include?(payload["payment_status"])
# Credit the order — check idempotency by order_id first
end
status 200
endВсегда проверяйте подпись перед зачислением средств пользователю. Webhook без подписи или с неверной подписью может быть подделкой.
Webhook'и выплат
При смене status выплаты система отправляет POST webhook на URL url_callback, переданный при создании выплаты. Если url_callback не был указан, webhook'и для этой выплаты не отправляются.
Webhook'и выплат должны проверяться Payout API key, а не обычным API key. Алгоритм подписи идентичен webhook'ам платежей (удалить sign, JSON-кодирование, base64, HMAC-SHA256), отличается только ключ.
Payload
{
"uuid": "019dff1f-0dbd-7277-8d45-271e7775388f",
"order_id": "4dfdcc84402b1185b71cbe399321533e",
"status": "completed",
"currency": "TRX",
"network": "TRX-TRC20",
"amount": "3.00",
"merchant_amount": "3.00",
"network_amount": "3.00",
"amount_usd": "1.04",
"to_address": "THauRv5tcucQRohXg8NiyGTk16DX1XQG5x",
"memo": null,
"txid": "9242e533703704ef3eaba840f70b4a26333e72c943377ee375fea17badb53def",
"tx_explorer_url": "https://tronscan.org/#/transaction/9242e533703704ef3eaba840f70b4a26333e72c943377ee375fea17badb53def",
"block_number": null,
"error_type": null,
"created_at": "2026-05-07T00:08:38+03:00",
"updated_at": "2026-05-07T00:08:54+03:00",
"from_currency": "USDT",
"debited_amount": "1.050735",
"debited_currency": "USDT",
"sign": "925ad7bf3d6841864101f7cc2c7e30652e70a06cdb04dbe07a0129480000ce4a"
}Описание полей
| Поле | Тип | Описание |
|---|---|---|
uuid | string | UUID выплаты |
order_id | string | Ваш ID идемпотентности / референс, если был передан |
status | string | pending, completed, failed, cancelled (см. References) |
currency | string | Валюта вывода |
network | string | Блокчейн-сеть |
amount | decimal | Сумма вывода (в currency) |
merchant_amount | decimal | Сумма, списанная с баланса мерчанта |
network_amount | decimal | Сумма, фактически отправленная в сеть |
amount_usd | decimal | Стоимость в USD на момент выплаты |
to_address | string | Блокчейн-адрес получателя |
memo | string | null | Memo / destination tag, если использовался |
txid | string | null | Хеш транзакции в блокчейне, заполняется при completed |
tx_explorer_url | string | null | Ссылка на транзакцию в блокчейн-эксплорере. null, если txid отсутствует или перевод выполнен как внутренний P2P. |
block_number | integer | null | Высота блока on-chain транзакции |
error_type | string | null | Причина при status = failed (например, aml_risk, см. References) |
created_at | string (ISO 8601) | Когда была создана выплата |
updated_at | string (ISO 8601) | Когда статус последний раз менялся |
from_currency | string | Исходный баланс, с которого было списание при авто-конвертации (например, USDT для выплаты в BTC) |
debited_amount | decimal | Сумма, списанная с баланса from_currency |
debited_currency | string | Валюта списания |
sign | string (hex) | Подпись HMAC-SHA256 payload'а, рассчитанная Payout API key |
Лучшие практики
- Идемпотентность — всегда проверяйте, не был ли платёж уже обработан (по
order_idилиuuid). Webhook'и могут приходить несколько раз. - Быстрый ответ — возвращайте HTTP 200 как можно быстрее. Тяжёлую работу выносите в фоновую очередь.
- Повторы — если система не получит HTTP 200, webhook отправляется повторно через 2 минуты. Максимум 5 попыток повтора.
- Асинхронная обработка — обрабатывайте события webhook'ов асинхронно, чтобы не блокировать ответ.
- Безопасность — ВСЕГДА проверяйте подпись
sign, прежде чем доверять payload'у.
Webhook'и могут приходить не по порядку. Не считайте, что первый полученный webhook отражает финальное состояние — при необходимости перепроверяйте через /v1/payment/info (или /v1/payout/status/{uuid}).
Правила доставки и обработки
Обрабатывайте запросы на webhook-эндпоинте в следующем порядке:
- Прочитайте тело запроса, не записывая в логи секреты и полное значение подписи.
- Определите тип события: платёж или статический кошелёк либо выплата. От этого зависит выбор правильного API-ключа.
- Удалите
sign, воспроизведите документированное JSON-представление, вычислите HMAC-SHA256 и сравните подписи за постоянное время. - Проверьте обязательные идентификаторы, десятичные строки и значения статуса.
- Атомарно создайте запись входящего события с ключом идемпотентности. Если запись уже существует, верните HTTP 200 без повторного выполнения побочных эффектов.
- В одной транзакции зафиксируйте изменение заказа или реестра, а некритичные письма, аналитику и уведомления поставьте в очередь.
- Быстро верните HTTP 200.
Не вызывайте медленные внешние сервисы внутри транзакции идемпотентной обработки. Тайм-аут после фиксации изменений, но до отправки ответа, может вызвать повторную доставку; дубликат должен обнаружить сохранённый ключ входящего события и ничего не менять.
Рекомендуемые ключи идемпотентности
| Событие | Основной идентификатор | Примечание |
|---|---|---|
| Платёжная сессия | uuid + статус или версия | У одного счёта может быть несколько корректных изменений статуса. |
| Частичная оплата | uuid счёта + txid | К одному недоплаченному счёту могут относиться несколько переводов. |
| Депозит статического кошелька | сеть + txid | Один order_id повторно используется для каждого пополнения этого кошелька. |
| Выплата | uuid выплаты + статус | Логика повторной обработки webhook никогда не должна создавать вторую выплату. |
Если схема не содержит отдельного идентификатора события, сохраните хеш проверенного тела запроса как дополнительное аудиторское доказательство. Не заменяйте перечисленные бизнес-идентификаторы временной меткой.
Порядок событий и сверка
Доставка выполняется как минимум один раз, а уведомления о статусах могут прийти не по порядку. Используйте монотонные бизнес-правила, а не подход «последний запрос побеждает»:
- не возвращайте исполненный заказ в
checkтолько потому, что старое событие пришло с опозданием; - разрешайте
underpaid_checkпринимать дополнительныеtxid, не повторяя прежние зачисления; - считайте
paidиoverpaidуспешными финансовыми состояниями, но сохраняйте различия в фактических суммах; - сохраняйте
underpaidкак окончательный результат частичной оплаты, если авторитетный API позже не сообщил другое состояние; - отправляйте
aml_lockна проверку и не позволяйте обычному обработчику повторов автоматически исполнить заказ; - запрашивайте актуальные данные платежа или выплаты, если переход невозможен, не хватает контекста либо финансовый результат неоднозначен.
Выполняйте периодическую сверку, даже если доставка webhook работает без видимых сбоев. Сравнивайте конечный локальный статус и зачисленную сумму с /v1/payment/info, /v1/static-wallet/transactions или /v1/payout/status/{uuid}. При расхождении создавайте предупреждение, а не перезаписывайте историю финансового реестра молча.
Безопасность webhook-эндпоинта
- Требуйте HTTPS и оставляйте callback доступным из интернета; адреса из приватных диапазонов и loopback отклоняются при создании платежа.
- Ограничьте размер тела запроса и принимайте JSON.
- Применяйте rate limit до ресурсоёмкой обработки, но оставляйте запас для легитимных всплесков и повторных доставок.
- Не авторизуйте webhook только по IP-адресу источника. Сетевой allowlist — дополнительный уровень защиты; проверка HMAC обязательна.
- Удаляйте из логов
sign, API-ключи, адреса, если этого требует политика, и персональные метаданные. - Во время контролируемой ротации поддерживайте текущий и явно назначенный следующий ключ; не пытайтесь угадывать, каким ключом подписано событие.
- При неверной подписи возвращайте общее сообщение об ошибке, чтобы эндпоинт не раскрывал сведения о ключе или аккаунте.