Back to blog

The Complete Guide to Exact Online API Integration in 2026

This guide covers everything you need to know about the Exact Online API: authentication flows, rate limits, endpoint patterns, common pitfalls, and how to build production-ready integrations without the typical headaches.

GJGJ

GJ

24 min read
The Complete Guide to Exact Online API Integration in 2026

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 TypeProtocolUse CaseLimitations
REST APIOData-based RESTPrimary integration method60 records per request (bulk: 1000)
XML APISOAP/XMLLegacy operations, specific actionsBeing 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:

  1. Authenticate the user
  2. Fetch their available divisions via /api/v1/current/Me
  3. Let them select which division to work with
  4. 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:

  1. Navigate to Manage > Apps
  2. Create a new app (choose Public or Private)
  3. Note your Client ID and Client Secret
  4. 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:

RegionAuth URLToken URL
Netherlandshttps://start.exactonline.nl/api/oauth2/authhttps://start.exactonline.nl/api/oauth2/token
Belgiumhttps://start.exactonline.be/api/oauth2/authhttps://start.exactonline.be/api/oauth2/token
UKhttps://start.exactonline.co.uk/api/oauth2/authhttps://start.exactonline.co.uk/api/oauth2/token
Germanyhttps://start.exactonline.de/api/oauth2/authhttps://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:

ParameterDescriptionExample
$selectChoose specific fields$select=InvoiceID,InvoiceNumber,AmountDC
$filterFilter results$filter=AmountDC gt 1000
$orderbySort results$orderby=InvoiceDate desc
$topLimit results (max 60, bulk: 1000)$top=60
$skipPagination 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 TypeThresholdReset
Per Minute60 callsResets every minute
Per Day5,000 callsResets 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:

  1. Know which region the customer is in
  2. Route to the correct regional endpoint
  3. 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

CapabilityNative IntegrationApideck Unified API
AuthenticationBuild OAuth flow, handle refresh, manage per-regionHandled via Vault UI
Rate LimitingImplement dual-header logicNormalized
Data NormalizationMap each field manuallyPre-normalized to unified schema
Related EntitiesSeparate API calls (no $expand)Included automatically
PaginationImplement cursor/offset logicAutomatic
Error HandlingParse OData errorsStandardized error format
Multi-regionRoute to correct endpointAutomatic
Division SelectionBuild selection UIHandled in connection flow
Add QuickBooksBuild second integrationChange service_id parameter
MaintenanceMonitor Exact API changesWe 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

  1. Register at Exact Online App Center
  2. Create your OAuth application
  3. Implement the authentication flow above
  4. Build API client with rate limiting
  5. Map responses to your data model
  6. 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:

  1. Visit apps.exactonline.com and sign in
  2. Click "Register a test app" (can be promoted to production later)
  3. Configure your app:
    • App Name: Your application name
    • Redirect URI: https://unify.apideck.com/vault/callback
  4. 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

  1. Sign up at apideck.com
  2. Navigate to the Exact Online connector settings
  3. Select "Use your Exact Online client credentials"
  4. Enter your Client ID and Client Secret
  5. Click "Save settings"

Step 3: Connect Your Users

Use Apideck Vault to let your users authenticate:

  1. Open the Test Vault in your dashboard
  2. Select your API domain (nl, be, uk, etc.)
  3. Click "Authorize"
  4. Your users sign in at Exact Online
  5. 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.

Ready to get started?
Talk to an expert

Trusted by fast-moving product & engineering teams

JobNimbus
Blue Zinc
Drata
Octa
Nmbrs
Apideck Blog

Insights, guides, and updates from Apideck

Discover company news, API insights, and expert blog posts. Explore practical integration guides and tech articles to make the most of Apideck's platform.

Unified.to Alternatives: A Technical Overview for 2026
Industry insightsUnified API

Unified.to Alternatives: A Technical Overview for 2026

A technical comparison of Unified.to alternatives for 2026, examining its architecture alongside platforms like Apideck, Merge, Codat, Nango, and Plaid, with guidance on matching platform capabilities to your integration requirements.

Kateryna Poryvay

Kateryna Poryvay

17 min read
Understanding Tracking Dimensions in Accounting Integrations
Unified APIGuides & TutorialsAccounting

Understanding Tracking Dimensions in Accounting Integrations

Learn how tracking dimensions like departments, locations, classes, and custom categories work across QuickBooks, Xero, NetSuite, and Sage Intacct. Discover best practices for building accounting integrations that handle platform differences gracefully with dynamic dimension discovery, validation, and unified support.

GJ

GJ

7 min read
NetSuite Integration Guide
AccountingGuides & Tutorials

NetSuite Integration Guide

In this deep dive, we break down what NetSuite integration actually looks like in production, from sync patterns and API options to governance limits, concurrency constraints, and the real issues teams run into at scale. With architecture principles, clear decision frameworks, and hands on implementation tips, you’ll walk away with a practical blueprint for building NetSuite integrations that are reliable, secure, and built to handle growth.

Kateryna Poryvay

Kateryna Poryvay

15 min read