Documentation
FRANKLAB API

Webhooks

Billing and task push events signed with HMAC-SHA256.

Open .md
Open in AI

Work with an AI assistant

Shares the current documentation without your keys. Long sections are copied in full for you to paste into chat.

Cursor and Claude Code accept short prompts in links. For long sections, ChatGPT and Antigravity, paste the copied documentation manually.

Ask support

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.

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

EventWhen
billing.settleda charge was confirmed (with the final amount)
billing.refundeda task reservation was refunded
billing.topup.confirmeda top-up was credited
billing.low_balancebalance dropped below 1,000 ₣ — the same threshold as isLow and lowBalanceThresholdFranks on GET /v1/billing/balance (at most once per 24h)
billing.invoice.status_changeda legal invoice changed status / was paid
task.succeeded / task.failed / task.refundeda generation task reached a terminal status
webhook.testa test event (on demand)

Delivery format

Headers:

Text
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:

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

Verifying the signature

js
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 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. Check receiver records by eventId first, then replay if necessary via POST /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

OperationEndpoint
ListGET /v1/webhooks/endpoints
RegisterPOST /v1/webhooks/endpoints
DeleteDELETE /v1/webhooks/endpoints/{id}
Rotate secretPOST /v1/webhooks/endpoints/{id}/rotate-secret
TestPOST /v1/webhooks/endpoints/{id}/test
ReplayPOST /v1/webhooks/deliveries/{id}/replay
Delivery historyGET /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.

/en/docs/webhooks.md
Need help?Contact support