Перейти к содержимому
API-документация

События и вебхуки

Как узнавать об итогах операций — опросом очереди или push-доставкой на ваш адрес.

Выпуск и пополнение асинхронные: итог приходит позже ответа. Узнать его можно тремя способами — используйте событийный канал как основной и GET /operations/{id} как страховку.

СпособКогда подходит
Вебхуки — мы отправляем событие на ваш HTTPS-адресОсновной вариант: итог приходит примерно через секунду
Очередь — GET /events + POST /events/{id}/ackНет публичного адреса или нужен полный контроль над темпом
Сверка — GET /operations/{id}После перезапуска сервиса или если событие не пришло

Очередь и вебхуки независимы: событие попадает в очередь всегда, даже если вы получили его вебхуком. Подтверждайте его в очереди, если её читаете.

Типы событий

typeКогда
card.issuedВыпуск завершился успешно
card.issue_failedВыпуск не удался, резерв снят
card.topped_upПополнение завершилось успешно
card.topup_failedПополнение не удалось, резерв снят
card.closedКарта закрыта — вашим запросом, нашим оператором или эмитентом
card.balance_reclaimedОстаток закрытой карты зачислен на ваш баланс
operation.needs_reviewОперация требует ручного разбора. Не повторяйте её

Формат события

Одинаковый в очереди и в теле вебхука (в очереди добавляется delivery_count):

{
  "id": "e0f1a2b3-c4d5-4e6f-9a7b-8c9d0e1f2a3b",
  "type": "card.issued",
  "operation_id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f",
  "card_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
  "payload": {
    "operation_id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f",
    "kind": "issue",
    "status": "succeeded",
    "card_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
    "net_amount": "50"
  },
  "created_at": "2026-10-07T09:13:41Z"
}

Содержимое payload

{
  "operation_id": "d4e5f6a7-b8c9-4d0e-8f1a-2b3c4d5e6f7a",
  "kind": "topup",
  "status": "succeeded",
  "card_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
  "net_amount": "100"
}
{
  "operation_id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f",
  "kind": "issue",
  "status": "failed",
  "net_amount": "50",
  "error_code": "provider_unavailable"
}

Значения error_code — в описании поля error_code у GET /operations/{id}.

{
  "operation_id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f",
  "kind": "issue",
  "status": "needs_review",
  "net_amount": "50",
  "error_code": "needs_review",
  "retry_safe": false
}
{
  "operation_id": "f6a7b8c9-d0e1-4f2a-9b3c-4d5e6f7a8b9c",
  "card_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
  "kind": "close",
  "status": "succeeded",
  "closed_by": "provider",
  "residual_returns_separately": true
}

closed_by есть, только если карту закрыли не вы: admin — наш оператор, provider — эмитент. Остаток придёт отдельным событием card.balance_reclaimed.

{
  "operation_id": "f6a7b8c9-d0e1-4f2a-9b3c-4d5e6f7a8b9c",
  "card_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
  "residual": "37.4",
  "fee": "0",
  "amount": "37.4"
}

amount зачислен на ваш баланс: residual − fee.

Доставка at-least-once

Одно событие может прийти больше одного раза: после сбоя вашего обработчика, после таймаута вебхука, из очереди и вебхуком одновременно. Делайте обработку идемпотентной: запоминайте id обработанных событий, а состояние заказа двигайте по operation_id и status — повторное «succeeded» не должно ничего менять.

Вебхуки

Подписка

BODY='{"url":"https://partner.example.com/hooks/cards"}'
TS=$(date +%s)
SIG=$(printf '%s\n%s\n%s\n%s' "$TS" "POST" "/api/partner/v1/webhooks" "$BODY" \
  | openssl dgst -sha256 -hmac "$PP_SECRET" -hex | awk '{print $NF}')

curl -sS -X POST "https://api.plativputi.com/api/partner/v1/webhooks" \
  -H "X-Key-Id: $PP_KEY_ID" -H "X-Timestamp: $TS" -H "X-Signature: $SIG" \
  -H "Content-Type: application/json" -d "$BODY"
const { webhook, secret } = await pp("POST", "/webhooks", {
  url: "https://partner.example.com/hooks/cards",
});
// secret показывается один раз — сохраните его в менеджер секретов
created = pp("POST", "/webhooks", body={"url": "https://partner.example.com/hooks/cards"})
secret = created["secret"]  # показывается один раз
$created = pp('POST', '/webhooks', ['url' => 'https://partner.example.com/hooks/cards']);
$secret = $created['secret']; // показывается один раз

event_types не передан — придут все типы. Адрес — только HTTPS и только публичный.

Запрос, который придёт к вам

