Zeng Book
ProductIndustriesIntegrationsPricingResources
Sign inContact salesTry it free
Introduction
  • Overview
  • Quickstart
API reference
  • Authentication
  • Errors
  • Rate limits
  • Pagination
  • Interactive explorer
Resources
  • Organization
  • Leads
  • Clients
  • Projects
  • Quotations
  • Invoices
Integrations
  • Overview
  • Webhooks
  • Public portal
  • Zapier
  • Make / n8n recipes
  • Xero (coming soon)

Integrations

Webhooks

Configure an HTTPS endpoint. Zeng Book POSTs an HMAC-signed JSON envelope to it whenever one of 16 event types fires.

How it works

  1. You register a URL and pick which events to receive.
  2. When one of those events fires, Zeng Book serialises an envelope and POSTs it to your URL with an Zb-Signature header.
  3. Your endpoint returns 2xx within 10 seconds. Anything else triggers an automatic retry.

Webhooks are powered by a worker that polls every 5 seconds and claims pending deliveries atomically (so multiple workers can scale horizontally without double-delivering).

Registering an endpoint

Open Settings → Webhooks, click Add endpoint, paste your URL, and select the events you want.

Each endpoint also receives a signing secret — a 64-character hex string. Store it server-side; you need it to verify incoming deliveries.

URL requirements

URLs are validated at registration and on every delivery attempt (so a previously-stored URL that becomes invalid stops delivering). The following are rejected:

  • Non-http(s) schemes.
  • Hostnames in the loopback set (localhost, ip6-localhost, etc.).
  • IPv4 in 0.0.0.0/8, 10.0.0.0/8, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16.
  • IPv6 loopback, link-local (fe80::/10), and unique-local (fc00::/7), plus IPv4-mapped equivalents of the above.

This is an SSRF defence — it prevents Zeng Book from being used as a proxy to reach internal services in our network or cloud metadata APIs.

Testing locally
For local development, use a tunnel service such as ngrok or Cloudflare Tunnel. The public HTTPS URL it gives you will pass the SSRF checks and forward deliveries to localhost on your machine.

Request format

Every delivery is a JSON POST. Headers:

http
POST /your/webhook HTTP/1.1
Content-Type: application/json
Zb-Signature: t=1715760480,v1=4f8e2b6c...c1
Zb-Event-Id: evt_5f3a2c1d4b7e8f6a9c2d1b0e3f4a5c6d
Zb-Event-Type: invoice.paid
User-Agent: ZengBook-Webhooks/1.0

The body is an envelope:

json
{
  "id": "evt_5f3a2c1d4b7e8f6a9c2d1b0e3f4a5c6d",
  "type": "invoice.paid",
  "created": 1715760480,
  "data": {
    "object": {
      "id": "inv_01HX...",
      "number": "INV-2026-0117",
      "status": "PAID"
      // ...resource fields
    }
  }
}
  • id — globally unique event ID, prefix evt_. Stable across retries — use it as your idempotency key.
  • type — see the event reference below.
  • created — Unix timestamp (seconds) when the event was generated.
  • data.object — the affected resource, with the same shape as the corresponding REST detail endpoint.

Verifying signatures

The Zb-Signature header is comma-separated:

text
Zb-Signature: t=<unix_timestamp>,v1=<hex_signature>

The signature is HMAC-SHA256 over `${t}.${raw_request_body}` using your endpoint's signing secret.

Use the raw body
Compute the HMAC over the exact bytes you received. If your framework parses JSON before you see the request body, re-serialising it for verification will not reproduce the signature. Capture the raw buffer or string before any parsing.

Node.js (Express)

webhook.js
import express from "express"
import crypto from "node:crypto"

const app = express()

app.post(
  "/webhooks/zengbook",
  express.raw({ type: "application/json" }), // raw body
  (req, res) => {
    const header = req.get("zb-signature") ?? ""
    const parts = Object.fromEntries(
      header.split(",").map((p) => p.split("=")),
    )
    const expected = crypto
      .createHmac("sha256", process.env.ZB_WEBHOOK_SECRET)
      .update(`${parts.t}.${req.body.toString("utf8")}`)
      .digest("hex")

    if (
      !parts.v1 ||
      !crypto.timingSafeEqual(
        Buffer.from(expected, "hex"),
        Buffer.from(parts.v1, "hex"),
      )
    ) {
      return res.status(401).send("invalid signature")
    }

    const event = JSON.parse(req.body.toString("utf8"))
    // dispatch on event.type, idempotent on event.id
    res.sendStatus(200)
  },
)

Python (Flask)

webhook.py
import hmac, hashlib, os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["ZB_WEBHOOK_SECRET"].encode()

