Integrations
AFAS Profit connectors: 17 gotchas that pass as 200 OK
Seventeen AFAS Profit connector gotchas from a 38-person Apeldoorn payroll bureau's onboarding agent, ranked by how quietly each one breaks a multi-werkmaatschappij tenant.

The Tuesday-morning miss in Apeldoorn
A payroll bureau in Apeldoorn runs payroll on the second Tuesday of every month for 38 client companies, all hosted inside one AFAS Profit tenant. In February their HR coordinator opened the verloningsperiode and three new hires from the Microsoft Forms intake had not made it onto any of the loonstaten. The onboarding agent had logged 200 OK against the UpdateConnector for all three. The records were in the system. They were not in the right month.
That bug took us four hours to reproduce and two days to map across every other connector the agent touched. By the time we were done we had a cheatsheet of seventeen failure modes that pass AFAS's own validation, ranked by how quietly they break a multi-werkmaatschappij tenant.
Why a connector can lie about success
AFAS Profit's GetConnector and UpdateConnector REST endpoints are SOAP-era code with a JSON jacket. The server validates the XML schema, the field types, and the access rights of your token. It does not validate that the payload makes business sense to the payroll module that consumes it downstream. A field with the wrong code lands in the database. Whether it then drives a verloningsperiode is a separate question the connector never asks.
On a single-werkmaatschappij tenant this is forgiving. There is only one employer, one payroll calendar, one set of valid period codes. On a tenant with 38 werkmaatschappijen there are 38 of each. Every gotcha below was discovered while watching the bureau's tenant return 200 OK against a payload that the payroll module then ignored.
The five that drop verloningsperiode
These ranked first because each one returns 200 OK on the UpdateConnector and silently leaves the new employee outside the active payroll period for their werkmaatschappij.
1. Wg defaults to werkgever one
Every KnEmployee payload that omits the Wg field is written to werkgever 1, no matter which werkmaatschappij owns the bedrijfscode you passed in. There is no warning. On a single-werkmaatschappij tenant Wg is a no-op. On the bureau's tenant the first three onboardings of the rollout all landed under "Administratie Holding" instead of the operating company. The agent log shows 200 OK on every one.
The fix is to never omit Wg and to derive it from a GetConnector lookup against KnOrganisation rather than hardcoding a map. Werkgevers get renumbered when a bureau onboards a new client.
<KnEmployee>
<Element>
<Fields Action="insert">
<EmId>110621</EmId>
<Wg>14</Wg>
<Vp>202606</Vp>
<PvCd>MAAND</PvCd>
</Fields>
</Element>
</KnEmployee>2. Vp accepts any six-digit code
The verloningsperiode field accepts any well-formed YYYYMM string. It does not check whether that period is open on the werkmaatschappij you targeted. We saw 202604 written into a werkmaatschappij whose payroll calendar was already closed for Q1, and the record sat in limbo until someone opened the period manually.
Read the open periods from the InsiteCalendar GetConnector before every UpdateConnector call. Cache for an hour, not a day.
3. PvCd mismatches the periodetabel per werkgever
PvCd is the periode-code. The bureau had MAAND, VIERWEKEN and WEEK active across different werkmaatschappijen. Passing MAAND to a werkgever that only runs VIERWEKEN gets written. The next payroll run silently drops the employee because the period table for that werkgever does not contain MAAND.
4. MatchPer 7 ignores updates
The MatchPer attribute on the KnPerson element controls how AFAS reconciles the incoming person with existing records. The default in most tutorials is 7, which means "insert new". On an onboarding rerun, MatchPer 7 inserts a duplicate person record and the EmId attaches to the new BcCo. The old employee record stays. Both calls return 200 OK.
Use MatchPer 0 with an explicit BcCo lookup, or MatchPer 6 if you trust the BSN field. Never 7 outside of a clean test tenant.
5. CalculateBalance defaults to false
The CalculateBalance attribute on KnEmployee triggers AFAS to recompute the employee's payroll balance after the write. With it off, an employee can land in the verloningsperiode for that werkmaatschappij but carry a zero balance that hides them from the loonstaat preview. The HR coordinator looks at the preview, sees nothing, and reports the onboarding as failed even though the record is there.
<Fields Action="insert" CalculateBalance="true">The mid-tier traps
These do not silently drop verloningsperiode but they break things downstream with no error.
6. The token must be base64 of an XML envelope, not the raw token
AFAS's docs show the token in plain text. The endpoint expects base64 of <token><version>1</version><data>YOUR_TOKEN_HEX</data></token>. Sending the raw hex string returns 401. Sending base64 of the hex string without the XML envelope returns 200 OK on read endpoints and a silently empty result set on filtered ones. We lost an afternoon to that.
7. Skip is one-indexed despite being called Skip
Skip=0 returns the first record. Skip=1 also returns the first record. Skip=2 returns the second. There is an off-by-one against every paginated reader you have ever written. The agent's incremental sync had been skipping the first record of every page for two weeks before we noticed.
8. Take maxes at 1000 with no warning above
Take=10000 returns 1000 records. No header, no pagination hint. If your code does not check whether the result count equals the take value and loop, you stop reading at 1000 silently.
9. Boolean fields want "1", not "true"
The XML schema lists xs:boolean but the payroll module reads xs:string. Passing "true" lands as the literal string and any downstream filter that compares to 1 fails. Same with dates: ISO YYYY-MM-DD is accepted by the connector and quietly mishandled in fields that the module expects to be Dutch-format DD-MM-YYYY. Use the AFAS sample XML for the exact field, not the schema.
10. Filter operators are integers, not strings
The GetConnector filter syntax uses integer codes for operators. 1 is equal, 2 is greater-than, 3 is greater-or-equal, 8 is contains, 14 is starts-with. The list is in the REST connector help under "Filteren". Passing the operator as a string like "eq" returns the unfiltered result set. 200 OK, full table, no error.
11. KnPerson AddressLine resolves through PostBus tables
If you write a Dutch postal address with house number 12A as a single string, AFAS splits it into HmNr=12 and HmAd=A. If you write it as HmNr=12A directly, it silently drops the A. The postal-code service then returns a different street, and the address looks correct in the connector echo but maps to a different building.
The six you will hit on day one
These are the noisier ones. They will burn you once, you fix the field, you move on.
- EmId collisions on parallel insert. AFAS generates EmId on the server. Two parallel UpdateConnector calls for two different new hires can return the same EmId if they hit within the same second. Serialise the insert path or post-check.
- The JobTitleId lookup must exist before insert. Insert a person with a function code that is not in the Functietabel for that werkmaatschappij and the field lands as null. No error. The employee has no job title.
- Trial tenants throttle at 50 calls per minute. Production throttles at 600. The agent's load test on the bureau's trial tenant looked fine. Production looked the same until we pushed onboarding batches above 50 per minute and hit a soft block that took 20 minutes to clear.
- DaPaGsrt is a five-character code, not three. "MAAND", not "MND". The schema says max 5 but the examples in older blog posts use 3. Three-character codes are accepted and ignored by the period engine.
- Empty string is not null. Sending
<HmNr></HmNr>writes an empty string that fails downstream "is filled" checks. Omit the field entirely if you do not have a value. - The audit log shows the request, not the resolved data. The connector log shows what you sent. It does not show what AFAS wrote after defaulting. If you want to verify the write you have to GetConnector the record back and diff it. The agent does this now on every insert above a confidence threshold.
If your UpdateConnector pipeline relies on 200 OK as proof of success on a multi-werkmaatschappij tenant, you have a silent-data-loss bug. Read back every insert against a GetConnector and compare Wg, Vp and PvCd before you trust it.
The verification pattern that closed the bug
The fix we shipped for the bureau was not a longer feature list. It was a one-paragraph verification step the agent runs after every onboarding insert.
def verify_employee_write(em_id: int, expected: dict) -> bool:
record = afas.get(
connector="Employees_full",
filterfieldids="EmId",
filtervalues=str(em_id),
operatortypes="1",
take=1,
)
if not record:
return False
actual = record[0]
for field in ("Wg", "Vp", "PvCd"):
if actual.get(field) != expected[field]:
log.warn("afas-drift", em_id=em_id, field=field,
expected=expected[field], actual=actual.get(field))
return False
return TrueThe agent now blocks on this verification. If the GetConnector echoes a Wg, Vp or PvCd that does not match what the UpdateConnector was asked to write, the new employee is queued for a human review instead of the welcome email going out. Two months in, the bureau has not had another verloningsperiode miss.
What this looks like at the agent level
When we built the HR-onboarding agent for the bureau, the model itself was the easy part. The hard part was teaching it that 200 OK is not a contract. We ended up wrapping every AFAS write in a read-back step with field-level expectations, and the agent's confidence score now folds in connector drift the same way it folds in form-field ambiguity. If you are running AI agents against any Dutch ERP that has been around since the AccountView era, assume the connectors lie politely and verify everything that matters.
Pull your last week of UpdateConnector logs and grep for 200 OK. Pick ten at random and GetConnector the record back. If any field defaulted, you have a silent-loss bug too.
Key takeaway
AFAS Profit's UpdateConnector returns 200 OK on payloads the payroll module silently ignores. On multi-werkmaatschappij tenants, read back every Wg, Vp and PvCd.
FAQ
Does AFAS Profit's UpdateConnector return an error when a field is silently dropped?
No. The connector validates schema and access, not business rules. Wrong werkgever codes, mismatched period codes and missing CalculateBalance flags all return 200 OK while the payroll module ignores the row.
How do I know which verloningsperiode is open per werkmaatschappij?
Query the InsiteCalendar GetConnector per Wg before each UpdateConnector write. Do not cache for more than an hour; bureaus open and close periods mid-day around payroll runs.
Why is the GetConnector Skip parameter one-indexed?
Historic SOAP behaviour preserved in the REST jacket. Skip=0 and Skip=1 both return record one. Treat Skip as a one-indexed offset or you will lose the first row of every page you read.
Can I detect silent UpdateConnector failures without reading every record back?
Not reliably. AFAS does not expose a post-write hash or echo of resolved fields. The only safe pattern on multi-werkmaatschappij tenants is a per-insert GetConnector read-back on the fields that drive payroll.