Руководство: обработчик вебхуков
Полный, безопасный обработчик события payment.succeeded: проверка подписи по сырому телу → дедупликация → транзакционная выдача → 2xx.
Приёмы ниже соответствуют проверенному эталонному приёмнику Eleven Pay и синтетическому тест‑вектору (секрет/тело/подпись) из репозитория — реальные секреты не используются.
Node.js (Express)
Ключевой момент: сохраните сырое тело (express.raw) и не используйте JSON‑парсер до проверки подписи.
javascript
import express from "express";
import crypto from "node:crypto";
const app = express();
const SECRETS = [process.env.ELEVEN_PAY_WEBHOOK_SECRET]; // [текущий] или [текущий, предыдущий]
const TOLERANCE = 300;
function verifySignature(secrets, header, rawBody) {
const parts = String(header).split(",");
if (parts.length < 2 || !parts[0].startsWith("t=")) return null;
const ts = parts[0].slice(2);
if (!/^\d+$/.test(ts)) return null;
const timestamp = Number(ts);
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > TOLERANCE) return null;
const sigs = [];
for (const p of parts.slice(1)) {
if (!p.startsWith("v1=")) return null;
sigs.push(p.slice(3));
}
const material = Buffer.concat([Buffer.from(`${timestamp}.`, "ascii"), rawBody]);
let ok = false;
for (const secret of secrets) {
const expected = crypto.createHmac("sha256", secret).update(material).digest("hex");
for (const c of sigs) {
if (expected.length === c.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(c))) ok = true;
}
}
return ok ? timestamp : null;
}
app.post("/webhooks/eleven-pay",
express.raw({ type: "application/json" }), // сырые байты, без JSON-парсера
async (req, res) => {
const raw = req.body; // Buffer
if (verifySignature(SECRETS, req.get("X-ElevenPay-Signature"), raw) === null) {
return res.sendStatus(401);
}
const eventId = req.get("X-ElevenPay-Event-Id");
if (await alreadyProcessed(eventId)) return res.sendStatus(200); // дедупликация
const event = JSON.parse(raw.toString("utf8")); // безопасно только после проверки
if (event.schema_version !== 1 || event.type !== "payment.succeeded" || event.id !== eventId) {
return res.sendStatus(400);
}
const p = event.data.payment;
// Транзакционно: найдите свой заказ, проверьте сумму/валюту/статус, выдайте, отметьте событие.
await fulfillInTransaction({
eventId,
orderId: p.merchant_order_id,
reference: p.reference,
amount: p.amount,
currency: p.currency,
status: p.status, // ожидается "succeeded"
});
res.sendStatus(200);
});Python
python
import hashlib
import hmac
import json
import time
def verify(secrets, signature_header, raw_body, *, tolerance=300):
parts = signature_header.split(",")
if len(parts) < 2 or not parts[0].startswith("t="):
return None
ts_text = parts[0][2:]
if not ts_text.isdigit():
return None
timestamp = int(ts_text)
if abs(int(time.time()) - timestamp) > tolerance:
return None
sigs = [p[3:] for p in parts[1:] if p.startswith("v1=")]
if len(sigs) != len(parts) - 1 or not sigs:
return None
material = f"{timestamp}.".encode("ascii") + raw_body
ok = False
for secret in secrets:
expected = hmac.new(secret.encode("utf-8"), material, hashlib.sha256).hexdigest()
for candidate in sigs:
if hmac.compare_digest(expected, candidate):
ok = True
return timestamp if ok else None
def handle(raw_body: bytes, headers: dict[str, str]) -> int:
secrets = [read_secret()] # [текущий] или [текущий, предыдущий]
if verify(secrets, headers["X-ElevenPay-Signature"], raw_body) is None:
return 401
event_id = headers["X-ElevenPay-Event-Id"]
if already_processed(event_id):
return 200 # дедупликация
event = json.loads(raw_body) # только после проверки
if event.get("schema_version") != 1 or event.get("type") != "payment.succeeded":
return 400
if event.get("id") != event_id:
return 400
payment = event["data"]["payment"]
# Транзакционно: заказ + сумма/валюта/статус -> выдача -> отметка события. Затем commit -> 2xx.
fulfill_in_transaction(event_id=event_id, payment=payment)
return 200Проверьте себя
- Подпись проверяется по сырым байтам до
JSON.parse/json.loads. - Сравнение — постоянного времени; поддержаны несколько
v1. - Событие дедуплицируется по
X-ElevenPay-Event-Id. - Выдача — транзакционная и идемпотентная.
2xxвозвращается только после надёжной фиксации.
См. также Идемпотентная обработка и Доставка и повторы.