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
- In Zapier, Make or n8n, create a trigger called Webhooks by Zapier › Catch Hook, Custom webhook or Webhook. Copy the address it gives you.
- In Billfetch, open Settings and choose Webhooks under Integrations.
- Paste the address. It must start with
https://. - Under Events to send, tick one or more events. You need at least one.
- 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
| Event | When it’s sent |
|---|---|
document.received | A 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.approved | You approve a document in review. |
export.completed | An accountant package has been generated. |
missing.summary | Once a day (07:00 UTC): how many items are still missing this month. |
webhook.test | Only when you select Send test. |
What a request looks like
Every request is a POST with Content-Type: application/json and these headers:
| Header | Meaning |
|---|---|
Billfetch-Event | The event type, for example document.approved. |
Billfetch-Delivery | A unique ID for this delivery. It stays the same when a delivery is retried, so you can ignore repeats. |
Billfetch-Signature | t=<unix time>,v1=<signature>. See “Check the signature”. |
User-Agent | Billfetch-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.
- Read the
Billfetch-Signatureheader and split it intot(a Unix timestamp) andv1(the signature). - Join
t, a full stop and the raw request body exactly as received, for example1760693400.{"id":…}. Don’t parse and re-serialise the JSON first. - Compute HMAC-SHA256 of that text with your whole signing secret (including the
whsec_prefix) as the key, and write it as lowercase hex. - Compare it with
v1using a constant-time comparison. Reject the request if they differ, or iftis 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
2xxstatus 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-Deliveryor the eventidto 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. Plainhttp,localhostand private network addresses are refused.