Вебхуки

Когда статус платежа меняется, EGN Pay отправляет POST на webhook_url платежа или, если он не указан, на webhook_url сервиса.

События

typeКогда
payment.succeededПлатёж оплачен
payment.failedБанк отклонил оплату
payment.expiredВремя на оплату истекло
payment.canceledПлатёж отменён
payment.partially_refundedЧастичный возврат (приходит на каждый возврат)
payment.refundedВозвращена вся сумма

Формат

POST /api/egn-pay/webhook HTTP/1.1
Content-Type: application/json
User-Agent: EGN-Pay-Webhooks/1.0
X-EGN-Event: payment.succeeded
X-EGN-Delivery: evt_Q2w9…
X-EGN-Signature: t=1793095421,v1=5f1c…e9
{
  "id": "evt_Q2w9sLk3VbN8mXc1Hj4R",
  "type": "payment.succeeded",
  "created_at": "2026-10-27T12:03:41.600Z",
  "livemode": true,
  "data": {
    "payment": {
      "id": "pay_8fK2mQ1xZbY7cV3nT0aR",
      "object": "payment",
      "status": "succeeded",
      "amount": 49900,
      "order_id": "order-1042",
      "metadata": { "user_id": "u_123" }
    }
  }
}

data.payment — полный объект платежа на момент события.

Ответ

Ответьте любым кодом 2xx в течение 10 секунд. Тело ответа не важно. Всё остальное (3xx, 4xx, 5xx, таймаут) считается неудачей. Редиректы не выполняются.

Тяжёлую работу (выдачу товара, письма) лучше ставить в очередь и отвечать сразу.

Повторы

После неудачи доставка повторяется через: 1 мин, 5 мин, 30 мин, 2 ч, 6 ч, 24 ч, 24 ч. Это 8 попыток примерно за 2,5 суток. Администратор может переотправить вебхук вручную в админке.

Вебхуки могут прийти повторно или не по порядку. Поэтому:

  • Дедуплицируйте по id события (X-EGN-Delivery) или делайте выдачу товара идемпотентной по data.payment.id.
  • Ориентируйтесь на data.payment.status, а не на порядок прихода. В сомнительных случаях запросите GET /api/v1/payments/{id}.

Проверка подписи

X-EGN-Signature: t=<unix-время>,v1=<hex>, где

v1 = HMAC-SHA256(key = webhook_secret, message = t + "." + сырое_тело_запроса)
  1. Возьмите сырое тело запроса, до JSON-парсинга.
  2. Посчитайте HMAC и сравните с v1 за постоянное время.
  3. Отклоните запрос, если t отличается от текущего времени больше чем на 5 минут (защита от повтора).

Node.js / Next.js

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyEgnPay(rawBody: string, header: string | null, secret: string) {
  if (!header) return false;
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return expected.length === parts.v1?.length && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}

// app/api/egn-pay/webhook/route.ts
export async function POST(req: Request) {
  const raw = await req.text();
  if (!verifyEgnPay(raw, req.headers.get("x-egn-signature"), process.env.EGN_PAY_WEBHOOK_SECRET!)) {
    return new Response("bad signature", { status: 400 });
  }
  const event = JSON.parse(raw);
  if (event.type === "payment.succeeded") {
    await grantOrder(event.data.payment.order_id, event.data.payment.id); // идемпотентно!
  }
  return new Response("ok");
}

Python (FastAPI)

import hmac, hashlib, time, os
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()
SECRET = os.environ["EGN_PAY_WEBHOOK_SECRET"].encode()

def verify(raw: bytes, header: str | None) -> bool:
    if not header:
        return False
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts.get("t", 0))
    if abs(time.time() - t) > 300:
        return False
    expected = hmac.new(SECRET, f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

@app.post("/egn-pay/webhook")
async def webhook(request: Request):
    raw = await request.body()
    if not verify(raw, request.headers.get("x-egn-signature")):
        raise HTTPException(400, "bad signature")
    event = await request.json()
    ...
    return {"ok": True}

PHP

$raw = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_X_EGN_SIGNATURE'] ?? ''), $sig);
$expected = hash_hmac('sha256', $sig['t'] . '.' . $raw, getenv('EGN_PAY_WEBHOOK_SECRET'));
if (abs(time() - (int)$sig['t']) > 300 || !hash_equals($expected, $sig['v1'] ?? '')) {
    http_response_code(400);
    exit('bad signature');
}
$event = json_decode($raw, true);

Go

func verify(raw []byte, header, secret string) bool {
	var t, v1 string
	for _, p := range strings.Split(header, ",") {
		kv := strings.SplitN(p, "=", 2)
		if len(kv) == 2 && kv[0] == "t" { t = kv[1] }
		if len(kv) == 2 && kv[0] == "v1" { v1 = kv[1] }
	}
	ts, err := strconv.ParseInt(t, 10, 64)
	if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > 300 { return false }
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(t + "."))
	mac.Write(raw)
	return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(v1))
}

Markdown-версия этой страницы: /docs/webhooks.md