Exact Online is the leading cloud-based accounting software in the Netherlands and Belgium, with growing adoption across the UK, Germany, and other European markets. For developers building financial integrations, vertical SaaS products, or multi-tenant applications, the Exact Online API is essential—but it comes with unique challenges that trip up even experienced teams.
What You'll Learn
- How Exact Online's OAuth 2.0 flow works (and its unusual requirements)
- REST API structure with OData filtering
- Rate limiting strategies and how to handle them
- Multi-division architecture and why it matters
- Working code examples in Python and JavaScript
- How to reduce integration complexity by 10x with unified APIs
Understanding the Exact Online API Architecture
Two APIs, Different Purposes
Exact Online provides two distinct APIs:
| API Type | Protocol | Use Case | Limitations |
|---|---|---|---|
| REST API | OData-based REST | Primary integration method | 60 records per request (bulk: 1000) |
| XML API | SOAP/XML | Legacy operations, specific actions | Being phased out, limited support |
For new integrations, always use the REST API. The XML API exists primarily for edge cases not yet supported by REST—like programmatic invoice-payment matching (reconciliation), which has no REST endpoint. Note that XML reconciliation requires manual file upload through the Exact Online web interface rather than true API calls.
Multi-Division Architecture
Unlike most accounting software, Exact Online uses a division-based architecture. Every API call requires a division ID:
/api/v1/{division}/salesinvoice/SalesInvoices
A single Exact Online account can contain multiple divisions (essentially separate accounting environments). Before making any API call, you must:
- Authenticate the user
- Fetch their available divisions via
/api/v1/current/Me - Let them select which division to work with
- Include that division ID in all subsequent requests
This catches many developers off guard—you can't simply authenticate and start fetching invoices.
Authentication: OAuth 2.0 with Exact Online
Registering Your Application
Before you can authenticate users, register your app in the Exact Online App Center:
- Navigate to Manage > Apps
- Create a new app (choose Public or Private)
- Note your Client ID and Client Secret
- Configure your Callback URL
Critical limitation: Until your app passes Exact's internal review, it can only connect with users from the same Exact instance that created it. This means you cannot onboard pilot customers from other tenants until approval—a significant blocker for startups testing with beta users.
OAuth 2.0 Endpoints
Exact Online uses regional endpoints. Here are the primary ones:
| Region | Auth URL | Token URL |
|---|---|---|
| Netherlands | https://start.exactonline.nl/api/oauth2/auth | https://start.exactonline.nl/api/oauth2/token |
| Belgium | https://start.exactonline.be/api/oauth2/auth | https://start.exactonline.be/api/oauth2/token |
| UK | https://start.exactonline.co.uk/api/oauth2/auth | https://start.exactonline.co.uk/api/oauth2/token |
| Germany | https://start.exactonline.de/api/oauth2/auth | https://start.exactonline.de/api/oauth2/token |
Native OAuth Implementation
Here's a complete OAuth 2.0 implementation in Python:
import requests
import base64
from urllib.parse import urlencode
class ExactOnlineAuth:
def __init__(self, client_id, client_secret, redirect_uri, region='nl'):
self.client_id = client_id
self.client_secret = client_secret
self.redirect_uri = redirect_uri
self.base_url = f"https://start.exactonline.{region}"
self.token_data = None
def get_authorization_url(self):
"""Generate the OAuth authorization URL."""
params = {
'client_id': self.client_id,
'redirect_uri': self.redirect_uri,
'response_type': 'code',
'force_login': 0 # Set to 1 to force re-authentication
}
return f"{self.base_url}/api/oauth2/auth?{urlencode(params)}"
def exchange_code_for_token(self, authorization_code):
"""Exchange authorization code for access token."""
token_url = f"{self.base_url}/api/oauth2/token"
# Exact Online requires Basic auth header for token exchange
credentials = base64.b64encode(
f"{self.client_id}:{self.client_secret}".encode()
).decode()
headers = {
'Authorization': f'Basic {credentials}',
'Content-Type': 'application/x-www-form-urlencoded'
}
data = {
'grant_type': 'authorization_code',
'code': authorization_code,
'redirect_uri': self.redirect_uri
}
response = requests.post(token_url, headers=headers, data=data)
if response.status_code != 200:
raise Exception(f"Token exchange failed: {response.text}")
self.token_data = response.json()
return self.token_data
def refresh_token(self):
"""Refresh the access token using the refresh token."""
if not self.token_data or 'refresh_token' not in self.token_data:
raise Exception("No refresh token available")
token_url = f"{self.base_url}/api/oauth2/token"
credentials = base64.b64encode(
f"{self.client_id}:{self.client_secret}".encode()
).decode()
headers = {
'Authorization': f'Basic {credentials}',
'Content-Type': 'application/x-www-form-urlencoded'
}
data = {
'grant_type': 'refresh_token',
'refresh_token': self.token_data['refresh_token']
}
response = requests.post(token_url, headers=headers, data=data)
if response.status_code != 200:
raise Exception(f"Token refresh failed: {response.text}")
self.token_data = response.json()
return self.token_data
def get_current_division(self):
"""Fetch the user's current division."""
if not self.token_data:
raise Exception("Not authenticated")
headers = {
'Authorization': f"Bearer {self.token_data['access_token']}",
'Accept': 'application/json'
}
response = requests.get(
f"{self.base_url}/api/v1/current/Me",
headers=headers
)
if response.status_code != 200:
raise Exception(f"Failed to fetch user info: {response.text}")
user_data = response.json()
return user_data['d']['results'][0]['CurrentDivision']
That's about 100 lines just for authentication. And you still need to handle:
- Token storage and encryption
- Automatic token refresh before expiry
- Multi-region endpoint routing
- Division selection UI
- Error handling for revoked tokens
Working with the REST API
OData Query Parameters
Exact Online's REST API is OData-based, supporting these parameters:
| Parameter | Description | Example |
|---|---|---|
$select | Choose specific fields | $select=InvoiceID,InvoiceNumber,AmountDC |
$filter | Filter results | $filter=AmountDC gt 1000 |
$orderby | Sort results | $orderby=InvoiceDate desc |
$top | Limit results (max 60, bulk: 1000) | $top=60 |
$skip | Pagination offset | $skip=60 |
Important: Unlike many OData implementations, Exact Online does not support the $expand parameter. Related entities (such as invoice line items) must be fetched through separate API calls rather than expanded inline.
Fetching Sales Invoices
class ExactOnlineClient:
def __init__(self, auth, division_id):
self.auth = auth
self.division_id = division_id
self.base_url = f"{auth.base_url}/api/v1/{division_id}"
def _get_headers(self):
return {
'Authorization': f"Bearer {self.auth.token_data['access_token']}",
'Accept': 'application/json',
'Content-Type': 'application/json'
}
def get_invoices(self, top=60, skip=0, filter_expr=None, order_by=None):
"""Fetch sales invoices with OData parameters."""
url = f"{self.base_url}/salesinvoice/SalesInvoices"
params = {
'$top': top,
'$skip': skip,
'$select': 'InvoiceID,InvoiceNumber,InvoiceTo,InvoiceDate,AmountDC,Currency,Status'
}
if filter_expr:
params['$filter'] = filter_expr
if order_by:
params['$orderby'] = order_by
response = requests.get(url, headers=self._get_headers(), params=params)
if response.status_code == 429:
# Rate limited - check headers for retry info
raise RateLimitError("Rate limit exceeded", response.headers)
if response.status_code != 200:
raise Exception(f"API error: {response.text}")
data = response.json()
return data['d']['results']
def get_invoice_by_id(self, invoice_id):
"""Fetch a single invoice by GUID."""
url = f"{self.base_url}/salesinvoice/SalesInvoices(guid'{invoice_id}')"
response = requests.get(url, headers=self._get_headers())
if response.status_code == 404:
return None
if response.status_code != 200:
raise Exception(f"API error: {response.text}")
return response.json()['d']
def get_invoice_lines(self, invoice_id):
"""Fetch invoice lines separately (no $expand support)."""
url = f"{self.base_url}/salesinvoice/SalesInvoiceLines"
params = {
'$filter': f"InvoiceID eq guid'{invoice_id}'"
}
response = requests.get(url, headers=self._get_headers(), params=params)
if response.status_code != 200:
raise Exception(f"API error: {response.text}")
return response.json()['d']['results']
def create_invoice(self, invoice_data):
"""Create a new sales invoice."""
url = f"{self.base_url}/salesinvoice/SalesInvoices"
response = requests.post(
url,
headers=self._get_headers(),
json=invoice_data
)
if response.status_code not in [200, 201]:
raise Exception(f"Failed to create invoice: {response.text}")
return response.json()['d']
def get_all_invoices_paginated(self, filter_expr=None):
"""Fetch all invoices with automatic pagination."""
all_invoices = []
skip = 0
while True:
batch = self.get_invoices(top=60, skip=skip, filter_expr=filter_expr)
if not batch:
break
all_invoices.extend(batch)
# Exact Online returns exactly $top results if more exist
if len(batch) < 60:
break
skip += 60
return all_invoices
Creating an Invoice with Line Items
def create_complete_invoice(client, customer_id):
"""Create an invoice with line items."""
invoice_data = {
'InvoiceTo': customer_id, # GUID of the customer account
'OrderDate': '2025-01-30',
'Description': 'Professional Services - January 2025',
'PaymentCondition': '30', # Net 30 payment terms
'Currency': 'EUR',
'SalesInvoiceLines': [
{
'Item': 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', # Item GUID
'Description': 'Consulting Services',
'Quantity': 40,
'UnitPrice': 150.00,
'VATCode': '1' # Standard VAT
},
{
'Item': 'b2c3d4e5-f6a7-8901-bcde-f12345678901',
'Description': 'Implementation Support',
'Quantity': 20,
'UnitPrice': 125.00,
'VATCode': '1'
}
]
}
return client.create_invoice(invoice_data)
Rate Limits: Understanding the Constraints
Exact Online applies rate limiting at two levels—per minute and per day—for each app-company combination:
| Limit Type | Threshold | Reset |
|---|---|---|
| Per Minute | 60 calls | Resets every minute |
| Per Day | 5,000 calls | Resets at midnight (company timezone) |
Note: While some partners may negotiate higher limits for specific use cases, there is no publicly documented tiered pricing structure. Plan your integration around the standard limits.
Rate Limit Headers
When you hit a limit, you'll receive HTTP 429. Exact Online uses a dual-header system for minutely and daily limits:
Minutely limit headers (sent when minutely limit is exhausted):
X-RateLimit-Minutely-Remaining: 0
X-RateLimit-Minutely-Reset: 1706648460
Daily limit headers:
X-RateLimit-Limit: 5000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1706684400
Important: When you have no more minutely calls available, Exact only sends the minutely headers. Your error handling should check for both header variants.
Rate Limit Handler:
import time
class RateLimitError(Exception):
def __init__(self, message, headers):
self.message = message
self.headers = headers
# Check for minutely-specific headers first
self.minutely_remaining = headers.get('X-RateLimit-Minutely-Remaining')
self.minutely_reset = headers.get('X-RateLimit-Minutely-Reset')
# Fall back to daily headers
self.daily_remaining = headers.get('X-RateLimit-Remaining')
self.daily_reset = headers.get('X-RateLimit-Reset')
super().__init__(message)
@property
def is_minutely_limit(self):
return self.minutely_remaining is not None
@property
def reset_time(self):
if self.is_minutely_limit:
return int(self.minutely_reset) if self.minutely_reset else 0
return int(self.daily_reset) if self.daily_reset else 0
class RateLimitedClient:
def __init__(self, client):
self.client = client
def execute_with_retry(self, func, *args, max_retries=3, **kwargs):
"""Execute API call with automatic rate limit handling."""
retries = 0
while retries < max_retries:
try:
return func(*args, **kwargs)
except RateLimitError as e:
retries += 1
if not e.is_minutely_limit:
# Daily limit - can't retry today
raise Exception(
"Daily rate limit exceeded. "
f"Resets at {time.ctime(e.reset_time)}"
)
# Minutely limit - wait and retry
wait_time = max(60, e.reset_time - int(time.time()))
print(f"Minutely rate limited. Waiting {wait_time}s...")
time.sleep(wait_time)
raise Exception("Max retries exceeded")
Common Pitfalls and Gotchas
1. The Reconciliation Problem
One of the most requested features—automatically matching payments to invoices—has no REST API endpoint. You can:
- Create invoices via API
- Create payments via API
- But you cannot programmatically reconcile them via API
The only workaround is uploading XML files with matching information through the Exact Online web interface—not through an API call. This requires manual intervention and breaks many automated accounting workflows.
2. No $expand Support
Unlike standard OData implementations, Exact Online does not support $expand. This means you cannot retrieve an invoice with its line items in a single request. Instead, you must:
# Fetch invoice
invoice = client.get_invoice_by_id(invoice_id)
# Fetch line items separately
lines = client.get_invoice_lines(invoice_id)
This doubles your API calls for any operation requiring related data.
3. Division Selection is Mandatory
Every request needs the division ID. If you hardcode it, your integration breaks when users switch divisions or have multiple. Always:
# On initial connection
divisions = client.get_user_divisions()
selected_division = prompt_user_to_select(divisions)
store_division_preference(user_id, selected_division)
4. App Approval Bottleneck
Your app won't work with external customers until Exact approves it. This process can take weeks, during which:
- You can only test with your own instance
- Beta customers can't connect
- You're blocked on external validation
Plan for this in your launch timeline.
5. Regional Endpoint Complexity
Unlike most APIs with a single global endpoint, Exact Online requires you to:
- Know which region the customer is in
- Route to the correct regional endpoint
- Handle token refresh per region
REGIONS = {
'nl': 'https://start.exactonline.nl',
'be': 'https://start.exactonline.be',
'uk': 'https://start.exactonline.co.uk',
'de': 'https://start.exactonline.de',
'us': 'https://start.exactonline.com',
'es': 'https://start.exactonline.es',
'fr': 'https://start.exactonline.fr'
}
def get_client_for_region(region):
if region not in REGIONS:
raise ValueError(f"Unsupported region: {region}")
return ExactOnlineAuth(
client_id=CLIENT_ID,
client_secret=CLIENT_SECRET,
redirect_uri=REDIRECT_URI,
region=region
)
The Simpler Path: Unified Accounting APIs
Building and maintaining direct Exact Online integrations requires:
- ~500 lines of authentication and API client code
- Rate limit handling with dual header support
- Multi-region endpoint management
- Division selection UI
- Separate calls for related entities (no $expand)
- Ongoing maintenance as Exact updates their API
For teams building multi-platform accounting integrations, this complexity multiplies. Add Xero, QuickBooks, Sage, and FreshBooks, and you're maintaining 5 separate integrations with different auth flows, data models, and quirks.
Before: Native Exact Online Integration
# Authentication setup
auth = ExactOnlineAuth(
client_id=os.environ['EXACT_CLIENT_ID'],
client_secret=os.environ['EXACT_CLIENT_SECRET'],
redirect_uri='https://app.example.com/callback',
region='nl'
)
# OAuth flow
auth_url = auth.get_authorization_url()
# ... redirect user, handle callback ...
auth.exchange_code_for_token(code)
# Get division
division_id = auth.get_current_division()
# Create client
client = ExactOnlineClient(auth, division_id)
# Fetch invoices with pagination and rate limiting
rate_limited_client = RateLimitedClient(client)
invoices = rate_limited_client.execute_with_retry(
client.get_all_invoices_paginated,
filter_expr="InvoiceDate gt datetime'2025-01-01'"
)
# Fetch line items separately for each invoice (no $expand)
for invoice in invoices:
invoice['lines'] = client.get_invoice_lines(invoice['InvoiceID'])
# Transform to your data model
normalized_invoices = [
{
'id': inv['InvoiceID'],
'number': inv['InvoiceNumber'],
'customer_id': inv['InvoiceTo'],
'amount': inv['AmountDC'],
'currency': inv['Currency'],
'status': map_exact_status(inv['Status']),
'date': parse_odata_date(inv['InvoiceDate']),
'line_items': inv['lines']
}
for inv in invoices
]
Total: ~600 lines of code across authentication, client, rate limiting, and data transformation.
After: Apideck Unified API
import apideck
client = apideck.Apideck(
api_key=os.environ['APIDECK_API_KEY'],
app_id=os.environ['APIDECK_APP_ID'],
consumer_id='user-123'
)
# Fetch invoices - works identically for Exact Online, Xero, QuickBooks, etc.
invoices = client.accounting.invoices.list(
service_id='exact-online',
filter={'updated_since': '2025-01-01T00:00:00Z'}
)
# Data is already normalized with line items included
for invoice in invoices.data:
print(f"Invoice {invoice.number}: {invoice.total} {invoice.currency}")
Total: 15 lines of code. Authentication, rate limiting, pagination, and data normalization handled automatically.
Feature Comparison
| Capability | Native Integration | Apideck Unified API |
|---|---|---|
| Authentication | Build OAuth flow, handle refresh, manage per-region | Handled via Vault UI |
| Rate Limiting | Implement dual-header logic | Normalized |
| Data Normalization | Map each field manually | Pre-normalized to unified schema |
| Related Entities | Separate API calls (no $expand) | Included automatically |
| Pagination | Implement cursor/offset logic | Automatic |
| Error Handling | Parse OData errors | Standardized error format |
| Multi-region | Route to correct endpoint | Automatic |
| Division Selection | Build selection UI | Handled in connection flow |
| Add QuickBooks | Build second integration | Change service_id parameter |
| Maintenance | Monitor Exact API changes | We handle updates |
Supported Exact Online Resources
Apideck's unified Accounting API supports these Exact Online resources:
- Invoices (Sales Invoices) - Full CRUD
- Bills (Purchase Invoices) - Read, Create
- Payments - Read
- Customers - Read with filtering
- Suppliers - Read with filtering
- Ledger Accounts - Read
- Journal Entries - Read, Create
- Tax Rates (VAT Codes) - Read
- Invoice Items - Read
- Credit Notes - Read
When to Use Native vs. Unified APIs
Choose Native Exact Online API When:
- You only need Exact Online (no other accounting platforms)
- You need Exact-specific features not in unified models
- You're building for a single tenant/region
- You have dedicated engineering resources for maintenance
Choose Unified API When:
- You need to support multiple accounting platforms
- You want faster time-to-market (days vs. weeks)
- You prefer normalized data models
- You want to avoid authentication complexity
- You don't want to maintain integrations long-term
Getting Started
Option 1: Native Integration
- Register at Exact Online App Center
- Create your OAuth application
- Implement the authentication flow above
- Build API client with rate limiting
- Map responses to your data model
- Submit for Exact review (required for production)
Option 2: Apideck Unified API
Step 1: Create Your Exact Online OAuth App
Even with Apideck, you need OAuth credentials. Here's the streamlined process:
- Visit apps.exactonline.com and sign in
- Click "Register a test app" (can be promoted to production later)
- Configure your app:
- App Name: Your application name
- Redirect URI:
https://unify.apideck.com/vault/callback
- Save your Client ID and Client Secret immediately
Important: The Client Secret is only shown once when you create the app. Copy it before closing the dialog.
For detailed instructions with screenshots, see our Exact Online OAuth Setup Guide.
Step 2: Configure Apideck
- Sign up at apideck.com
- Navigate to the Exact Online connector settings
- Select "Use your Exact Online client credentials"
- Enter your Client ID and Client Secret
- Click "Save settings"
Step 3: Connect Your Users
Use Apideck Vault to let your users authenticate:
- Open the Test Vault in your dashboard
- Select your API domain (nl, be, uk, etc.)
- Click "Authorize"
- Your users sign in at Exact Online
- Connection status shows "Connected"
Step 4: Start Making API Calls
# Test with curl
curl -X GET "https://unify.apideck.com/accounting/invoices" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "x-apideck-app-id: YOUR_APP_ID" \
-H "x-apideck-consumer-id: user-123" \
-H "x-apideck-service-id: exact-online"
Or use our SDKs:
import apideck
client = apideck.Apideck(
api_key=os.environ['APIDECK_API_KEY'],
app_id=os.environ['APIDECK_APP_ID'],
consumer_id='user-123'
)
# List all invoices
invoices = client.accounting.invoices.list(service_id='exact-online')
# Create an invoice
new_invoice = client.accounting.invoices.create(
service_id='exact-online',
invoice={
'type': 'service',
'customer': {'id': 'customer-guid'},
'line_items': [
{
'description': 'Consulting Services',
'quantity': 40,
'unit_price': 150.00,
'tax_rate': {'id': 'vat-standard'}
}
]
}
)
Conclusion
The Exact Online API is powerful but complex. Between OAuth flows, multi-division architecture, regional endpoints, rate limiting, the lack of $expand support, and the app approval process, building a production-ready integration takes significant effort.
For teams focused on delivering value rather than maintaining integrations, unified APIs offer a compelling alternative—same data, fraction of the code, and the flexibility to add other accounting platforms without rebuilding from scratch.
Whether you choose native or unified, the key is understanding Exact Online's unique requirements upfront. The division system, rate limits, regional endpoints, and OData limitations aren't optional—they're fundamental to how the API works.
Resources
Ready to get started?
Scale your integration strategy and deliver the integrations your customers need in record time.







