---
title: "The Complete Guide to Exact Online API Integration in 2026"
description: "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."
author: "GJ"
published: "2026-01-28T00:00+01:00"
updated: "2026-05-13T12:39:47.895Z"
url: "https://www.apideck.com/blog/guide-to-exact-online-api-integration"
category: "Accounting"
tags: ["Accounting", "Guides & Tutorials"]
---

# 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 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:

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](https://apps.exactonline.com):

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:

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

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

```python
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

```python
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:**

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

```python
# 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:

```python
# 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

```python
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

```python
# 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

```python
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

1. Register at [Exact Online App Center](https://apps.exactonline.com)
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](https://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](https://developers.apideck.com/connectors/exact-online/docs/application_owner+oauth_credentials).

**Step 2: Configure Apideck**

1. Sign up at [apideck.com](https://www.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**

```bash
# 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:

```python
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

- [Exact Online REST API Reference](https://support.exactonline.com/community/s/knowledge-base#All-All-DNO-Content-restrefdocs)
- [Exact Online App Center](https://apps.exactonline.com)
- [Exact Developer Portal](https://www.exact.com/us/developers)
- [Exact Online Integration](https://www.apideck.com/integrations/exact-online)
- [Exact Online OAuth Setup Guide](https://developers.apideck.com/connectors/exact-online/docs/application_owner+oauth_credentials)
- [Python Exact Online SDK](https://www.apideck.com/integrations/exact-online/sdk/python)
- [PHP Exact Online SDK](https://www.apideck.com/integrations/exact-online/php/python)

If your product needs to support Exact Online alongside other accounting platforms, [Apideck's Accounting API](https://www.apideck.com/accounting-api) provides a single integration that normalizes data across Exact Online, QuickBooks, Xero, Sage, and more — reducing the engineering overhead of maintaining separate connectors.