Connecting a CRM (Twenty) with the book.changed webhook
Being builtPartly works today — the limits are below.
finkpr can send a signed book.changed webhook when a book changes. It carries ids, not money: your CRM fetches what changed from the book's feed with its own read-only key. This page connects Twenty, the open-source CRM.
What is live, and what is not yet
The webhook is built and switched off on finkpr’s servers for now. It turns on
per server, not per book. Until then the Connections page has no webhook section,
and nothing on this page will work. The feed fields this recipe reads (meta,
tags, links) arrive with the next book update. A book that has not had it yet
sends the same changes without them.
How it works
-
You add an
https://address on the book’s Connections page. finkpr shows a signing secret once. -
A few seconds after the book changes (once it has been quiet for 30 seconds), finkpr POSTs this to the address:
{ "id": "evt_01K…", "type": "book.changed", "created_at": "2026-10-02T12:00:45.000Z", "book_id": "bok_01K…", "head": "<commit sha the book is at now>", "after": "<commit sha you were last told about, or null>", "changes": 2, "events_url": "https://api.finkpr.com/v1/books/bok_01K…/api/feed/events?after=<sha>" }changescounts commits and stops at 200, so 200 means “200 or more”. There are no amounts, names or accounts in it. Several quick changes are sent as one webhook. -
Your system fetches
events_urlwith a read-only API key from the book page (Authorization: Bearer cbk_…). It keeps followingnext_cursorwhilehas_moreis true. Each event lists the transactions added and removed, with their postings and, from the feed’scontract/v9, theirtags,linksand these metadata keys:invoice,due,customer,counterparty, and any key starting withcrm-orcrm_. No other metadata is sent. -
Any
2xxanswer counts as delivered. Anything else, including a redirect, is retried after 1 minute, 10 minutes, 1 hour and 12 hours, then every 12 hours. After 10 failures in a row the webhook is switched off. The book’s chat gets a notice, and the Connections page has Turn it back on. The next webhook then covers everything the CRM missed.
Removing the API key on the book page cuts the CRM off, whatever the webhook does.
Checking the signature
Each request carries X-Finkpr-Signature: t=<unix seconds>,v1=<hex>. v1 is
HMAC-SHA256 with your secret over <t>.<the raw request body>, the same scheme
Stripe uses. Turn away a t more than five minutes from your own clock.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, header, rawBody, nowSeconds = Math.floor(Date.now() / 1000)) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const t = Number(parts.t);
if (!t || Math.abs(nowSeconds - t) > 300) return false;
const want = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
const got = Buffer.from(parts.v1 ?? "", "hex");
return got.length === want.length && timingSafeEqual(got, want);
}
The HMAC covers the bytes exactly as they arrived. Re-serialising parsed JSON will not reproduce them.
Twenty, step by step
This uses a Twenty workflow with a webhook trigger and a code step. The labels below come from Twenty’s documentation. The recipe has not been run against a live Twenty yet. Menu names move between Twenty releases.
-
finkpr — a read key for Twenty. On the book page, create an API key with read scope and name it
twenty. Copy thecbk_…value. -
Twenty — the workflow. Go to Workflows → New workflow and choose the Webhook trigger with method
POST. Twenty shows the trigger’s URL. Copy it. -
finkpr — the webhook. Go to Book → Connections → Webhook, paste the Twenty URL and choose Add webhook. Keep the secret. Then choose Send a test: Twenty should list a run of the workflow.
-
Twenty — a code step after the trigger. Store the
cbk_…key and a Twenty API key (Settings → APIs & Webhooks) as the step’s secrets, not in the code. The step reads the finkpr feed, then sets the stage of the opportunity whose name is the invoice number:export const main = async ({ events_url }) => { const FINKPR_KEY = process.env.FINKPR_READ_KEY; // cbk_…, read scope const TWENTY = process.env.TWENTY_BASE_URL; // https://crm.example.com const TWENTY_KEY = process.env.TWENTY_API_KEY; const seen = {}; let url = events_url; for (let page = 0; url && page < 20; page++) { const res = await fetch(url, { headers: { Authorization: `Bearer ${FINKPR_KEY}` } }); if (!res.ok) throw new Error(`finkpr feed answered ${res.status}`); const feed = await res.json(); for (const ev of feed.data) { for (const ch of ev.changes) { const invoice = ch.meta?.invoice; if (!invoice || ch.kind !== "txn.added") continue; // A debit to Assets:Receivable:… is an invoice issued; a credit is a payment. const ar = ch.postings.find((p) => p.account.startsWith("Assets:Receivable:")); if (!ar) continue; seen[invoice] = ar.amount.startsWith("-") ? "CUSTOMER" : "PROPOSAL"; } } url = feed.has_more ? `${events_url.split("?")[0]}?after=${feed.next_cursor}` : null; } for (const [invoice, stage] of Object.entries(seen)) { const q = await fetch( `${TWENTY}/rest/opportunities?filter=name[eq]:${encodeURIComponent(JSON.stringify(invoice))}`, { headers: { Authorization: `Bearer ${TWENTY_KEY}` } }, ); const found = (await q.json())?.data?.opportunities?.[0]; if (!found) continue; await fetch(`${TWENTY}/rest/opportunities/${found.id}`, { method: "PATCH", headers: { Authorization: `Bearer ${TWENTY_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ stage }), }); } return { updated: Object.keys(seen).length }; };PROPOSALandCUSTOMERare Twenty’s default opportunity stages. Change the mapping to fit your own pipeline. -
In the book, write invoices so the CRM can find them. One transaction per invoice to
Assets:Receivable:<Customer>, with metadata:2026-10-01 * "Lotus Studio" "September retainer" invoice: "INV-2026-031" due: 2026-10-31 counterparty: "Lotus Studio" Assets:Receivable:Lotus-Studio 4200 SGD Income:ConsultingThen tag the payment with the same
invoice:when it lands. In the chat, saying “Lotus paid INV-2026-031” is enough.
Signatures in Twenty
Twenty’s webhook trigger passes the step the parsed body, not the raw bytes,
so the signature cannot be checked inside the workflow. That is acceptable
here: the body is only a nudge. The step trusts nothing in it except
events_url, and it reads every fact from the feed with its own key. A forged
nudge costs one extra read of your own book. To check signatures anyway, put a
small verifying proxy in front of Twenty using the function above.
Known gaps
- A change to an existing transaction’s metadata only (adding
invoice:to an old entry) does not appear in/feed/events. The transaction’s id excludes metadata, so nothing was added or removed. Re-read/feed/postingswhen you need the current metadata. - One webhook per book.
- finkpr never messages your customers. A payment reminder is text for you to send yourself.
Something here wrong or missing? Put it on the board — it is public, and the reply is in the thread.