События и вебхуки
Как узнавать об итогах операций — опросом очереди или 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 вырастет. Подтверждение идемпотентно — повторять безопасно.