Agent skill
Moneybird Webhooks Skill
Receive and verify Moneybird webhooks. Use when setting up Moneybird accounting webhook handlers, debugging Moneybird-Signature verification, or handling actions such as sales_invoice_created, sales_invoice_state_changed_to_paid, and contact_created.
Install this skill
npx skills add hookdeck/webhook-skills --skill moneybird-webhooks
When to Use This Skill
- How do I receive Moneybird webhooks?
- How do I verify the
Moneybird-Signatureheader? - How do I handle
sales_invoice_created,sales_, orinvoice_ state_ changed_ to_ paid contact_created? - How do I verify a signature with several
v1=values during a secret rotation? - Why is my Moneybird webhook signature verification failing?
Essential Code (USE THIS)
Every Moneybird delivery carries Moneybird-Signature: t=<unix seconds>,v1=<hex>. v1 is the HMAC-SHA256 (hex) of "{t}.{raw_request_body}", keyed with the webhook's secret as-is. The API returns secret only in the response to POST /{administration_; you can reveal it later on the webhook's detail page in Moneybird (after an MFA confirmation). During a secret rotation the header carries one v1= per active secret, so accept the request if any v1 matches.
Verification (Node.js)
const crypto = require('crypto');
// rawBody: Buffer of the exact request bytes (express.raw({ type: '*/*' }), not express.json)
function verifyMoneybird(rawBody, signatureHeader, secret) {
if (!secret || !signatureHeader) return false;
let t; const v1 = [];
for (const part of signatureHeader.split(',')) { // trim: "t=1, v1=..." is allowed
const i = part.indexOf('=');
const key = part.slice(0, i).trim(), value = part.slice(i + 1).trim();
if (i > 0 && key === 't' && t === undefined) t = value;
else if (i > 0 && key === 'v1') v1.push(value); // ignore unknown schemes
}
if (!t || !/^\d+$/.test(t) || v1.length === 0) return false;
// Optional replay window, off by default: retries and replays keep the original timestamp
const expected = crypto.createHmac('sha256', secret)
.update(`${t}.`).update(rawBody).digest('hex');
// 64-hex check first: Buffer.from(x, 'hex') silently truncates invalid hex
return v1.reduce((ok, d) => (/^[0-9a-f]{64}$/i.test(d) &&
crypto.timingSafeEqual(Buffer.from(d.toLowerCase()), Buffer.from(expected))) || ok, false);
}
Verification (Python)
import hashlib, hmac, re
def verify_moneybird(raw_body: bytes, signature_header: str, secret: str) -> bool:
if not secret or not signature_header:
return False
t, v1 = None, []
for part in signature_header.split(","):
key, sep, value = part.partition("=")
key, value = key.strip(), value.strip()
if sep and key == "t" and t is None:
t = value
elif sep and key == "v1":
v1.append(value)
if not t or not (t.isascii() and t.isdigit()) or not v1:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest().encode()
# bytes on both sides: a non-ASCII header value is a 400, not a TypeError/500
return any(re.fullmatch(r"[0-9a-f]{64}", d, re.I) and hmac.compare_digest(d.lower().encode(), expected)
for d in v1)
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
Timestamp window: off by default
Moneybird recommends rejecting a request whose t is more than 5 minutes from your clock. Hookdeck retries and replays resend the original timestamp, so a 5-minute window rejects late retries. The examples therefore skip the freshness check unless you set MONEYBIRD_ to a positive number of seconds (default 0 = off). Moneybird itself retries a non-200 response 10 times with gradually growing intervals, so the same applies without Hookdeck. Use the Idempotency-Key header for deduplication instead. references/verification.md shows exactly where the window can be dropped or widened.
Dispatch on action
The body is JSON with administration_id, webhook_id, webhook_token, entity_type (for example SalesInvoice), entity_id, state, action, and entity (the same JSON the API returns for that record). Dispatch on action, which is one of the event names you put in enabled_events:
action | Triggered when |
|---|---|
sales_invoice_created | A sales invoice is created |
sales_ | A sales invoice becomes paid |
sales_ | A sales invoice becomes late |
contact_created | A contact is created |
contact_changed | A contact is updated |
payment_registered | A payment is registered |
If you subscribe to no events, Moneybird falls back to the legacy behaviour (sales invoice state changes only). A group name such as estimate subscribes to every event in that group. See references/overview.md for the full event families and the payload.
Important Headers
| Header | Description |
|---|---|
Moneybird-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256>[,v1=<hex>...]; t is the delivery timestamp |
Idempotency-Key | Unique per push; the same value is sent on retries, so use it to dedupe |
Environment Variables
MONEYBIRD_WEBHOOK_SECRET=your_webhook_secret # `secret` returned by POST /{administration_id}/webhooks.json
MONEYBIRD_TIMESTAMP_TOLERANCE_SECONDS=0 # optional; a positive value opts in to the replay window
Local Development
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 moneybird --path /webhooks/moneybird
Reference Materials
- references/overview.md - Moneybird webhook concepts, events, payload
- references/setup.md - Creating the webhook through the API and saving the secret
- references/verification.md - Signature details, rotation, the timestamp window, and debugging