# 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_id}/webhooks.json` |
| 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_invoice_state_changed_to_open`, `sales_invoice_state_changed_to_paid`, `sales_invoice_state_changed_to_late`, `sales_invoice_marked_as_uncollectible` |
| External sales invoices | `external_sales_invoice_created`, `external_sales_invoice_state_changed_to_paid` |
| Estimates | `estimate_created`, `estimate_send_email`, `estimate_accepted_contact`, `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:

```json
{
  "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.

```bash
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.

```javascript
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:

```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](https://github.com/hookdeck/webhook-skills/tree/main/skills/moneybird-webhooks) leaves that window off by default, so it accepts Hookdeck retries and replays of older events. If you turn the window on with `MONEYBIRD_TIMESTAMP_TOLERANCE_SECONDS`, 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](/event-gateway) answers Moneybird with `200`, verifies the `Moneybird-Signature` header, 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_events` on 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](/docs/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`, not `202` or `204`.
* Check `last_http_status` and `last_http_body` through 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](/docs/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 `action` and `state` to 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](/webhooks/guides/implement-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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## 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](https://hookdeck.com) sits in front of your receiver, checks every Moneybird signature, and holds each push until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Moneybird webhooks reliably in minutes.