Webhooks
We POST signed JSON to your endpoint whenever subscribed events fire. Verify the signature, reject stale deliveries, return 2xx. Signature scheme is Stripe-style t=<unix_ts>,v1=<hex_hmac> so existing Stripe / GitHub verification code is mostly portable.
Register an endpoint
Go to Console → Webhooks, click Add endpoint, paste a URL, pick the event types you want, save. You'll get a per-endpoint signing secret — shown once at creation.
Or via API:
POST /api/public/v1/webhooks/endpoints
{
"url": "https://your-app.example.com/webhooks/aibooks",
"events": ["INVOICE_CREATED", "PAYMENT_RECEIVED", "BILL_PAID"]
}
# Response → grab "secret" (only shown here once)
{
"code": 0,
"endpoint": {
"endpoint_id": "wh_abc…",
"url": "https://...",
"secret": "whsec_…", ← store securely
"events": [...]
}
}Delivery shape
POST https://your-app.example.com/webhooks/aibooks
Content-Type: application/json
X-AIBooks-Event: INVOICE_CREATED
X-AIBooks-Delivery: d3a8d2c9-4f5b-…
X-AIBooks-Timestamp: 1715492700
X-AIBooks-Signature: t=1715492700,v1=8c4f1a9b…
{
"event_type": "INVOICE_CREATED",
"delivery_id": "d3a8d2c9-4f5b-…",
"occurred_at": "2026-05-12T08:30:00Z",
"org_id": "0c2c3781-…",
"data": {
"id": "inv_abc",
"number": "INV-2026-0042",
"customer_id": "ct_acme",
"total": 23600.0,
"status": "SENT"
}
}Verify the signature (canonical)
The signed payload is t.body — the timestamp string, a literal dot, then the raw request body bytes (NOT the parsed JSON — byte equality matters). Compute HMAC-SHA256 with your endpoint secret and compare constant-time against the v1 value.
Node.js
import crypto from "crypto";
function verifyAIBooksSignature(
rawBody: Buffer, // express.raw() or req body BEFORE JSON.parse
signatureHeader: string, // X-AIBooks-Signature
secret: string, // whsec_…
): boolean {
const parts = Object.fromEntries(
signatureHeader.split(",").map(kv => kv.split("=")),
);
const ts = parts.t;
const sig = parts.v1;
if (!ts || !sig) return false;
// 5-minute replay window
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const payload = Buffer.concat([Buffer.from(`${ts}.`), rawBody]);
const expected = crypto.createHmac("sha256", secret)
.update(payload)
.digest("hex");
// Constant-time compare. Lengths must match for timingSafeEqual.
if (expected.length !== sig.length) return false;
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
}Python
import hmac, hashlib, time
def verify_aibooks_signature(
raw_body: bytes,
signature_header: str,
secret: str,
) -> bool:
parts = dict(kv.split("=", 1) for kv in signature_header.split(",") if "=" in kv)
ts, sig = parts.get("t"), parts.get("v1")
if not ts or not sig:
return False
# 5-minute replay window
if abs(time.time() - int(ts)) > 300:
return False
payload = f"{ts}.".encode() + raw_body
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)Go
func VerifyAIBooksSignature(rawBody []byte, header, secret string) bool {
parts := strings.Split(header, ",")
kv := map[string]string{}
for _, p := range parts {
if i := strings.Index(p, "="); i > 0 {
kv[p[:i]] = p[i+1:]
}
}
ts, sig := kv["t"], kv["v1"]
if ts == "" || sig == "" { return false }
t, err := strconv.ParseInt(ts, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-t)) > 300 { return false }
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts + "."))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(sig))
}Common signature mistakes
- Parsing the JSON first. Compute HMAC on the raw bytes you received — any whitespace / key-order change breaks the signature. Use
express.raw()/req.body.read()/ equivalent before any JSON middleware runs. - Signing the wrong payload. It's
timestamp.body(timestamp string + literal dot + body), NOT just the body. We do this to bind the signature to the timestamp — preventing “replay with new timestamp” attacks. - Non-constant-time compare.
===/strcmpleak timing info that a remote attacker can exploit to forge signatures bit-by-bit. Always usetimingSafeEqual/hmac.compare_digest/hmac.Equal. - Skipping the timestamp check. Even with a valid signature, accept only deliveries within 5 minutes of now. Otherwise a captured payload can be replayed at any future point.
Idempotent receiver pattern
We retry failed deliveries (30s, 5m, 30m, 2h, 6h, 24h). The same X-AIBooks-Delivery id is reused across retries — store it and reject duplicates on your end:
// On every incoming webhook:
const deliveryId = req.header("X-AIBooks-Delivery");
const seen = await redis.set(`wh:${deliveryId}`, "1", "NX", "EX", 86400);
if (!seen) {
return res.status(200).end(); // already processed; 2xx so we don't retry
}
// ... handle the event ...Retries + the 2xx contract
Return any 2xx status to acknowledge. Anything else (4xx / 5xx / timeout) triggers retries: 30s, 5m, 30m, 2h, 6h, 24h with exponential backoff and jitter. After 24h the delivery is marked permanently failed; you can replay it from the delivery log.
Tip:if you can't process the event synchronously, enqueue + return 200 immediately. A long-running handler that eventually returns 200 is fine, but if it times out we'll think it failed and retry — leading to duplicate work.
Event types
The current catalog (subscribe to any subset):
INVOICE_CREATED,INVOICE_UPDATED,INVOICE_VOIDED,INVOICE_PAIDBILL_CREATED,BILL_UPDATED,BILL_VOIDED,BILL_PAIDPAYMENT_RECEIVED(INCOMING flow),PAYMENT_MADE(OUTGOING flow)CREDIT_NOTE_CREATED,CREDIT_NOTE_VOIDEDDEBIT_NOTE_CREATEDESTIMATE_CREATED,ESTIMATE_ACCEPTED,ESTIMATE_CONVERTEDSALES_ORDER_CREATED,PURCHASE_ORDER_CREATEDEXPENSE_CREATEDJOURNAL_POSTED
Testing locally
Use ngrok or cloudflared tunnel to expose your local handler, register the public URL as a webhook endpoint, trigger the event from the FinzBooks UI. The Console → Webhooks delivery log shows every attempt with the request/response payloads.