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

  1. You add an https:// address on the book’s Connections page. finkpr shows a signing secret once.

  2. 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>"
    }

    changes counts 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.

  3. Your system fetches events_url with a read-only API key from the book page (Authorization: Bearer cbk_…). It keeps following next_cursor while has_more is true. Each event lists the transactions added and removed, with their postings and, from the feed’s contract/v9, their tags, links and these metadata keys: invoice, due, customer, counterparty, and any key starting with crm- or crm_. No other metadata is sent.

  4. Any 2xx answer 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.

  1. finkpr — a read key for Twenty. On the book page, create an API key with read scope and name it twenty. Copy the cbk_… value.

  2. Twenty — the workflow. Go to Workflows → New workflow and choose the Webhook trigger with method POST. Twenty shows the trigger’s URL. Copy it.

  3. 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.

  4. 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 };
    };

    PROPOSAL and CUSTOMER are Twenty’s default opportunity stages. Change the mapping to fit your own pipeline.

  5. 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:Consulting

    Then 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/postings when 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.