# Webhook-уведомления

> Получайте обновления статусов платежей и выплат в реальном времени через webhook'и с подписью HMAC.

Система 2328.io отправляет webhook на ваш `url_callback` каждый раз, когда меняется статус платежа. Это рекомендованный способ узнавать об успешных оплатах.

## Формат запроса

- **Метод:** `POST`
- **Content-Type:** `application/json`
- **Подпись:** поле `sign` в теле запроса

## Payload

Тело webhook'а повторяет формат ответа `/v1/payment/info` и дополнительно содержит `tx_explorer_url` и поле `sign` для проверки подписи.

### Успешный платёж

```json
{
  "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`:

```json
{
  "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](/docs/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'а:

1. Извлеките поле `sign` из payload'а
2. Удалите поле `sign` из объекта
3. Закодируйте оставшиеся поля как JSON
4. Закодируйте JSON в Base64
5. Рассчитайте HMAC-SHA256 от строки Base64 с использованием API_KEY
6. Сравните полученную подпись со значением `sign` функцией постоянного времени

#### php

```php
<?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);
```

#### js

```js
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);
});
```

#### python

```python
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}
```

#### go

```go
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)
}
```

#### ruby

```ruby
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
```

> **DANGER:** **Всегда проверяйте подпись** перед зачислением средств пользователю. Webhook без подписи или с неверной подписью может быть подделкой.

## Webhook'и выплат

При смене `status` выплаты система отправляет `POST` webhook на URL `url_callback`, переданный при создании выплаты. Если `url_callback` не был указан, webhook'и для этой выплаты не отправляются.

> **WARNING:** Webhook'и выплат должны проверяться **Payout API key**, а не обычным API key. Алгоритм подписи идентичен webhook'ам платежей (удалить `sign`, JSON-кодирование, base64, HMAC-SHA256), отличается только ключ.

### Payload

```json
{
  "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](/docs/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](/docs/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'у.

> **WARNING:** Webhook'и могут приходить не по порядку. Не считайте, что первый полученный webhook отражает финальное состояние — при необходимости перепроверяйте через `/v1/payment/info` (или `/v1/payout/status/{uuid}`).

## Правила доставки и обработки

Обрабатывайте запросы на webhook-эндпоинте в следующем порядке:

1. Прочитайте тело запроса, не записывая в логи секреты и полное значение подписи.
2. Определите тип события: платёж или статический кошелёк либо выплата. От этого зависит выбор правильного API-ключа.
3. Удалите `sign`, воспроизведите документированное JSON-представление, вычислите HMAC-SHA256 и сравните подписи за постоянное время.
4. Проверьте обязательные идентификаторы, десятичные строки и значения статуса.
5. Атомарно создайте запись входящего события с ключом идемпотентности. Если запись уже существует, верните HTTP 200 без повторного выполнения побочных эффектов.
6. В одной транзакции зафиксируйте изменение заказа или реестра, а некритичные письма, аналитику и уведомления поставьте в очередь.
7. Быстро верните 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-ключи, адреса, если этого требует политика, и персональные метаданные.
- Во время контролируемой ротации поддерживайте текущий и явно назначенный следующий ключ; не пытайтесь угадывать, каким ключом подписано событие.
- При неверной подписи возвращайте общее сообщение об ошибке, чтобы эндпоинт не раскрывал сведения о ключе или аккаунте.