Skip to content

Руководство: обработчик вебхуков ​

Полный, безопасный обработчик события 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 возвращается только после надёжной фиксации.

См. также Идемпотентная обработка и Доставка и повторы.