Вебхуки
Мы отправляем POST JSON на адрес, указанный в кабинете (Интеграция → Подключение, только https). На него приходят все события. Ожидаем ответ 2xx в течение 10 с; иначе повторяем через 1 мин, 5 мин, 30 мин, 2 ч, 12 ч, 24 ч. Журнал и ручной повтор — в кабинете. Обработчик должен быть идемпотентным по id события.
| Событие | Когда | data |
|---|---|---|
| payment.pending | Платёж создан | payment |
| payment.authorized | Холд по карте | payment |
| payment.succeeded | Платёж оплачен | payment |
| payment.canceled | Платёж отклонён или отменён | payment |
| payment.expired | Платёж просрочен | payment |
| refund.succeeded | Возврат выполнен | refund |
| refund.failed | Возврат не выполнен | refund |
| receipt.ready | Чек готов | receipt |
| payment_method.saved | Способ оплаты сохранён для автосписаний | payment_method |
| payout.succeeded | Выплата выполнена | payout |
| payout.failed | Выплата не выполнена | payout |
Обрабатывайте нужные события, остальные просто подтверждайте ответом 200. В событиях платежа data.order_id — номер заказа, переданный при создании.
Пример тела:
{
"id": "evt_Ab12Cd34Ef56Gh78Ij90",
"type": "payment.succeeded",
"created_at": "2026-09-22T12:05:00+03:00",
"data": {
"id": "pay_3kZ1x9QmT0aBcDeFgHiJkL",
"object": "payment",
"shop_id": "723612",
"mode": "live",
"test": false,
"status": "succeeded",
"amount": 100000,
"currency": "RUB",
"captured_amount": 100000,
"refunded_amount": 0,
"order_id": "1001",
"description": "Заказ 1001",
"capture_mode": "auto",
"method": "card",
"confirmation": {
"type": "hosted"
},
"payment_url": "https://platezhka.online/pay/pay_3kZ1x9QmT0aBcDeFgHiJkL",
"return_url": "https://shop.example/orders/1001",
"customer": {
"id": "user-42",
"email": "buyer@example.com"
},
"items": [
{
"itemId": 1,
"text": "Товар",
"price": 100000,
"quantity": 1
}
],
"metadata": {},
"source": "api",
"receipt_status": null,
"paygine_order_id": 3504676,
"expires_at": "2026-09-22T13:00:00+03:00",
"created_at": "2026-09-22T12:00:00+03:00",
"paid_at": "2026-09-22T12:05:00+03:00"
}
}
Проверка подписи
Заголовки: X-Event, X-Delivery-Id, X-Signature: t=<unix>,v1=<hex>, где v1 = HMAC-SHA256(ключ, "<t>.<тело>"), а ключ — секретный ключ API магазина (sk_test_… или sk_live_…), как в CloudPayments. Отдельного секрета для вебхуков нет. Отклоняйте запросы старше 5 минут.
require "openssl"
def valid_signature?(secret, body, header)
parts = header.split(",").to_h { |kv| kv.split("=", 2) }
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{parts["t"]}.#{body}")
(Time.now.to_i - parts["t"].to_i).abs < 300 && Rack::Utils.secure_compare(expected, parts["v1"])
end
import hmac, hashlib, time
def valid_signature(secret: str, body: bytes, header: str) -> bool:
parts = dict(kv.split("=", 1) for kv in header.split(","))
expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + body, hashlib.sha256).hexdigest()
return abs(time.time() - int(parts["t"])) < 300 and hmac.compare_digest(expected, parts["v1"])
Готовый обработчик
Проверка подписи и обработка payment.succeeded целиком:
<?php
$secret = 'sk_test_...';
$body = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_X_SIGNATURE'] ?? ''), $sig);
$expected = hash_hmac('sha256', ($sig['t'] ?? '') . '.' . $body, $secret);
if (abs(time() - (int) ($sig['t'] ?? 0)) > 300 || !hash_equals($expected, $sig['v1'] ?? '')) {
http_response_code(400);
exit;
}
$event = json_decode($body, true);
if ($event['type'] === 'payment.succeeded') {
$payment = $event['data'];
// Найдите заказ по $payment['order_id'], сверьте $payment['amount'] и отметьте его оплаченным.
// Событие может прийти повторно: обработка должна быть идемпотентной по $event['id'].
}
http_response_code(200);
import hashlib, hmac, time
from flask import Flask, request
app = Flask(__name__)
SECRET = "sk_test_..."
@app.post("/webhooks/platezhka")
def webhook():
parts = dict(kv.split("=", 1) for kv in request.headers.get("X-Signature", "").split(",") if "=" in kv)
signed = f"{parts.get('t', '')}.".encode() + request.get_data()
expected = hmac.new(SECRET.encode(), signed, hashlib.sha256).hexdigest()
fresh = abs(time.time() - int(parts.get("t", 0))) < 300
if not fresh or not hmac.compare_digest(expected, parts.get("v1", "")):
return "", 400
event = request.get_json()
if event["type"] == "payment.succeeded":
payment = event["data"]
# Найдите заказ по payment["order_id"], сверьте payment["amount"] и отметьте его оплаченным.
# Событие может прийти повторно: обработка должна быть идемпотентной по event["id"].
return "", 200
import crypto from "node:crypto"
import express from "express"
const app = express()
const SECRET = "sk_test_..."
app.post("/webhooks/platezhka", express.raw({ type: "application/json" }), (req, res) => {
const parts = Object.fromEntries((req.get("X-Signature") || "").split(",").map((kv) => kv.split("=")))
const expected = crypto.createHmac("sha256", SECRET).update(`${parts.t}.`).update(req.body).digest("hex")
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300
const given = Buffer.from(parts.v1 || "")
if (!fresh || given.length !== expected.length || !crypto.timingSafeEqual(given, Buffer.from(expected))) {
return res.sendStatus(400)
}
const event = JSON.parse(req.body)
if (event.type === "payment.succeeded") {
const payment = event.data
// Найдите заказ по payment.order_id, сверьте payment.amount и отметьте его оплаченным.
// Событие может прийти повторно: обработка должна быть идемпотентной по event.id.
}
res.sendStatus(200)
})
Дополнительные адреса через API
GET /webhook_endpoints, POST /webhook_endpoints {url, events} (без events — все события, кроме payment.pending; подпись — тем же секретным ключом), DELETE /webhook_endpoints/{id}.