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
- You register a URL and pick which events to receive.
- When one of those events fires, Zeng Book serialises an envelope and POSTs it to your URL with an
Zb-Signatureheader. - 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.
localhost on your machine.Request format
Every delivery is a JSON POST. Headers:
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.0The body is an envelope:
{
"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, prefixevt_. 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:
Zb-Signature: t=<unix_timestamp>,v1=<hex_signature>The signature is HMAC-SHA256 over `${t}.${raw_request_body}` using your endpoint's signing secret.
Node.js (Express)
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)
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 "", 200Replay 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.
| Attempt | Wait before fire | Cumulative time |
|---|---|---|
| 1 | ~immediate | 0s |
| 2 | 30s | 30s |
| 3 | 5m | ~5m 30s |
| 4 | 30m | ~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.
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-Idis 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
| Event | When it fires |
|---|---|
client.created | A new client was added. |
Projects
| Event | When it fires |
|---|---|
project.created | A new project was added. |
project.status_changed | A project transitioned between status values. |
Quotations
| Event | When it fires |
|---|---|
quotation.created | A draft quotation was created. |
quotation.sent | A quotation was emailed to the client. |
quotation.accepted | The client accepted the quotation via the portal. |
quotation.rejected | The client rejected the quotation via the portal. |
Invoices
| Event | When it fires |
|---|---|
invoice.created | A draft invoice was created. |
invoice.sent | An invoice was issued to the client. |
invoice.paid | Cumulative payments now match the invoice total. |
invoice.overdue | Past dueDate without full payment. Emitted by an hourly cron. |
invoice.voided | Invoice was cancelled. |
Payments
| Event | When it fires |
|---|---|
payment.recorded | A new payment was attached to an invoice (may or may not mark it paid). |
Expenses
| Event | When it fires |
|---|---|
expense.created | A new expense was added. |
expense.updated | An expense was edited. |
expense.deleted | An expense was removed. |
Example: invoice.paid
{
"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
{
"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"
}
}
}