Exact Online heeft 450.000+ gebruikers in de Benelux. De meeste koppelingen zijn brak: ze crashen bij OAuth-token-refresh, verliezen facturen bij netwerkstoringen en schalen niet. Zo bouw je het correct.
Laravel + Exact Online: boekhouding API koppeling bouwen
Exact Online heeft meer dan 450.000 gebruikers in de Benelux. Bijna elke Nederlandse webshop of B2B-platform werkt ermee. Toch zijn de meeste koppelingen die wij tegenkomen slecht gebouwd: ze crashen bij OAuth-token-refresh, verliezen facturen bij netwerkstoringen en schalen niet zodra het ordervolume stijgt.
Dit artikel laat zien hoe je een robuuste Exact Online koppeling bouwt met Laravel. Geen quick-and-dirty script. Een architectuur die twee jaar later nog werkt.
Wat je leert in dit artikel
- Hoe de Exact Online OAuth2-flow werkt en hoe je token-refresh correct implementeert
- REST API endpoints voor facturen, debiteuren, artikelen en orders
- Foutafhandeling en retry-logica voor productie
- Queue-gebaseerde synchronisatie voor hoog ordervolume
- Veelgemaakte fouten en hoe je ze vermijdt
De Exact Online API in vijf minuten
Exact Online biedt een REST API gebaseerd op OData. Dat betekent dat je kunt filteren, sorteren en pagineren via URL-parameters. De API werkt per divisie — elke administratie in Exact heeft een eigen divisie-ID.
De basis-URL is https://start.exactonline.nl/api/v1/{division}/. Je hebt dat divisie-ID nodig bij elke API-call.
Authenticatie verloopt via OAuth2 met Authorization Code flow. Exact gebruikt geen machine-to-machine credentials. De eerste keer moet een gebruiker inloggen en toestemming geven. Daarna draait alles op access tokens en refresh tokens.
OAuth2 implementatie: token refresh is het kritieke punt
De Authorization Code flow starten is eenmalig werk. De uitdaging zit in het automatisch vernieuwen van tokens. Exact's access tokens verlopen na 10 minuten. Refresh tokens zijn 30 dagen geldig.
Wij slaan tokens op in de database, niet in de sessie. Zo kunnen queue jobs en achtergrondprocessen ook API-calls maken.
// app/Models/ExactToken.php
class ExactToken extends Model
{
protected $fillable = [
'division',
'access_token',
'refresh_token',
'expires_at',
];
protected $casts = [
'expires_at' => 'datetime',
];
public function isExpired(): bool
{
// Buffer van 60 seconden om race conditions te voorkomen
return $this->expires_at->subMinutes(1)->isPast();
}
}
De token refresh zit in een service class. Elke API-aanroep checkt eerst of het token nog geldig is.
// app/Services/ExactOnlineService.php
class ExactOnlineService
{
public function __construct(
private readonly Http $http,
private ExactToken $token,
) {}
private function getValidToken(): string
{
if ($this->token->isExpired()) {
$this->token = $this->refreshToken($this->token);
}
return $this->token->access_token;
}
private function refreshToken(ExactToken $token): ExactToken
{
$response = Http::asForm()->post(
'https://start.exactonline.nl/api/oauth2/token',
[
'grant_type' => 'refresh_token',
'refresh_token' => $token->refresh_token,
'client_id' => config('services.exact.client_id'),
'client_secret' => config('services.exact.client_secret'),
]
);
if ($response->failed()) {
// Token is ongeldig — admin moet opnieuw autoriseren
throw new ExactAuthorizationException(
'Exact Online token kon niet worden vernieuwd. Herverbinding vereist.'
);
}
return $token->update([
'access_token' => $response->json('access_token'),
'refresh_token' => $response->json('refresh_token'),
'expires_at' => now()->addSeconds($response->json('expires_in')),
]);
}
public function get(string $endpoint, array $params = []): array
{
$response = Http::withToken($this->getValidToken())
->get("https://start.exactonline.nl/api/v1/{$this->token->division}/{$endpoint}", $params);
if ($response->status() === 401) {
// Token is verlopen ondanks refresh — gooi specifieke exception
throw new ExactAuthorizationException('Exact Online autorisatie verlopen.');
}
$response->throw(); // Gooit exception bij andere HTTP-fouten
return $response->json('d.results', []);
}
}
Debiteuren synchroniseren
In Exact heet een klant een "Account" in de module CRM. Voor boekhouddoeleinden is het een "Debtor". De API-endpoint voor debiteuren is /crm/Accounts.
Bij een nieuwe klantregistratie in je webshop maak je direct een account aan in Exact. Of je synchroniseert batchgewijs — afhankelijk van je use case.
// app/Services/ExactDebiteurService.php
class ExactDebiteurService
{
public function __construct(private ExactOnlineService $exact) {}
public function aanmakenOfUpdaten(Customer $customer): string
{
// Controleer of de debiteur al bestaat op basis van e-mailadres
$bestaand = $this->exact->get('crm/Accounts', [
'$filter' => "Email eq '{$customer->email}'",
'$select' => 'ID,Code',
]);
if (! empty($bestaand)) {
$this->updaten($bestaand[0]['ID'], $customer);
return $bestaand[0]['ID'];
}
return $this->aanmaken($customer);
}
private function aanmaken(Customer $customer): string
{
$response = Http::withToken($this->exact->getAccessToken())
->post("https://start.exactonline.nl/api/v1/{$this->exact->getDivision()}/crm/Accounts", [
'Name' => $customer->company_name ?? $customer->full_name,
'Email' => $customer->email,
'Phone' => $customer->phone,
'AddressLine1' => $customer->address,
'Postcode' => $customer->postcode,
'City' => $customer->city,
'Country' => 'NL',
'IsSupplier' => false,
'IsPurchase' => false,
'IsSales' => true,
]);
$response->throw();
return $response->json('d.ID');
}
}
Facturen aanmaken via de API
Facturen in Exact zijn SalesEntries. Je maakt een SalesEntry aan met bijbehorende SalesEntryLines. Dit is een twee-staps proces: eerst de header, dan de regels.
// app/Services/ExactFactuurService.php
class ExactFactuurService
{
public function aanmaken(Order $order, string $exactDebiteurId): string
{
$entryLines = $order->items->map(fn($item) => [
'Item' => $this->getExactArtikelId($item->sku),
'Quantity' => $item->quantity,
'UnitPrice' => $item->price_excl_vat,
'VATCode' => $item->vat_rate === 21 ? '2' : '1',
'Description' => $item->name,
])->toArray();
// Voeg verzendkosten toe als aparte regel
if ($order->shipping_cost > 0) {
$entryLines[] = [
'GLAccount' => config('services.exact.shipping_gl_account'),
'Quantity' => 1,
'UnitPrice' => $order->shipping_cost_excl_vat,
'VATCode' => '2',
'Description' => 'Verzendkosten',
];
}
$payload = [
'Customer' => $exactDebiteurId,
'YourRef' => $order->order_number,
'Description' => "Order {$order->order_number}",
'Journal' => config('services.exact.sales_journal'),
'EntryDate' => $order->created_at->format('Y-m-d') . 'T00:00:00',
'PaymentCondition' => $order->payment_term ?? '30',
'SalesEntryLines' => $entryLines,
];
$response = $this->exact->post('salesentry/SalesEntries', $payload);
return $response['EntryNumber'];
}
}
Artikelen synchroniseren
Voordat je factuurregels kunt aanmaken, moet het artikel in Exact bestaan. Wij koppelen Magento/Shopify SKU's aan Exact-artikel-IDs via een mapping-tabel.
// database/migrations/create_exact_article_mappings_table.php
Schema::create('exact_article_mappings', function (Blueprint $table) {
$table->id();
$table->string('sku')->unique();
$table->string('exact_item_id', 36);
$table->timestamps();
});
Bij productaanmaak in je webshop controleer je of het artikel in Exact bestaat. Als dat niet zo is, maak je het aan.
public function syncArtikel(Product $product): string
{
$bestaand = $this->exact->get('logistics/Items', [
'$filter' => "Code eq '{$product->sku}'",
'$select' => 'ID',
]);
if (! empty($bestaand)) {
return $bestaand[0]['ID'];
}
$response = $this->exact->post('logistics/Items', [
'Code' => $product->sku,
'Description' => $product->name,
'SalesPrice' => $product->price_excl_vat,
'IsSalesItem' => true,
]);
return $response['ID'];
}
Foutafhandeling en retry-logica
Exact Online heeft een rate limit van 300 calls per minuut. Bij hoog ordervolume loop je daar tegenaan. Bouw retry-logica in met exponential backoff.
// app/Jobs/SyncOrderToExact.php
class SyncOrderToExact implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 5;
public int $backoff = 60; // Seconden tussen retries
public function __construct(private readonly int $orderId) {}
public function handle(ExactFactuurService $service): void
{
$order = Order::findOrFail($this->orderId);
try {
$factuurNummer = $service->aanmaken($order, $order->exact_debtor_id);
$order->update([
'exact_invoice_number' => $factuurNummer,
'synced_to_exact_at' => now(),
]);
} catch (ExactRateLimitException $e) {
// Wacht op basis van de Retry-After header van Exact
$this->release($e->retryAfter ?? 60);
} catch (ExactAuthorizationException $e) {
// Stuur alert naar admin — handmatige actie nodig
Notification::route('mail', config('services.exact.admin_email'))
->notify(new ExactAuthorizationAlert($e->getMessage()));
$this->fail($e);
}
}
}
Queue-gebaseerde architectuur voor hoog volume
Bij meer dan 50 orders per uur wil je synchronisatie niet synchroon uitvoeren. Gebruik Laravel Queues met een dedicated exact queue worker.
Order geplaatst in webshop
↓
SyncOrderToExact::dispatch($orderId)
↓
Queue worker pakt job op
↓
ExactOnlineService doet API-calls
↓
Order krijgt exact_invoice_number
↓
Bevestiging naar ERP/warehouse
Dit geeft je ook de mogelijkheid om bij storingen de jobs te hervatten zonder data te verliezen. Exact verwerkt de facturen zodra de verbinding hersteld is.
Veelgemaakte fouten
Token opslaan in sessie. Queue jobs hebben geen toegang tot de sessie. Sla tokens altijd op in de database. Geen divisie-ID opslaan. Bedrijven met meerdere administraties in Exact hebben meerdere divisie-IDs. Bouw dit van dag één in. Synchroon synchroniseren bij checkout. Een trage Exact API maakt je checkout traag. Synchroniseer altijd asynchroon via queues. Geen idempotentie. Als een job twee keer draait door een queue-probleem, moet je geen dubbele factuur aanmaken. Controleer altijd opYourRef (jouw ordernummer) voordat je aanmaakt.
BTW-codes hardcoden. BTW-codes zijn per Exact-administratie configureerbaar. Haal ze op via de API of maak ze configureerbaar via .env.
Best practices
| Practice | Waarom |
|---|---|
| Tokens in database opslaan | Queue jobs kunnen dan ook API-calls maken |
| Altijd asynchroon via jobs | Exact's API is soms traag — geen impact op de webshop |
| Idempotente aanmaak | Voorkomt dubbele facturen bij herhaalde job-uitvoering |
| Expliciete exception types | ExactRateLimitException vs ExactAuthorizationException vs ExactApiException |
| Divisie-ID configureerbaar | Multi-administratie support van dag één |
| Retry met exponential backoff | Rate limits en tijdelijke storingen afhandelen zonder dataverlies |
Conclusie
Een Exact Online koppeling is geen weekend-project. De OAuth2-flow, token-refresh, divisie-IDs, OData-filtering en rate limits vragen om doordacht werk. De meeste problemen die wij zien zijn geen API-kennis-problemen. Het zijn architectuurproblemen: tokens op de verkeerde plek, synchrone calls die de checkout blokkeren, geen retry-logica.
De aanpak in dit artikel geeft je een basis die schaalbaar is en productieproof. Van 10 orders per dag tot 1.000.
De Exact Online API documentatie beschrijft de volledige REST API. Voor de OAuth2-flow, zie ook de OAuth 2.0 specificatie. Het Laravel HTTP Client is de basis voor onze implementatie.
Gerelateerde artikelen:- ERP koppelingen met Laravel — architectuurgids
- Laravel Queues voor e-commerce — asynchroon verwerken
- Alumio vs custom integraties — iPaaS of maatwerk?
- Laravel + Mollie: betaalflows — de betaalkant van je integratie
Bouw je een koppeling met Exact Online? Bekijk onze Laravel-diensten of neem contact op — wij hebben deze architectuur meerdere keren gebouwd.

Geschreven door Ruthger Idema
15+ jaar ervaring in e-commerce development. Gespecialiseerd in Magento, Shopify en Laravel maatwerk.
Meer over ons team →