MoneyBird has roughly 2 million customers, the majority of them Dutch SMBs using it as their primary accounting system. If you're building a platform that handles money for European businesses — payments, subscriptions, travel bookings, vertical SaaS — you will eventually get asked for MoneyBird sync. This guide covers everything you need to build that integration, including the parts of the API that behave differently from what you'd expect coming from other accounting platforms.
Two things that differ from most REST APIs before you write a single line
MoneyBird's API has two structural choices that aren't standard and will catch you off guard if you skip straight to the endpoint reference.
First: the response format is part of the URL path, not a header. You don't use Accept: application/json. You append .json (or .xml) to every endpoint:
GET https://moneybird.com/api/v2/{administration_id}/contacts.json
This means generic API clients that rely on standard content negotiation need extra handling. If you're getting 404s on an endpoint you know exists, a missing format suffix is the first thing to check.
Second: filtering uses dedicated /filter sub-endpoints with a key:value query syntax, not conventional query string parameters. This matters for any sync job that needs to pull a subset of records.
Authentication
MoneyBird supports two authentication models.
The first is a personal API token. You generate one from moneybird.com/user/applications/new, and you pass it as a Bearer token on every request:
Authorization: Bearer {your_token}
Personal tokens are fast to set up and fine for internal tools and scripts that access a single MoneyBird account. The hard limit: if you're building a product where your customers connect their own MoneyBird accounts, personal tokens don't work. Each customer would have to generate a token manually and paste it into your app.
The second path is OAuth 2.0. You register your application once at moneybird.com/user/applications/new, receive a Client ID and Client Secret, and implement the authorization code flow. The authorize endpoint is https://moneybird.com/oauth/authorize and the token endpoint is https://moneybird.com/oauth/token. MoneyBird access tokens don't expire by default, but a user can revoke them at any time through their account settings. Build revocation handling from day one — a revoked token returns 401 and your integration needs to detect that and prompt the user to reconnect, not silently fail.
Available OAuth scopes include sales_invoices, purchase_invoices, contacts, financial_mutations, time_entries, documents, and administration. Request only what your application needs.
Getting your administration ID
Every API call requires an administration ID in the path. You retrieve it by calling the administrations endpoint after auth:
import requests
import os
access_token = os.environ["MONEYBIRD_TOKEN"]
headers = {"Authorization": f"Bearer {access_token}"}
response = requests.get(
"https://moneybird.com/api/v2/administrations.json",
headers=headers
)
response.raise_for_status()
administrations = response.json()
administration_id = administrations[0]["id"]
print(f"Administration ID: {administration_id}")
One user can have multiple administrations (separate legal entities, for example). If your product supports multi-administration scenarios, store each id separately — you can't assume there's only one.
Core endpoints
Sales invoices live at /{administration_id}/sales_invoices.json. Each invoice object includes invoice_id, contact_id, state (draft, open, late, paid, uncollectible), total_price_incl_tax, due_date, and line items under details.
Contacts are at /{administration_id}/contacts.json. You'll need a valid contact ID before creating invoices programmatically, so contacts are usually the first resource you sync.
Purchase invoices sit at /{administration_id}/documents/purchase_invoices.json. Note the documents/ prefix in the path — it's different from the sales invoice path and the asymmetry catches people.
Financial mutations live at /{administration_id}/financial_mutations.json and represent bank transaction-level data. The official changelog explicitly warns against fetching individual mutations in a tight loop because the rate limit hits fast. Use the synchronization API for bulk pulls (covered below).
Ledger accounts are at /{administration_id}/ledger_accounts.json. Since July 2024, creating ledger accounts without an RGS taxonomy item code (rgs_code) is blocked. If your integration creates ledger accounts, you need to include a valid RGS code or the request will fail.
Pagination
MoneyBird uses page and per_page query parameters. The maximum page size is 100 records. What the documentation buries: MoneyBird does not return total record counts. There's no total or count field in the response body.
Instead, MoneyBird uses HTTP Link headers to signal whether more pages exist:
Link: <https://moneybird.com/api/v2/123/sales_invoices.json?page=2>; rel="next"
When there's no rel="next" in the Link header, you're on the last page. Here's a complete Python function that handles pagination correctly, rate limit responses, and the missing total count:
import requests
import time
import os
def fetch_all_sales_invoices(administration_id: str, access_token: str) -> list:
headers = {"Authorization": f"Bearer {access_token}"}
base_url = f"https://moneybird.com/api/v2/{administration_id}/sales_invoices.json"
invoices = []
page = 1
while True:
response = requests.get(
base_url,
headers=headers,
params={"page": page, "per_page": 100}
)
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", 30))
print(f"Rate limited. Waiting {retry_after} seconds.")
time.sleep(retry_after)
continue
response.raise_for_status()
batch = response.json()
invoices.extend(batch)
# Check Link header for next page
link_header = response.headers.get("Link", "")
if 'rel="next"' not in link_header:
break
page += 1
return invoices
if __name__ == "__main__":
token = os.environ["MONEYBIRD_TOKEN"]
admin_id = os.environ["MONEYBIRD_ADMINISTRATION_ID"]
all_invoices = fetch_all_sales_invoices(admin_id, token)
print(f"Total invoices fetched: {len(all_invoices)}")
This matters for anything where you need to know how many total records exist — progress indicators, sync job sizing, confirming a full sync completed. You have to traverse all pages first. Build your sync jobs with that assumption baked in.
Filtering
To filter sales invoices by state, you don't append ?state=open. You POST to a dedicated filter endpoint:
POST /{administration_id}/sales_invoices/filter.json
Content-Type: application/json
{
"filter": "state:open"
}
Multiple conditions combine with commas:
{
"filter": "state:open,contact_id:143273868766741508"
}
The filter endpoint respects the same pagination behavior as the list endpoint — check the Link header, don't assume a single response covers everything. The supported filter keys per resource are documented on each endpoint's reference page. Combinations that seem logical but aren't listed will either silently return unfiltered results or fail validation.
Rate limits
The API allows 150 requests per 5 minutes. Exceeding that returns a 429 Too Many Requests response with a Retry-After header. The Python example above handles this correctly with a sleep-and-retry pattern.
At 150 requests per 5 minutes, the limit is workable for background sync jobs if you batch reads efficiently. It becomes a problem with chatty patterns: fetching individual records in a loop, resolving contacts one at a time inside an invoice iteration, or polling for changes on a short interval. The synchronization API solves most of these cases.
Synchronization API
For bulk reads on resources like financial mutations or products, MoneyBird exposes a two-step synchronization flow.
Step one: fetch the synchronization list to get all record IDs and version numbers without pulling full record data:
GET /{administration_id}/sales_invoices/synchronization.json
This returns a flat array of {id, version} objects for every record. It's fast and cheap against the rate limit.
Step two: compare those versions against your local cache, then POST only the IDs that have changed to the bulk fetch endpoint:
POST /{administration_id}/sales_invoices/synchronization.json
Content-Type: application/json
{
"ids": ["143273868766741508", "143274315994891267"]
}
This returns full record detail only for the IDs you requested. On a recurring sync job, this pattern can reduce your API calls by 90% or more compared to paginating through all records on every run.
Webhooks
MoneyBird supports webhooks for real-time event notifications. Register an endpoint with a POST to /{administration_id}/webhooks.json with your target URL and the event types you want to receive.
Your endpoint must respond with HTTP 200. If it doesn't, MoneyBird retries delivery 10 times with gradually increasing intervals between attempts. The webhook payload includes the event type, administration ID, and the full entity object that changed.
Use HTTPS. The official documentation explicitly recommends against plain HTTP because invoice and contact payloads pass through unencrypted otherwise.
One useful feature for debugging: the webhooks API stores the last HTTP status code and response body from each delivery attempt. You can retrieve this via a GET to your webhook object — useful when you need to diagnose delivery failures without digging through server logs.
External sales invoices: linking contacts at creation
A PATCH to an external sales invoice with only contact_id in the payload can behave unexpectedly — it can affect more invoices than intended. This is a known MoneyBird API edge case that surfaces when processing documents in volume.
The fix is to assign contact_id at creation time, not after the fact. Instead of uploading the PDF first and then PATCHing the contact, restructure the flow:
-
Check or create the contact (most integrations already do this).
-
Create the external sales invoice with
contact_idincluded in the creation payload:
POST https://moneybird.com/api/v2/{admin_id}/external_sales_invoices.json
{
"external_sales_invoice": {
"contact_id": "{contact_id}",
"reference": "INV-001",
"date": "2025-01-15"
}
}
- Attach the PDF to the invoice after creation:
POST https://moneybird.com/api/v2/{admin_id}/external_sales_invoices/{invoice_id}/attachments
The invoice is linked to the contact from the moment it's created. The risky PATCH is removed from the flow entirely.
Documentation drift
One real operational issue that costs teams time: some fields appear as deprecated in the changelog but still show up in the API reference. In several cases, PATCH and POST behavior doesn't match what the docs describe. Budget time for empirical testing beyond reading the reference.
A specific one to watch in 2026: the detail_id field on time entry objects is being removed after July 31, 2025. If your integration uses time entries and maps against detail_id, you need to migrate to sales_invoice_id, which was added to the PATCH and POST endpoints in the same release.
Build vs. buy
Building directly against MoneyBird makes sense when it's your only target accounting system. If your customers also use Xero, QuickBooks, Exact Online, or FreeAgent, you'll repeat most of this work for each one. Different auth flows, different entity models, different pagination behavior, different filter syntax, different webhook structures. Every new accounting connector is a meaningful engineering and maintenance commitment.
Apideck's accounting API normalizes MoneyBird alongside QuickBooks, Xero, Exact Online, and 25+ other systems behind a single schema. One integration, one OAuth flow, standardized objects for invoices, contacts, ledger accounts, and payments. MoneyBird's pagination quirks and filter syntax stay abstracted. If you're at a vertical SaaS company or a platform moving money on behalf of European businesses, running the build-versus-buy numbers before writing your eighth accounting connector is worth the hour.
Ready to get started?
Scale your integration strategy and deliver the integrations your customers need in record time.








