Приём и проверка вебхуков
Каждый раз, когда платёж, возврат или выплата меняет статус, мы отправляем событие на ваш эндпоинт. События подписаны по схеме Standard Webhooks, поэтому вы проверяете их готовой библиотекой — свой код подписи не нужен.
1. Задайте эндпоинт
Заголовок раздела «1. Задайте эндпоинт»Один HTTPS-эндпоинт на проект: публичный хост с именем, порт 443 или 8443. IP-адрес, другой порт, имя
пользователя в URL или фрагмент — это 422 url_not_allowed.
#!/usr/bin/env bash# Point events at your HTTPS address. Keep the secret of the first answer: it is shown once.set -euo pipefail. "$(dirname "$0")/../../signed-request.sh"
body=$(cat <<JSON{ "url": "${WEBHOOK_URL:-https://shop.example.lk/webhooks/processing}"}JSON)mka_request PUT "/v1/webhook-endpoint" "$body"// Point events at your HTTPS address. Keep the secret of the first answer: it is shown once.import { print, request } from "../../signed-request.mjs";
const answer = await request("PUT", "/v1/webhook-endpoint", { body: { url: process.env.WEBHOOK_URL ?? "https://shop.example.lk/webhooks/processing", },});print(answer);<?php// Point events at your HTTPS address. Keep the secret of the first answer: it is shown once.
declare(strict_types=1);
require __DIR__ . '/../../signed_request.php';
mka_show(mka_request( 'PUT', '/v1/webhook-endpoint', [ 'url' => getenv('WEBHOOK_URL') ?: 'https://shop.example.lk/webhooks/processing', ],));"""Point events at your HTTPS address. Keep the secret of the first answer: it is shown once."""import os
from signed_request import request, show
show(request( "PUT", "/v1/webhook-endpoint", body={ "url": os.environ.get("WEBHOOK_URL", "https://shop.example.lk/webhooks/processing"), },))//go:build ignore
// Point events at your HTTPS address. Keep the secret of the first answer: it is shown once.package main
import ( "docs.processing.yolotech.cc/examples/signedrequest")
func main() { signedrequest.Show(signedrequest.Request("PUT", "/v1/webhook-endpoint", map[string]any{ "url": signedrequest.Env("WEBHOOK_URL", "https://shop.example.lk/webhooks/processing"), }, ""))}Первый ответ, 201, содержит секрет подписи whsec_… — один раз. Храните его вместе с другими секретами.
Если позже сменить адрес, ответ будет 200, а секрет останется прежним. Потеряли секрет?
Смените его.
2. Проверьте и подтвердите
Заголовок раздела «2. Проверьте и подтвердите»// Receive webhooks: verify each one with the Standard Webhooks library, act on it once.import { createServer } from "node:http";import { Webhook } from "standardwebhooks";
const webhook = new Webhook(process.env.WEBHOOK_SECRET); // whsec_…, answered once by PUT /v1/webhook-endpointconst handled = new Set(); // webhook-id of the events acted on; keep this in your databaseconst versions = new Map(); // the highest version applied, per payment, refund or payout
createServer((req, res) => { const chunks = []; req.on("data", (chunk) => chunks.push(chunk)); req.on("end", () => { let event; try { // The exact bytes received, before any JSON parsing; the timestamp must be within 5 minutes. event = webhook.verify(Buffer.concat(chunks).toString("utf8"), req.headers); } catch { res.writeHead(400).end(); return; } if (!handled.has(req.headers["webhook-id"])) { handled.add(req.headers["webhook-id"]); apply(event); } res.writeHead(204).end(); // answer 2xx within 15 seconds; do slow work after answering });}).listen(Number(process.env.PORT ?? 8080));
function apply(event) { const [subject, version] = { "payment.status_changed": [event.payment_id, event.payment_version], "refund.status_changed": [event.refund_id, event.refund_version], "payout.status_changed": [event.payout_id, event.payout_version], }[event.type] ?? [event.event_id, 0]; // Events may come late or twice: an older version than the one applied changes nothing. if ((versions.get(subject) ?? 0) > version) return; versions.set(subject, version); console.log(`verified ${event.type} ${subject} v${version} ${event.status ?? ""}`);}<?php// Receive one webhook: verify it with the Standard Webhooks library, act on it once.// Serve it with your web server, or try it with: php -S 0.0.0.0:8080 webhooks/receive.php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use StandardWebhooks\Webhook;use StandardWebhooks\Exception\WebhookVerificationException;
$webhook = new Webhook(getenv('WEBHOOK_SECRET')); // whsec_…, answered once by PUT /v1/webhook-endpoint$raw = file_get_contents('php://input'); // the exact bytes received, before any JSON parsing$headers = array_change_key_case(getallheaders(), CASE_LOWER);try { $event = $webhook->verify($raw, $headers); // the timestamp must be within 5 minutes} catch (WebhookVerificationException) { http_response_code(400); exit;}
// Act once per webhook-id, and never apply an older version over a newer one: keep both in// your database. Events may come late or twice.[$subjectField, $versionField] = match ($event['type']) { 'payment.status_changed' => ['payment_id', 'payment_version'], 'refund.status_changed' => ['refund_id', 'refund_version'], 'payout.status_changed' => ['payout_id', 'payout_version'], default => ['event_id', null],};$version = $versionField === null ? 0 : $event[$versionField];error_log(sprintf('verified %s %s v%d %s', $event['type'], $event[$subjectField], $version, $event['status'] ?? ''));
http_response_code(204); // answer 2xx within 15 seconds; do slow work after answering"""Receive webhooks: verify each one with the Standard Webhooks library, act on it once."""import osfrom http.server import BaseHTTPRequestHandler, HTTPServer
from standardwebhooks.webhooks import Webhook, WebhookVerificationError
webhook = Webhook(os.environ["WEBHOOK_SECRET"]) # whsec_…, answered once by PUT /v1/webhook-endpointhandled = set() # webhook-id of the events acted on; keep this in your databaseversions = {} # the highest version applied, per payment, refund or payout
def apply(event): subject, version = { "payment.status_changed": ("payment_id", "payment_version"), "refund.status_changed": ("refund_id", "refund_version"), "payout.status_changed": ("payout_id", "payout_version"), }.get(event["type"], ("event_id", None)) subject, version = event[subject], event.get(version, 0) if version else 0 # Events may come late or twice: an older version than the one applied changes nothing. if versions.get(subject, 0) > version: return versions[subject] = version print(f"verified {event['type']} {subject} v{version} {event.get('status', '')}", flush=True)
class Receiver(BaseHTTPRequestHandler): def do_POST(self): raw = self.rfile.read(int(self.headers.get("Content-Length", 0))) try: # The exact bytes received; the timestamp must be within 5 minutes. event = webhook.verify(raw, {name.lower(): value for name, value in self.headers.items()}) except WebhookVerificationError: self.send_response(400) self.end_headers() return if self.headers["webhook-id"] not in handled: handled.add(self.headers["webhook-id"]) apply(event) self.send_response(204) # answer 2xx within 15 seconds; do slow work after answering self.end_headers()
def log_message(self, *args): pass
HTTPServer(("", int(os.environ.get("PORT", "8080"))), Receiver).serve_forever()//go:build ignore
// Receive webhooks: verify each one with the Standard Webhooks library, act on it once.package main
import ( "encoding/json" "io" "log" "net/http" "os" "sync"
standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go")
type event struct { EventID string `json:"event_id"` Type string `json:"type"` Status string `json:"status"` PaymentID string `json:"payment_id"` PaymentVersion int `json:"payment_version"` RefundID string `json:"refund_id"` RefundVersion int `json:"refund_version"` PayoutID string `json:"payout_id"` PayoutVersion int `json:"payout_version"`}
func main() { // whsec_…, answered once by PUT /v1/webhook-endpoint. webhook, err := standardwebhooks.NewWebhook(os.Getenv("WEBHOOK_SECRET")) if err != nil { log.Fatal(err) } var mu sync.Mutex handled := map[string]bool{} // webhook-id of the events acted on; keep this in your database versions := map[string]int{} // the highest version applied, per payment, refund or payout
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { raw, err := io.ReadAll(r.Body) // the exact bytes received, before any JSON parsing if err != nil || webhook.Verify(raw, r.Header) != nil { // the timestamp must be within 5 minutes w.WriteHeader(http.StatusBadRequest) return } var e event if err := json.Unmarshal(raw, &e); err != nil { w.WriteHeader(http.StatusBadRequest) return } mu.Lock() if id := r.Header.Get("webhook-id"); !handled[id] { handled[id] = true apply(e, versions) } mu.Unlock() w.WriteHeader(http.StatusNoContent) // answer 2xx within 15 seconds; do slow work after answering }) port := os.Getenv("PORT") if port == "" { port = "8080" } log.Fatal(http.ListenAndServe(":"+port, nil))}
// apply acts on an event; an older version than the one applied changes nothing, as events// may come late or twice.func apply(e event, versions map[string]int) { subject, version := e.EventID, 0 switch e.Type { case "payment.status_changed": subject, version = e.PaymentID, e.PaymentVersion case "refund.status_changed": subject, version = e.RefundID, e.RefundVersion case "payout.status_changed": subject, version = e.PayoutID, e.PayoutVersion } if versions[subject] > version { return } versions[subject] = version log.Printf("verified %s %s v%d %s", e.Type, subject, version, e.Status)}- Проверяйте подпись по сырому телу, до разбора: подпись покрывает точные байты. Фреймворк, который сначала разбирает JSON, а потом сериализует его заново, ломает подпись.
- Отклоняйте устаревшую метку времени. Библиотеки отказывают, если расхождение больше 5 минут; оставьте так.
- Отвечайте
2xxв течение 15 секунд, а медленную работу делайте потом. Всё остальное — таймаут, редирект,5xx— повторяется. - Обрабатывайте каждый
webhook-idодин раз: повтор несёт тот же id (порядок).
У каждого события три заголовка:
| Заголовок | |
|---|---|
webhook-id |
Id события; одинаковый при каждом повторе. |
webhook-timestamp |
Время этой попытки в секундах Unix. |
webhook-signature |
v1, и HMAC-SHA256 в base64 от id.timestamp.body с вашим секретом. Пока при смене секрета действуют оба, по одной подписи на секрет через пробел. |
События
Заголовок раздела «События»| Тип | Когда | Ключевые поля |
|---|---|---|
payment.status_changed |
Платёж перешёл в requires_action, processing, succeeded, failed или cancelled. |
payment_id, payment_version, status, data.merchant_order_id, data.amount, data.amount_received |
refund.status_changed |
Возврат запрошен, взят, отправлен или не прошёл. | refund_id, refund_version, payment_id, status |
payout.status_changed |
Выплата создана, взята, отправлена, не прошла или отменена. | payout_id, payout_version, status, data.merchant_payout_id, data.fee |
webhook.test |
Вы запросили тест. | — |
Все поля описаны в справочнике событий. Игнорируйте незнакомые типы событий и поля: новые добавляются в рамках v1.
3. Проверьте эндпоинт
Заголовок раздела «3. Проверьте эндпоинт»#!/usr/bin/env bash# Send a webhook.test event now and see how your endpoint answered.set -euo pipefail. "$(dirname "$0")/../../signed-request.sh"
mka_request POST "/v1/webhook-endpoint/test"// Send a webhook.test event now and see how your endpoint answered.import { print, request } from "../../signed-request.mjs";
const answer = await request("POST", "/v1/webhook-endpoint/test");print(answer);<?php// Send a webhook.test event now and see how your endpoint answered.
declare(strict_types=1);
require __DIR__ . '/../../signed_request.php';
mka_show(mka_request('POST', '/v1/webhook-endpoint/test'));"""Send a webhook.test event now and see how your endpoint answered."""from signed_request import request, show
show(request("POST", "/v1/webhook-endpoint/test"))//go:build ignore
// Send a webhook.test event now and see how your endpoint answered.package main
import ( "docs.processing.yolotech.cc/examples/signedrequest")
func main() { signedrequest.Show(signedrequest.Request("POST", "/v1/webhook-endpoint/test", nil, ""))}Ответ показывает, принял ли эндпоинт событие и с каким статусом. Все события и попытки также видны в консоли в разделе «Разработчикам» → «Вебхуки» и доступны через API:
#!/usr/bin/env bash# Your deliveries, newest first, 50 a page.set -euo pipefail. "$(dirname "$0")/../../signed-request.sh"
mka_request GET "/v1/webhook-deliveries"// Your deliveries, newest first, 50 a page.import { print, request } from "../../signed-request.mjs";
const answer = await request("GET", "/v1/webhook-deliveries");print(answer);<?php// Your deliveries, newest first, 50 a page.
declare(strict_types=1);
require __DIR__ . '/../../signed_request.php';
mka_show(mka_request('GET', '/v1/webhook-deliveries'));"""Your deliveries, newest first, 50 a page."""from signed_request import request, show
show(request("GET", "/v1/webhook-deliveries"))//go:build ignore
// Your deliveries, newest first, 50 a page.package main
import ( "docs.processing.yolotech.cc/examples/signedrequest")
func main() { signedrequest.Show(signedrequest.Request("GET", "/v1/webhook-deliveries", nil, ""))}