# Вебхуки

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

## События

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

## Формат

```http
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
```

```json
{
  "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` — полный [объект платежа](/docs/payments#объект-платежа) на момент события.

## Ответ

Ответьте любым кодом `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>`, где

```text
v1 = HMAC-SHA256(key = webhook_secret, message = t + "." + сырое_тело_запроса)
```

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

### Node.js / Next.js

```ts
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)

```python
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

```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

```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))
}
```
