# Webhook-сповіщення

> Отримуйте оновлення статусу платежів та виплат у режимі реального часу через webhooks, підписані 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 dp) | Фіатна сума у `currency` |
| `currency` | string | Фіатна валюта, яку запитав мерчант |
| `url` | string | URL хостингового checkout |
| `expires_at` | string (ISO 8601) | Коли спливає термін платіжної сесії |
| `created_at` | string (ISO 8601) | Коли було створено платіжну сесію |
| `payer_currency` | string | Криптовалюта, якою сплачує платник |
| `payer_amount` | decimal (8 dp) | Очікувана сума криптовалюти |
| `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 dp) \| null | Сума, зарахована мерчанту після комісій |
| `amount_usd` | decimal (8 dp) | Сума у USD на момент створення |
| `exchange_rate` | decimal | Використаний курс обміну crypto / fiat |
| `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 може бути підробленим запитом.

## Webhooks виплат

Коли `status` виплати змінюється, система надсилає `POST` webhook на URL `url_callback`, переданий під час створення виплати. Якщо `url_callback` не було надано, для цієї виплати webhooks не надсилаються.

> **WARNING:** Webhooks виплат мають перевірятися за допомогою вашого **Payout API key** — а не звичайного API key. Алгоритм підпису ідентичний webhooks платежів (видалити `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`). Webhooks можуть надходити кілька разів.
- **Швидка відповідь** — повертайте HTTP 200 якомога швидше. Виносьте важку роботу у фонову чергу.
- **Повторні спроби** — якщо система не отримує HTTP 200, webhook повторно надсилається через 2 хвилини. Максимум 5 спроб повторного надсилання.
- **Асинхронна обробка** — обробляйте події webhook асинхронно, щоб не блокувати відповідь.
- **Безпека** — ЗАВЖДИ перевіряйте підпис `sign` перед тим, як довіряти payload.

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

## Договір на доставку та обробку

Використовуйте наступний порядок всередині вашого webhook-ендпойнта:

1. Прочитайте тіло запиту, не зберігаючи секрети та повний підпис у логах.
2. Визначте, чи це подія платежу/статичного гаманця або подія виплати, щоб вибрати правильний API-ключ.
3. Видаліть `sign`, відтворіть документовані байти JSON, обчисліть HMAC-SHA256 і порівняйте у постійний час.
4. Перевірте обов'язкові ідентифікатори, десяткові рядки та значення статусу.
5. Атомарно вставте запис у папку входящих/idempotency. Якщо він вже існує, поверніть HTTP 200 без повторення побічних ефектів.
6. Зафіксуйте мутацію замовлення/книги обліку та поставте в чергу некритичні електронні листи, аналітику або сповіщення.
7. Поверніть HTTP 200 швидко.

Не викликайте повільні сторонні служби під час утримання транзакції ідемпотентності. Таймаут після коміту, але до повернення, може спричинити повторну спробу; дубль має враховувати зафіксований ключ вхідної пошти і стати операцією без змін (no-op).

### Рекомендовані ключі ідемпотентності

| Подія | Основна ідентичність | Примітки |
|-------|------------------|-------|
| Сесія оплати | `uuid` + докази статусу/версії | Та сама рахунок-фактура може випускати кілька законних змін статусу. |
| Часткове поповнення платежу | рахунок `uuid` + `txid` | Більше ніж один переказ може належати до одного недостатньо сплаченого рахунку. |
| Депозит статичного гаманця | мережа + `txid` | `order_id` використовується повторно для кожного депозиту в цей гаманець. |
| Виплата | виплата `uuid` + статус | Ніколи не створюйте другий платіж через повторну логіку вебхука. |

Якщо у вашій схемі немає ідентифікатора події, збережіть перевірений хеш навантаження як додаткове аудиторське підтвердження, але не замінюйте вищевказані бізнес-ідентифікатори на мітку часу.

## Сортування та звіряння

Доставка відбувається принаймні один раз, і повідомлення про статус можуть приходити в різному порядку. Реалізуйте монотонні бізнес-правила замість «перемагає останній запит»:

- ніколи не переміщуйте виконане замовлення назад до `check`, якщо старіша подія надійшла пізно;
- дозвольте `underpaid_check` отримувати додаткові txid без повторення попередніх зарахувань;
- розглядайте `paid` та `overpaid` як успішні стани врегулювання, зберігаючи їх різні суми;
- тримаєте `underpaid` як остаточний результат часткової оплати, якщо авторитетний API пізніше не повідомить інший стан;
- направляйте `aml_lock` на перевірку і не дозволяйте загальному працівнику повторного виконання завершити його;
- запитуйте інформацію про оплату/виплати щоразу, коли перехід неможливий, відсутній контекст або фінансово неоднозначний.

Виконуйте заплановану звірку навіть якщо доставка вебхука виглядає нормальною. Порівнюйте стан локального терміналу та нараховану суму з `/v1/payment/info`, `/v1/static-wallet/transactions` або `/v1/payout/status/{uuid}` та повідомляйте про відмінності замість тихого перезаписування історії книги обліку.

## Безпека кінцевої точки вебхука

- Вимагайте HTTPS та зберігайте callback публічно доступним; приватні/loopback цілі callback відхиляються під час створення платежу.
- Забезпечте невеликий ліміт на тіло запиту та тип вмісту JSON.
- Обмежуйте швидкість перед дорогими операціями, але залишайте достатньо запасу для легітимних пікових навантажень та повторних спроб.
- Ніколи не авторизуйте вебхук лише за джерелом IP. Дозвільні списки мережі є захистом у глибину; перевірка HMAC є обов'язковою.
- Редагуйте `sign`, ключі API, адреси, коли це вимагає політика, та особисті метадані з логів застосунку.
- Тримайте як поточні, так і явно заплановані замінні ключі доступними під час контрольованого вікна ротації; ніколи не вгадуйте, який ключ підписав подію.
- Повертайте загальне повідомлення про помилку при недійсних підписах, щоб кінцева точка не стала оракулом ключа або облікового запису.