<!-- /en/docs/webhooks (en) -->

# Webhooks

Webhooks push billing and task events to you. Register an HTTPS endpoint, subscribe to event types, and receive signed HMAC-SHA256 POST requests.

## Quick start

```bash
# register an endpoint (the secret is shown exactly once!)
curl -X POST -H "X-API-Key: $FRANKLAB_KEY" -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/franklab-hook","eventTypes":["billing.settled","task.succeeded"]}' \
  https://apergrex.ru/franklab/api/v1/webhooks/endpoints

# test delivery
curl -X POST -H "X-API-Key: $FRANKLAB_KEY" \
  https://apergrex.ru/franklab/api/v1/webhooks/endpoints/<id>/test
```

## Event types

| Event | When |
|---|---|
| `billing.settled` | a charge was confirmed (with the final amount) |
| `billing.refunded` | a task reservation was refunded |
| `billing.topup.confirmed` | a top-up was credited |
| `billing.low_balance` | balance dropped below 1,000 ₣ (at most once per 24h) |
| `billing.invoice.status_changed` | a legal invoice changed status / was paid |
| `task.succeeded` / `task.failed` / `task.refunded` | a generation task reached a terminal status |
| `webhook.test` | a test event (on demand) |

## Delivery format

Headers:

```
X-Franklab-Event: billing.settled
X-Franklab-Event-Id: <event uuid — your idempotency key>
X-Franklab-Timestamp: <unix seconds>
X-Franklab-Signature: sha256=<HMAC-SHA256(secret, "{timestamp}.{body}")>
```

Body — one envelope:

```json
{
  "eventId": "…",
  "eventType": "billing.settled",
  "occurredAt": "2026-09-04T12:00:00.000Z",
  "partnerId": "…",
  "schemaVersion": 1,
  "data": { "transactionId": "…", "amountFranks": 84 }
}
```

## Verifying the signature

```js
const expected = crypto.createHmac('sha256', secret)
  .update(`${timestamp}.${rawBody}`)
  .digest('hex');
// compare against X-Franklab-Signature (strip the sha256= prefix) and reject
// timestamps older than ~5 minutes (anti-replay)
```

## Delivery guarantees

- **At-least-once**: duplicates are possible — deduplicate by `eventId`.
- Source-derived events (billing, tasks, invoices) carry a deterministic `eventId` uuid: a re-delivery of the same business event arrives with the same `eventId`, so dedupe by it is complete. The exceptions are `billing.low_balance` (rate-limited to one emission per 24h) and `webhook.test` — each of their emissions is a fresh event with a fresh `eventId`.
- Duplicates can also appear after a poller restart — the fallback dedupe key is `eventType` + `data.transactionId` / `data.taskId` / `data.invoiceId`.
- Latency is up to a minute (poller cadence).
- Retries on failure: 1 min → 5 min → 30 min → 2h → 6h, then **dead-letter** (visible via `GET /v1/webhooks/deliveries?status=dead_lettered`).
- The `uncertain` status means the request left but the outcome is unknown: those deliveries are never auto-retried — replay them via `POST /v1/webhooks/deliveries/{id}/replay`.
- Answer 2xx quickly; the timeout is 10 seconds.

## Management

| Operation | Endpoint |
|---|---|
| List | `GET /v1/webhooks/endpoints` |
| Register | `POST /v1/webhooks/endpoints` |
| Delete | `DELETE /v1/webhooks/endpoints/{id}` |
| Rotate secret | `POST /v1/webhooks/endpoints/{id}/rotate-secret` |
| Test | `POST /v1/webhooks/endpoints/{id}/test` |
| Delivery history | `GET /v1/webhooks/deliveries?endpointId=…&status=…` |

Limits: up to 10 endpoints per partner; the URL must be public HTTPS (internal hosts and IP literals are rejected); redirects are never followed.
