Guide to Moneybird Webhooks: Features and Best Practices
Moneybird webhooks tell your systems when something changes in a Moneybird administration: an invoice is created, sent, paid or goes late, an estimate is accepted, a contact is added or a payment is registered. If you build on Moneybird, the Dutch bookkeeping and invoicing platform, webhooks are how those changes reach your CRM, your shop or your own reporting without polling the API.
This guide covers how Moneybird webhooks are registered and signed, the events and payload, how retries and rotation behave, and the practices that keep a Moneybird receiver reliable.
What are Moneybird webhooks?
A Moneybird webhook is a URL registered through the API for one administration, with an optional list of events. When one of those events happens, Moneybird sends a JSON POST naming the event in action and carrying the affected record in entity.
Every delivery has a Moneybird-Signature header in the form t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of {t}.{raw body} keyed with the webhook's secret. During a secret rotation the header carries one v1 per active secret. Each delivery also has an Idempotency-Key header that is unique to the push.
Moneybird webhook features
| Feature | Details |
|---|---|
| Configuration | API only: POST /api/v2/{administration_ |
| Scope | Per administration. Any number of webhooks |
| Event selection | enabled_events: event names, or a group prefix such as estimate |
| Default | No enabled_events means sales invoice state changes only |
| Registration check | The URL must answer 200 |
| Event name | action in the body |
| Signature header | Moneybird-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>] |
| Scheme | HMAC-SHA256, hex digest |
| Signed content | {t}.{raw body} |
| Secret | Returned once by the create call. Revealable and rotatable in the app after MFA |
| Rotation | A grace period you choose, with one v1 per active secret |
| Idempotency | Idempotency-Key header, unique per push |
| Acknowledgement | Exactly 200 |
| Retries | 10, at gradually longer intervals |
| Debugging | The webhooks API stores each webhook's last_http_status and last_http_body |
| Source IPs | Not published |
Common events
Moneybird documents 343 event names. Some of the ones most integrations use:
| Area | Events |
|---|---|
| Sales invoices | sales_invoice_created, sales_invoice_updated, sales_invoice_destroyed, sales_invoice_send_email, sales_, sales_, sales_, sales_ |
| External sales invoices | external_, external_ |
| Estimates | estimate_created, estimate_send_email, estimate_, estimate_mark_rejected, estimate_billed |
| Contacts | contact_created, contact_changed, contact_destroyed, contact_merged |
| Payments | payment_registered, payment_destroyed |
| Documents | document_saved, document_updated, document_destroyed |
| Subscriptions | subscription_created, subscription_edited, subscription_cancelled |
Names are verbs and states joined with underscores, without dots. To subscribe to a whole area, put its top-level name in enabled_events, for example estimate for every event that starts with it. The full list is on Moneybird's Events page.
Here is Moneybird's example payload, with the invoice trimmed:
{
"administration_id": "116015326147118082",
"webhook_id": "116010948233266179",
"webhook_token": "hPCNhskk1PwGC1pyJZMZmKNT",
"entity_type": "SalesInvoice",
"entity_id": "116015245643744263",
"state": "late",
"action": "sales_invoice_state_changed_to_late",
"entity": {
"id": "116015245643744263",
"contact_id": "116015245567198212",
"state": "late",
"reference": "Project X",
"currency": "EUR",
"paid_at": null,
"details": [{ "description": "Project X", "price": "300.0" }]
}
}
entity is the record as the API returns it. state is the state that triggered the push, while entity.state is the current state in the database, and the two can differ if the record changed again in the meantime. IDs are strings of digits, and webhook_token is a static value from before signing existed, not a signature.
Setting up Moneybird webhooks
Start your receiver first: Moneybird checks that the URL answers 200 when you register it.
curl -X POST "https://moneybird.com/api/v2/$ADMINISTRATION_ID/webhooks.json" \
-H "Authorization: Bearer $MONEYBIRD_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/webhooks/moneybird", "enabled_events": ["sales_invoice_state_changed_to_paid", "payment_registered", "estimate"]}'
The response includes a secret field. Store it straight away, for example as MONEYBIRD_WEBHOOK_SECRET: it is the only time the API returns it, and GET, activate and deactivate responses leave it out. If you lose it, open the webhook's detail page in Moneybird and reveal it after an MFA confirmation. Webhooks created before signing was introduced were given a secret too, which you can reveal the same way.
Always pass enabled_events. Without it, a webhook uses Moneybird's old behaviour and only reports sales invoice state changes.
If you receive Moneybird webhooks through Hookdeck, register the Hookdeck source URL and paste the secret into the source. Hookdeck answers Moneybird's registration check with 200.
Securing Moneybird webhooks
Split Moneybird-Signature on commas, take t and every v1, and ignore any other scheme. Compute an HMAC-SHA256 of {t}.{raw body} with the secret, hex-encode it, and accept the request if any v1 matches in constant time.
const crypto = require('crypto');
const express = require('express');
function verifyMoneybirdSignature(rawBody, header, secret) {
if (!header || !secret) return false;
let t;
const candidates = [];
for (const part of header.split(',')) {
const [key, ...rest] = part.trim().split('=');
if (key === 't') t = rest.join('=');
else if (key === 'v1') candidates.push(rest.join('='));
}
if (!/^\d+$/.test(t || '') || candidates.length === 0) return false;
const expected = Buffer.from(
crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex'),
'utf8'
);
return candidates.some((candidate) => {
const received = Buffer.from(candidate.toLowerCase(), 'utf8');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
const app = express();
app.post('/webhooks/moneybird', express.raw({ type: '*/*' }), (req, res) => {
const secret = process.env.MONEYBIRD_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500); // fail closed
if (!verifyMoneybirdSignature(req.body, req.get('moneybird-signature'), secret)) {
return res.sendStatus(401);
}
const idempotency_key = req.get('idempotency-key');
const push = JSON.parse(req.body.toString('utf8'));
// Deduplicate on idempotency_key, route on push.action, then process asynchronously.
res.sendStatus(200);
});
The same check in Python:
import hashlib
import hmac
def verify_moneybird_signature(raw_body: bytes, header, secret: str) -> bool:
if not header or not secret:
return False
t, candidates = None, []
for part in header.split(","):
key, _, value = part.strip().partition("=")
if key == "t":
t = value
elif key == "v1":
candidates.append(value)
if not t or not (t.isascii() and t.isdigit()) or not candidates:
return False
expected = hmac.new(
secret.encode("utf-8"), t.encode("utf-8") + b"." + raw_body, hashlib.sha256
).hexdigest().encode("utf-8")
return any(hmac.compare_digest(c.lower().encode("utf-8"), expected) for c in candidates)
Moneybird's step 6 says to reject the request if t is more than 5 minutes from the current time. The Moneybird webhook skill leaves that window off by default, so it accepts Hookdeck retries and replays of older events. If you turn the window on with MONEYBIRD_, Hookdeck retries and replays resend the original t, so any that arrive after the window has passed fail the check. Use Idempotency-Key to catch repeats.
Make Moneybird webhooks production-ready. Hookdeck Event Gateway answers Moneybird with
200, verifies theMoneybird-Signatureheader, and stores every invoice, estimate and payment event before your service sees it.
Moneybird webhook limitations and pain points
Forgetting enabled_events
The Problem: A new webhook only reports sales invoice state changes, and none of the other events you expected.
Why It Happens: To avoid breaking existing integrations, a webhook with no enabled_events uses Moneybird's original behaviour, which only covers sales invoice state changes.
Workarounds:
- Always pass
enabled_eventson create. - Use a group's top-level name, such as
estimate, when you want every event in that group.
How Hookdeck Can Help: Hookdeck filters on action let one broad subscription feed several services, each receiving only the events it handles.
Exactly 200, and a limited number of retries
The Problem: Pushes are lost after an outage, or retried even though your service accepted them.
Why It Happens: Moneybird wants a 200. Any other status is retried 10 times at gradually longer intervals, and the intervals aren't published, so you can't tell how long an outage the retries cover.
Workarounds:
- Return exactly
200, not202or204. - Check
last_http_statusandlast_http_bodythrough the webhooks API when debugging. - Reconcile against the API after an outage.
How Hookdeck Can Help: Hookdeck answers with 200 as soon as a push arrives and stores it, then delivers to your service with automatic retries on a schedule you set.
The push state can be stale
The Problem: The handler acts on a state the record has already left.
Why It Happens: state is the state that triggered the push, and entity is the record at the time it was serialised. With retries and queues, a newer change may have happened before you process the push.
Workarounds:
- Use
actionandstateto decide what happened, and re-read the record from the API before acting on its current state.
How Hookdeck Can Help: Hookdeck keeps every push it receives, so you can inspect the sequence of action values for an entity when tracking down an ordering problem.
Best practices
Verify against the raw body
Moneybird warns that re-serialising the JSON changes the bytes and breaks verification. Use express.raw() in Express, await request.text() in a Next.js route handler and await request.body() in FastAPI, and parse only after the check passes.
Deduplicate on Idempotency-Key
Each push has its own Idempotency-Key, and Moneybird's retries make duplicates likely. Store the keys you've processed and skip repeats. See our guide to webhook idempotency.
Rotate with a grace period
When you rotate the secret on the webhook's detail page, choose a grace period long enough to deploy the new value. While both secrets are active, accepting any matching v1 keeps deliveries verifying.
Use HTTPS
Moneybird strongly recommends HTTPS, because payloads contain sensitive information such as contacts and invoices.
Acknowledge fast and process asynchronously
Verify, store the push, return 200, and do the work afterwards. See why to process webhooks asynchronously.
Conclusion
Moneybird signs every push with Moneybird-Signature: a timestamp and one hex HMAC-SHA256 of {t}.{raw body} per active secret. Register with an explicit enabled_events list, store the secret from the create response, accept any matching v1, keep the timestamp window optional, deduplicate on Idempotency-Key, and return exactly 200.
Hookdeck Event Gateway sits in front of your receiver, checks every Moneybird signature, and holds each push until your service has processed it.
Get started with Hookdeck for free and handle Moneybird webhooks reliably in minutes.