@app.post("/webhooks/zengbook")
def receive():
    header = request.headers.get("Zb-Signature", "")
    parts = dict(p.split("=", 1) for p in header.split(","))
    raw = request.get_data()  # raw bytes
    expected = hmac.new(
        SECRET,
        f"{parts['t']}.{raw.decode()}".encode(),
        hashlib.sha256,
    ).hexdigest()
    if not hmac.compare_digest(expected, parts.get("v1", "")):
        abort(401)
    event = request.get_json(force=True)
    # dispatch on event["type"], idempotent on event["id"]
    return "", 200

Replay protection

Compare the t value with the current time and reject anything more than ~5 minutes old. Combined with idempotency on Zb-Event-Id, this defeats both replay and duplicate-delivery scenarios.

Retry policy

A delivery counts as successful if the response status is 2xx within 10 seconds. Anything else (non-2xx, timeout, connection error) is retried.

AttemptWait before fireCumulative time
1~immediate0s
230s30s
35m~5m 30s
430m~35m 30s
5 (final)2h~2h 35m 30s

After 5 failed attempts the delivery is marked failed and abandoned. The response body (truncated to 2 KB) and HTTP status of each attempt are kept in the in-app delivery log.

If your endpoint is disabled
Deliveries to an endpoint that has been turned off are marked failed immediately rather than retried. Re-enable the endpoint before rescheduling.

Building a robust receiver

  • Be idempotent on Zb-Event-Id. Retries and (rarely) duplicate deliveries are normal. Store the ID with the row you create and skip if it already exists.
  • Return 2xx fast.Acknowledge inside ~1s. Push heavy work (Slack posts, email, AI summaries) onto your own queue. If you do the work synchronously and exceed 10s you'll get a retry, then a duplicate.
  • Validate the signature on every request. Even from internal networks. The signing secret is your only proof of authenticity.
  • Log the request ID. The Zb-Event-Id is the same value our delivery log shows, so quoting it in a support ticket lets us correlate immediately.

Event reference

All 16 event types currently emitted by Zeng Book. The data.object shape matches the corresponding REST detail endpoint — for example, an invoice.paid envelope wraps the same object you get from GET /v1/invoices/{id}.

Clients

EventWhen it fires
client.createdA new client was added.

Projects

EventWhen it fires
project.createdA new project was added.
project.status_changedA project transitioned between status values.

Quotations

EventWhen it fires
quotation.createdA draft quotation was created.
quotation.sentA quotation was emailed to the client.
quotation.acceptedThe client accepted the quotation via the portal.
quotation.rejectedThe client rejected the quotation via the portal.

Invoices

EventWhen it fires
invoice.createdA draft invoice was created.
invoice.sentAn invoice was issued to the client.
invoice.paidCumulative payments now match the invoice total.
invoice.overduePast dueDate without full payment. Emitted by an hourly cron.
invoice.voidedInvoice was cancelled.

Payments

EventWhen it fires
payment.recordedA new payment was attached to an invoice (may or may not mark it paid).

Expenses

EventWhen it fires
expense.createdA new expense was added.
expense.updatedAn expense was edited.
expense.deletedAn expense was removed.

Example: invoice.paid

json
{
  "id": "evt_5f3a2c1d4b7e8f6a9c2d1b0e3f4a5c6d",
  "type": "invoice.paid",
  "created": 1715760480,
  "data": {
    "object": {
      "id": "inv_01HX...",
      "number": "INV-2026-0117",
      "title": "Progress claim #2",
      "status": "PAID",
      "clientId": "cli_01HX...",
      "projectId": "prj_01HX...",
      "quotationId": "qtn_01HX...",
      "issuedAt": "2026-04-30T00:00:00.000Z",
      "dueDate": "2026-05-14T00:00:00.000Z",
      "gstRateAtIssue": 0.09,
      "retentionPct": 0.05,
      "items": [/* line items */],
      "payments": [/* payments */],
      "createdAt": "2026-04-30T01:22:14.000Z",
      "updatedAt": "2026-05-15T03:00:00.000Z"
    }
  }
}

Example: project.status_changed

json
{
  "id": "evt_8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d",
  "type": "project.status_changed",
  "created": 1715760480,
  "data": {
    "object": {
      "id": "prj_01HX...",
      "name": "Tampines BTO Reno",
      "status": "IN_PROGRESS",
      "clientId": "cli_01HX...",
      "budget": 65000,
      "startDate": "2026-04-15T00:00:00.000Z",
      "endDate": null,
      "createdAt": "2026-04-01T08:11:00.000Z",
      "updatedAt": "2026-05-15T03:00:00.000Z"
    }
  }
}