Webhooks
Billing and task push events signed with HMAC-SHA256.
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
Replace https://example.com/franklab-hook with your public HTTPS receiver. Save endpoint.id and the secret from registration; replace <id> in the next request with that identifier.
# 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>/testEvent 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 ₣ — the same threshold as isLow and lowBalanceThresholdFranks on GET /v1/billing/balance (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}")>
X-Franklab-Signature-Previous: sha256=<HMAC with the previous secret — 6h after rotation only>Body — one envelope:
{
"eventId": "…",
"eventType": "billing.settled",
"occurredAt": "2026-09-04T12:00:00.000Z",
"partnerId": "…",
"schemaVersion": 1,
"data": { "transactionId": "…", "amountFranks": 84 }
}Verifying the signature
import { createHmac, timingSafeEqual } from 'node:crypto';
// headers: lowercase header names; rawBody: the original request Buffer.
export function verifyWebhook(secret, headers, rawBody, nowMs = Date.now()) {
const timestamp = headers['x-franklab-timestamp'];
if (typeof secret !== 'string' || !secret || !Buffer.isBuffer(rawBody)) return false;
if (typeof timestamp !== 'string' || !/^[0-9]{10,}$/.test(timestamp)) return false;
const seconds = Number(timestamp);
if (!Number.isSafeInteger(seconds) || !Number.isFinite(nowMs)) return false;
if (Math.abs(nowMs / 1000 - seconds) > 300) return false;
const expected = createHmac('sha256', secret)
.update(timestamp + '.')
.update(rawBody)
.digest();
return ['x-franklab-signature', 'x-franklab-signature-previous'].some((name) => {
const signature = headers[name];
if (typeof signature !== 'string' || !/^sha256=[a-f0-9]{64}$/.test(signature)) return false;
const received = Buffer.from(signature.slice(7), 'hex');
return received.length === expected.length && timingSafeEqual(received, expected);
});
}Pass the original request bytes before JSON parsing (for example, mount express.raw({ type: 'application/json' }) on the webhook route before express.json()). JSON.stringify(req.body) is not the original body. Keep the secret server-side; during rotation a known secret can verify either signature header.
After signature verification, parse JSON and durably accept the event with its eventId. Persist the event and its unique eventId atomically: duplicates return 2xx without repeating business actions. Acknowledge after durable acceptance and perform slow work separately. If persistence fails, do not return success. Signature verification does not replace deduplication.
Secret rotation and grace window
For 6 hours after POST /v1/webhooks/endpoints/{id}/rotate-secret, deliveries carry X-Franklab-Signature signed with the new secret and X-Franklab-Signature-Previous signed with the previous secret. Either valid signature is sufficient. The 6-hour window does not cover the entire retry chain: its delays total 8 hours 36 minutes, excluding processing and queue time. Switch the receiver to the new secret before the grace window ends. After it ends, the previous signature is omitted and the previous secret is cleared server-side.
Delivery guarantees
- At-least-once: duplicates are possible — deduplicate by
eventId. - Source-derived events (billing, tasks, invoices) carry a deterministic
eventIduuid: a re-delivery of the same business event arrives with the sameeventId, so dedupe by it is complete. The exceptions arebilling.low_balance(rate-limited to one emission per 24h) andwebhook.test— each of their emissions is a fresh event with a fresheventId. - 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
uncertainstatus means the request left but the outcome is unknown: those deliveries are never auto-retried. Check receiver records byeventIdfirst, then replay if necessary viaPOST /v1/webhooks/deliveries/{id}/replay. - Answer 2xx quickly; the timeout is 10 seconds.
Double rotation within the 6h window: only one previous secret is kept — on a second rotation the generation N−2 signature dies immediately; switch your receiver to the new secret promptly.
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 |
| Replay | POST /v1/webhooks/deliveries/{id}/replay |
| 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.