Webhooks: connect Zapier, Make or n8n

Send Billfetch events to Zapier, Make, n8n or your own server: which events exist, what each request looks like, and how to check the signature. · 6 min read

A webhook lets Billfetch tell another tool when something happens, for example when you approve a document. Billfetch sends a short message (an HTTPS POST with a JSON body) to an address you choose. Zapier, Make and n8n give you that address, so you can connect Billfetch to hundreds of apps without writing code.

Add a webhook

  1. In Zapier, Make or n8n, create a trigger called Webhooks by Zapier › Catch Hook, Custom webhook or Webhook. Copy the address it gives you.
  2. In Billfetch, open Settings and choose Webhooks under Integrations.
  3. Paste the address. It must start with https://.
  4. Under Events to send, tick one or more events. You need at least one.
  5. Select Add webhook.

Billfetch shows the signing secret once, straight after you add the webhook. Select Copy and keep it somewhere safe. If you’re only using Zapier or Make, you can skip the secret. If your own server receives the requests, use it to check that each request really came from Billfetch (see below).

Send a test

Select Send test next to a webhook. Billfetch sends a webhook.test event straight away and shows the result on the same line:

  • Delivered · HTTP 200 · 312 ms means the address answered and everything works. In Zapier or Make, you can now use the test request to set up the next steps.
  • Your endpoint answered HTTP 404 (or another code) means the address answered but refused the request. Check that the address is complete and that the Zap or scenario is switched on.
  • No answer within 10 seconds or Could not connect means the address is wrong or the server is down.
  • The address points to a private or local network means the address isn’t publicly reachable. Billfetch never sends to private networks.

You can send up to 5 tests per webhook every 10 minutes. The test body lists the events you chose and an example of each, so you can map fields before a real event arrives.

Events

EventWhen it’s sent
document.receivedA new document arrives: upload, email, Telegram, a connection or an import. Copies of a document that’s already in your vault don’t trigger it.
document.approvedYou approve a document in review.
export.completedAn accountant package has been generated.
missing.summaryOnce a day (07:00 UTC): how many items are still missing this month.
webhook.testOnly when you select Send test.

What a request looks like

Every request is a POST with Content-Type: application/json and these headers:

HeaderMeaning
Billfetch-EventThe event type, for example document.approved.
Billfetch-DeliveryA unique ID for this delivery. It stays the same when a delivery is retried, so you can ignore repeats.
Billfetch-Signaturet=<unix time>,v1=<signature>. See “Check the signature”.
User-AgentBillfetch-Webhooks/1.0

The body always has the same envelope. id is the event ID (the same for every webhook that receives this event), and data depends on the event:

{
  "id": "3f0b6a52-7a7e-4c1d-9f8e-0f3c2b1a9d10",
  "type": "document.approved",
  "occurredAt": "2026-10-17T09:30:00+00:00",
  "tenantId": "8d2c4e1a-0b5f-4a7e-9c3d-2e1f0a9b8c7d",
  "data": {
    "documentId": "00000000-0000-0000-0000-000000000001",
    "ledgerItemId": "00000000-0000-0000-0000-000000000002",
    "entityId": "00000000-0000-0000-0000-000000000003",
    "categoryId": "00000000-0000-0000-0000-000000000004",
    "supplier": "Example Supplies Ltd",
    "invoiceNumber": "INV-1042",
    "issueDate": "2026-10-15",
    "currency": "GBP",
    "gross": 120.00,
    "tax": 20.00
  }
}

data for the other events:

// document.received
{ "documentId": "…", "filename": "invoice-1042.pdf", "source": "Upload", "receivedAt": "2026-10-17T09:30:00+00:00",
  "mime": "application/pdf", "size": 48213, "entityId": null }

// export.completed
{ "exportJobId": "…", "entityId": "…", "from": "2026-09-01T00:00:00+00:00", "to": "2026-09-30T23:59:59+00:00",
  "profile": "xero_csv", "mode": "full", "documents": 42, "contentHash": "9f86d0…" }

// missing.summary — counts per section of the Missing list
{ "period": "2026-10", "sections": { "a": 3, "b": 1, "c": 0 } }

source is one of Upload, Imap (email), Connector, CompaniesHouse, Telegram, Migration (Dext/AutoEntry import), Accountant or AccountantUnverified. Amounts are numbers, dates are ISO 8601. New fields may be added over time, so ignore fields you don’t recognise.

Check the signature

Do this if your own server receives the requests. It proves the request came from Billfetch and wasn’t changed.

  1. Read the Billfetch-Signature header and split it into t (a Unix timestamp) and v1 (the signature).
  2. Join t, a full stop and the raw request body exactly as received, for example 1760693400.{"id":…}. Don’t parse and re-serialise the JSON first.
  3. Compute HMAC-SHA256 of that text with your whole signing secret (including the whsec_ prefix) as the key, and write it as lowercase hex.
  4. Compare it with v1 using a constant-time comparison. Reject the request if they differ, or if t is more than 5 minutes away from your server’s clock.

Node.js:

import crypto from 'node:crypto'

// rawBody: the body exactly as received (string or Buffer), not re-serialised JSON.
export function verifyBillfetch(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
  const t = Number(parts.t)
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false
  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest()
  const given = Buffer.from(parts.v1 ?? '', 'hex')
  return given.length === expected.length && crypto.timingSafeEqual(given, expected)
}

Python:

import hashlib
import hmac
import time


def verify_billfetch(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts.get("t", "0"))
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

Retries and timeouts

  • Billfetch waits up to 10 seconds for an answer. Any 2xx status counts as delivered. Redirects aren’t followed.
  • If a delivery fails, Billfetch tries again after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours: 6 attempts in total over about 15 hours. After that the delivery is marked Failed.
  • Test events are sent once and not retried.
  • Deliveries can arrive more than once or out of order. Use Billfetch-Delivery or the event id to ignore repeats.
  • Recent deliveries in the webhook card shows the latest deliveries with their status and the last HTTP code.

Keep the secret safe

  • If the secret leaks, or you’ve lost it, select New secret next to the webhook and confirm. The old secret stops working immediately, so paste the new one into your tool straight away.
  • Remove stops all deliveries to that address, including pending retries.
  • Billfetch only sends to public https:// addresses. Plain http, localhost and private network addresses are refused.
Still need a hand? Contact support, or try it yourself — signing up takes a minute.