POST /hooks/cards HTTP/1.1
Host: partner.example.com
Content-Type: application/json
User-Agent: plativputi-webhooks/1
X-Timestamp: 1759828421
X-Signature: 5d1c0f3b9e…

{"id":"e0f1a2b3-c4d5-4e6f-9a7b-8c9d0e1f2a3b","type":"card.issued","operation_id":"c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f","card_id":"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d","payload":{…},"created_at":"2026-10-07T09:13:41Z"}

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

Подпись — та же конструкция, что у ваших запросов к нам, но на секрете подписки и всегда с методом POST:

hex( HMAC-SHA256( secret, X-Timestamp \n POST \n <путь с query> \n <сырое тело> ) )

Проверяйте подпись по сырому телу до разбора JSON, сравнивайте за постоянное время и отклоняйте слишком старое время.

import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

const app = express();
const SECRET = process.env.PP_WEBHOOK_SECRET;

app.post("/hooks/cards", express.raw({ type: "application/json" }), (req, res) => {
  const ts = req.get("X-Timestamp") ?? "";
  const got = Buffer.from(req.get("X-Signature") ?? "", "hex");
  const want = createHmac("sha256", SECRET)
    .update(`${ts}\nPOST\n${req.originalUrl}\n`)
    .update(req.body) // Buffer — сырые байты
    .digest();

  const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300;
  if (!fresh || got.length !== want.length || !timingSafeEqual(got, want)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString("utf8"));
  enqueue(event); // обработайте асинхронно и идемпотентно по event.id
  res.sendStatus(204);
});
import hashlib
import hmac
import json
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["PP_WEBHOOK_SECRET"].encode()


@app.post("/hooks/cards")
def cards_webhook():
    ts = request.headers.get("X-Timestamp", "")
    got = request.headers.get("X-Signature", "").encode()
    raw = request.get_data()  # сырые байты
    # Путь — как он пришёл по сети, без декодирования %xx; с query, если есть.
    path = request.environ.get("RAW_URI") or request.environ.get("REQUEST_URI")
    if not path:  # сервер не отдал сырой путь — подпись не проверить надёжно
        raise RuntimeError("enable RAW_URI/REQUEST_URI in your WSGI server")
    msg = f"{ts}\nPOST\n{path}\n".encode() + raw
    want = hmac.new(SECRET, msg, hashlib.sha256).hexdigest().encode()

    fresh = ts.isdigit() and abs(time.time() - int(ts)) < 300
    if not fresh or not hmac.compare_digest(got, want):
        abort(401)

    enqueue(json.loads(raw))  # обработайте асинхронно и идемпотентно по id
    return "", 204
<?php
$secret = getenv('PP_WEBHOOK_SECRET');
$ts = $_SERVER['HTTP_X_TIMESTAMP'] ?? '';
$got = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$raw = file_get_contents('php://input'); // сырые байты
$path = $_SERVER['REQUEST_URI'];          // путь вместе с query

$want = hash_hmac('sha256', "$ts\nPOST\n$path\n" . $raw, $secret);
$fresh = ctype_digit($ts) && abs(time() - (int) $ts) < 300;

if (!$fresh || !hash_equals($want, $got)) {
    http_response_code(401);
    exit;
}

enqueue(json_decode($raw, true)); // обработайте асинхронно и идемпотентно по id
http_response_code(204);

Путь — как видит его ваш сервер снаружи

Подписан путь из адреса подписки. Если перед приложением стоит прокси, который переписывает путь (/hooks/cards → /cards), подпись не сойдётся. Проверяйте по исходному пути.

Ответ и повторы

  • Ответьте 2xx быстро — в пределах нескольких секунд. Тяжёлую обработку делайте после ответа.
  • Любой другой ответ, таймаут или ошибка соединения — повтор через 1, 5, 15, 30 и 60 минут. После шестой неудачной попытки доставка прекращается; событие остаётся в очереди GET /events.
  • Редиректы не выполняются: адрес должен отвечать сам.

Что происходило с доставками, видно в журнале — GET /webhooks/{id}/deliveries.

Очередь событий

// Раз в несколько секунд:
const { items } = await pp("GET", "/events?limit=50");
for (const event of items) {
  await handle(event); // идемпотентно
  await pp("POST", `/events/${event.id}/ack`);
}
# Раз в несколько секунд:
for event in pp("GET", "/events?limit=50")["items"]:
    handle(event)  # идемпотентно
    pp("POST", f"/events/{event['id']}/ack")
// Раз в несколько секунд:
foreach (pp('GET', '/events?limit=50')['items'] as $event) {
    handle($event); // идемпотентно
    pp('POST', '/events/' . $event['id'] . '/ack');
}

Подтверждайте событие после обработки. Неподтверждённое событие вернётся снова, и delivery_count вырастет. Подтверждение идемпотентно — повторять безопасно.