Вебхуки
Когда статус платежа меняется, 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 + "." + сырое_тело_запроса)- Возьмите сырое тело запроса, до JSON-парсинга.
- Посчитайте HMAC и сравните с
v1за постоянное время. - Отклоните запрос, если
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