FinzBooksDevelopers

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. === / strcmp leak timing info that a remote attacker can exploit to forge signatures bit-by-bit. Always use timingSafeEqual / 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_PAID
  • BILL_CREATED, BILL_UPDATED, BILL_VOIDED, BILL_PAID
  • PAYMENT_RECEIVED (INCOMING flow), PAYMENT_MADE (OUTGOING flow)
  • CREDIT_NOTE_CREATED, CREDIT_NOTE_VOIDED
  • DEBIT_NOTE_CREATED
  • ESTIMATE_CREATED, ESTIMATE_ACCEPTED, ESTIMATE_CONVERTED
  • SALES_ORDER_CREATED, PURCHASE_ORDER_CREATED
  • EXPENSE_CREATED
  • JOURNAL_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.