Integrations
AFAS Profit REST: elf eigenaardigheden voor je agent
Elf AFAS Profit-valkuilen die we tegenkwamen bij het bouwen van een payroll-reconciliatieagent voor een Apeldoornse HR-groep van 47 man. Gerangschikt op hoe stil ze falen.

Een dinsdag in Apeldoorn. De payroll-reconciliatieagent draait al negen dagen bij een HR-dienstverlener met 47 medewerkers. De ochtend dat de controller het uitzonderingsrapport opent, staan er drie namen bovenaan onder "loonheffingsnummer ontbreekt". Ze zoekt ze op in de AFAS Profit-desktopclient. De fiscale nummers staan er gewoon, op elk scherm. De agent is niet gecrasht. De API gaf netjes 200 OK terug. Het nummer zat alleen niet in de JSON.
Dit is het type fout dat pijn doet: een schone response, een zelfverzekerd groen vinkje, een trage lek van foute data in een onderliggend systeem dat niemand opmerkt totdat iemand het handmatig naloopt. De elf eigenaardigheden hieronder zijn de exemplaren die ons tijdens die uitrol beten, gerangschikt op hoe stil ze falen. De bovenste helft zijn 200 OK's met stilletjes foute payloads. De onderste helft zijn luidruchtiger, makkelijker te zien, makkelijker te vergeven.
De setup
Even voor de context: de agent trekt medewerker-, contract- en loondata uit AFAS Profit via drie GetConnectors, koppelt die aan het uitgaande SEPA-bestand van de bank en markeert elke salarisregel waar de medewerkergegevens onvolledig of verouderd lijken. AFAS Profit stelt data beschikbaar via REST endpoints rond connectorconfiguraties die binnen de applicatie worden gedefinieerd. Tokens zijn base64-gecodeerde XML, gekoppeld aan een AppConnector-profiel per omgeving. De architectuur werkt. Het datamodel werkt niet altijd mee.
1. Loonheffingsnummer verdwijnt bij uitgediende medewerkers
Deze is het startpunt van deze field guide. De HrEmployee GetConnector geeft Loonheffingennummer (het Nederlandse loonheffingennummer dat de Belastingdienst gebruikt) gevuld terug voor actieve medewerkers. Zodra de uitdienstdatum in het verleden ligt, komt het veld als null binnen. Het record bestaat nog. De contractdata komt nog door. Alleen het loonheffingennummer is weg.
Waarom het pijn doet: een reconciliatieagent die alleen checkt of het loonheffingsnummer aanwezig is, markeert elke uitgediende medewerker op een eindafrekening als kapot. Het nummer staat nog gewoon in AFAS, alleen niet op de connector die je toevallig gebruikt. De oplossing is om het uit HrEmployeeFiscaal te lezen, die het veld gevuld houdt ongeacht de dienstverbandstatus, en om te joinen op EmployeeId in plaats van te leunen op één connector voor het hele record. Bij eindafrekeningen wordt dit code-pad het drukste dat je schrijft.
def fetch_loonheffingen(employee_id, token, env):
url = f"https://{env}.rest.afas.online/profitrestservices/connectors/HrEmployeeFiscaal"
params = {
"filterfieldids": "EmployeeId",
"filterids": "1",
"filtervalues": employee_id,
"take": 1,
}
r = requests.get(
url,
headers={"Authorization": f"AfasToken {token}"},
params=params,
timeout=10,
)
r.raise_for_status()
rows = r.json().get("rows", [])
return rows[0].get("Loonheffingennummer") if rows else None
2. Fouten in filtersyntax geven 200 OK met een lege rows-array
Stuur een triple van filterfieldids / filterids / filtervalues waarin een veld verkeerd gespeld is, of waarin de operatorcode niet bestaat, en de REST API geeft geen 400. Hij geeft 200 met rows=[]. Vanuit het perspectief van de agent "liep" de query gewoon en vond niets.
In een payroll-reconciliatieflow ziet dat er identiek uit als "geen uitzonderingen om te melden". We vingen het in week twee omdat een typo op de branch van een ontwikkelaar stilletjes de helft van de dataset onderdrukte. Het defensieve patroon: vergelijk de rij-aantallen met een bekende ondergrens (we verwachten altijd minimaal N actieve medewerkers) en alarmeer wanneer het aantal verdacht rond is, zoals nul. We houden ook een kleine contract test die bij elke deploy een opzettelijk fout filter afvuurt en bevestigt dat de response precies zo eruitziet als het bekende lege-op-fout-filter-gedrag, zodat we het merken op de dag dat AFAS dit ooit besluit te repareren.
3. De wijzigdatum-valkuil op parent-records
De meeste incrementele syncs gebruiken de last-modified-timestamp van AFAS op het parent-record om te bepalen wat opnieuw opgehaald moet worden. De valkuil: wijzigingen in child-records (contractregels, looncomponenten, adreshistorie) borrelen niet altijd door naar de wijzigdatum van de parent. Een salariswijziging op een contract-subrecord kan de wijzigdatum van de medewerker drie maanden oud laten staan.
Bouw je incrementele sync alleen op parent-timestamps, dan mis je precies de updates die je het hardst nodig hebt. Synchroniseer de child-connectors direct met hun eigen timestamps, of val periodiek terug op een volledige refresh voor high-value tabellen. Wij doen allebei en kruisvergelijken wekelijks de parent-count met de child-count om drift te vangen voordat de controller dat doet.
4. Numerieke velden komen terug als strings met Europese komma
Loonbedragen, percentages, urentotalen: sommige komen terug als JSON-getallen, andere als strings in Nederlands formaat ("1.234,56"). De keuze lijkt af te hangen van de velddefinitie binnen AFAS, niet van de connector. We hebben geen documentatie gevonden die ons vooraf vertelt wat wat is.
De eerlijke oplossing is een defensieve parser aan de rand van de agent die elk numeriek ogend veld door een Nederlands-locale-aware functie haalt voordat het de business-logic raakt. Vertrouw het JSON-type nooit. Geef de ruwe string nooit door aan een onderliggend systeem dat een float verwacht.
def parse_afas_amount(value):
if value is None:
return None
if isinstance(value, (int, float)):
return float(value)
s = str(value).strip()
if not s:
return None
# Common forms: "1.234,56", "1234,56", "1234.56"
if "," in s and "." in s:
s = s.replace(".", "").replace(",", ".")
elif "," in s:
s = s.replace(",", ".")
try:
return float(s)
except ValueError:
return None
5. Soft-deleted records blijven verschijnen als lege hulzen
Geblokkeerde of uitgediende records vallen niet altijd uit de connector. Verschillende HR-connectors geven shell-rijen terug waar de primary key nog intact is, maar de meeste velden null of default zijn. Een naïeve merge die die rijen als updates behandelt, overschrijft echte data met leegtes.
De defensieve aanpak: check op een statusveld (Blocked, IsActive, statuscode, afhankelijk van de connector) en behandel elke rij met een uitgeklede payload als een tombstone, niet als een record. Doe je dat niet, dan degradeert je downstream-kopie langzaam en is het enige signaal een gestage toename van "null waar het niet zou moeten"-tickets vanuit finance.
6. Drift in connectorconfiguratie
De GetConnector-configuratie leeft binnen AFAS Profit, niet in je codebase. Een admin met de juiste rechten kan een veld toevoegen of verwijderen, een filter wijzigen of kolommen herschikken. Je token werkt nog. Je endpoint-URL is ongewijzigd. De vorm van je payload is anders.
Dit is het integratie-equivalent van een onaangekondigde schemawijziging. De maatregelen zijn niet sexy: een dagelijkse metadata-diff job die de actuele veldenlijst van de connector vergelijkt met een ingecheckte snapshot, en een harde regel dat elke productie-connector eigendom is van één met name genoemd persoon aan klantzijde. Dat genoemde eigenaarschap maken we onderdeel van de AppConnector-documentatie die we bij go-live overdragen.
7. Datumformaat hangt af van waar je de datum plaatst
Filtervalues in queryparameters hebben "yyyy-MM-ddTHH:mm:ss" zonder timezone-suffix nodig. Filtervalues in een UpdateConnector-body hebben soms Nederlandse "dd-MM-yyyy" nodig. Response bodies geven meestal ISO 8601 met een Z terug. Door elkaar gebruiken levert je ofwel een 200 zonder rijen op (zie valkuil 2) of een 400 met een melding die het datumveld niet eens noemt.
We standaardiseren op ISO-input overal waar we de controle hebben en houden een kleine adapter voor de velden die Nederlands formaat eisen. Eén keer schrijven, op één plek, en nooit meer aanraken.
8. Paginering zonder totaalaantal
AFAS REST kent skip- en take-parameters. Er komt geen X-Total-Count of equivalent terug. De enige manier om te weten dat je het einde hebt bereikt, is minder rijen ontvangen dan je hebt opgevraagd. Vraag je om 100 en krijg je er precies 100, dan moet je nog eens vragen.
Dat gaat prima totdat je reconciliatievenster toevallig op een veelvoud van je page size uitkomt, waarop je loop te vroeg eindigt. Draai altijd één rondje extra, of pagineer met een take size waarvan je kunt aantonen dat de resultset hem niet exact raakt. Een priemgetal als take (wij gebruiken 97 voor HR-connectors) maakt het exacte-veelvoud-geval effectief onmogelijk.
9. Throttling onder concurrent load levert verouderde cached payloads op
Hit dezelfde connector vanuit drie workers tegelijk en één van de responses kan direct terugkomen met wat lijkt op verse data, maar in werkelijkheid een cached payload van minuten geleden is. Er is geen Cache-Control header die je dat vertelt. De wijzigdatum op de rijen is het enige aanknopingspunt, en alleen als je toevallig weet hoe "vers" eruit zou moeten zien.
Wij serialiseren reads per connector via een token-aware worker pool. Eén in-flight call per connector per token. Het verwijdert het symptoom volledig en de impact op throughput is onzichtbaar bij onze datavolumes (zo'n 30k rijen per nachtelijke sync).
10. Werkmaatschappij-lek wanneer environmentId impliciet is
AFAS REST-paden bevatten de environment-ID: {envid}.rest.afas.online. Onderhoud je een token dat rechten heeft over meerdere omgevingen (sommige service-provider-setups doen dat), en wordt de env-ID ergens upstream een config-waarde, dan kan het wijzigen van één string stilletjes de data van een andere onderneming aanwijzen met dezelfde token. Geen auth-fout. Geen waarschuwing. Gewoon de salarisadministratie van een ander bedrijf in je rapport.
Behandel de environment-ID als een harde, in code vastgezette constante per deployment. Laat het nooit een environment variable worden die een vermoeide engineer om 17:00 kan omzetten.
11. AppConnector-tokenrotatie heeft geen verloopsignaal
AFAS-tokens verlopen niet op de klok. Ze verlopen als een admin ze intrekt, als het AppConnector-profiel wordt verwijderd, of als de gebruiker waaronder de token is uitgegeven zijn rechten verliest. Tot dat moment blijven ze werken. Geen 401 op een naderend verloop, geen rotation-handshake, geen refresh token.
Operationeel betekent dat: plan je eigen rotaties, monitor een bekende canary-call elk uur, en koppel een productie-token nooit aan het account van een echte medewerker (altijd aan een service-user die niet door HR gedeactiveerd kan worden). De dag waarop de assistent van de controller het bedrijf verlaat en HR haar account opheft, is niet de dag waarop je wilt ontdekken welke token van haar was.
Het patroon onder alle elf
Het patroon onder alle elf is hetzelfde. De REST-laag van AFAS is ontworpen voor gezonde data die tussen AFAS-vormige systemen stroomt. Op het moment dat een agent het leest als bron van waarheid en daarop downstream gevolgen schrijft, wordt elke stille fout een incident van factuur-niveau. Een schone 200 OK is niet hetzelfde als een correcte payload. Bouw de agent alsof hij de response niet vertrouwt, ook als die er goed uitziet. Juist als die er goed uitziet.
De defensieve laag van een integratie-agent zit niet in het model dat hem aandrijft. Hij zit in de saaie code tussen de API en de actie: de validatie, de ondergrenzen, de canaries, de contract tests per connector, de in code vastgezette constanten. Niets daarvan is glamoureus en alles daarvan is wat een reconciliatiejob eerlijk houdt.
Een 200 OK van AFAS Profit bevestigt alleen dat het request syntactisch acceptabel was. Hij zegt niets over of de payload compleet, vers of uit de verwachte omgeving komt. Valideer downstream, altijd.
Wat je morgenochtend kunt doen
Bouw je iets tegen AFAS Profit en draait daar salaris, HR of finance doorheen, doe dan één diagnose voor de lunch: pak een recent uitgediende medewerker, haal hem op via je gebruikelijke connector en kijk of het loonheffingsnummer er staat. Staat het er niet, dan heb je minstens één van de elf valkuilen live in productie. Die ene check kost vijf minuten en vertelt je of je reconciliatie stilletjes lekt.
Toen wij de payroll-reconciliatieagent voor de Apeldoornse HR-groep bouwden, was die loonheffingsnummer-gap precies wat ons dwong om de connector-laag opnieuw op te bouwen rond defensieve reads in plaats van te vertrouwen op het happy path van AFAS. Diezelfde defensieve patronen leveren we nu mee met elke nieuwe integratie die onze AI-agents raken, en dat is de enige reden waarom we door de financiële maandafsluiting heen slapen.
Kern
Een schone 200 OK van AFAS Profit betekent niet dat de payload klopt. Bouw de integratie-agent alsof hij de response niet kan vertrouwen, juist als die er goed uitziet.
FAQ
Waarom verdwijnt het loonheffingsnummer bij uitgediende medewerkers?
De HrEmployee GetConnector maakt het veld leeg zodra de uitdienstdatum in het verleden ligt. Lees het in plaats daarvan uit HrEmployeeFiscaal, die de waarde gevuld houdt ongeacht de dienstverbandstatus.
Hoe vang je stille 200 OK-fouten op in AFAS REST?
Vergelijk bij elke read het aantal rijen met een bekende ondergrens en houd een contract test die opzettelijk een fout filter afvuurt, zodat je bevestigt dat het lege-op-fout-filter-gedrag nog steeds is wat je verwacht.
Verlopen AppConnector-tokens van AFAS automatisch?
Nee. Ze blijven werken totdat een admin ze intrekt, het AppConnector-profiel wordt verwijderd, of de onderliggende gebruiker rechten verliest. Plan je eigen rotaties en gebruik een serviceaccount, nooit dat van een echte medewerker.