← Blog

Integrations

AFAS Profit REST: eleven quirks a payroll agent must catch

Eleven AFAS Profit quirks we hit building a payroll-reconciliation agent for a 47-person Apeldoorn HR group. Ranked by which ones lie quietly in a 200 OK.

Jacob Molkenboer· Founder · A Brand New Company· 11 Jun 2026· 10 min
Brass postal scale with cream envelope sealed in green wax, ribbon and numbered ledger slips on ivory desk.

A Tuesday in Apeldoorn. The payroll-reconciliation agent has been live for nine days at a 47-person HR-services group. On the morning the controller opens the exception report, three names sit at the top under "missing loonheffingsnummer." She looks them up in AFAS Profit on the desktop client. The tax IDs are right there, on every screen. The agent did not crash. The API returned 200 OK. The number just was not in the JSON.

This is the failure mode that hurts: a clean response, a confident green tick, a slow leak of bad data into a downstream system that nobody notices until someone reconciles by hand. The eleven quirks below are the ones that bit us during that rollout, ordered by how silently they fail. The top half are 200 OKs with quietly wrong payloads. The bottom half are noisier, easier to spot, easier to forgive.

The setup

For context: the agent pulls employee, contract, and wage data from AFAS Profit through three GetConnectors, joins it against the bank's outbound SEPA file, and flags any salary line where the employee record looks incomplete or stale. AFAS Profit exposes data through REST endpoints wrapped around connector configurations defined inside the application. Tokens are base64-encoded XML, attached to an AppConnector profile per environment. The architecture works. The data model does not always cooperate.

1. Loonheffingsnummer goes missing on terminated employees

This is the one that started the field guide. The HrEmployee GetConnector returns Loonheffingennummer (the Dutch wage-tax registration number used by the Belastingdienst) as a populated field for active employees. The moment an employee's end-of-service date is in the past, the field returns null. The record still exists. The contract data still resolves. But the wage-tax ID is gone.

Why it hurts: a reconciliation agent that only checks "is loonheffingsnummer present?" will mark every terminated employee on a final-pay run as broken. The number itself is still in AFAS, just not on the connector you happen to be using. The fix is to read it from HrEmployeeFiscaal, which keeps the field populated regardless of service status, and to join on EmployeeId rather than rely on a single connector for the whole record. For final-pay runs, expect this code path to be the busiest one you write.

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. Filter syntax errors return 200 OK with an empty rows array

Send a filterfieldids / filterids / filtervalues triple where one field is misspelled, or where the operator code does not exist, and the REST API does not return 400. It returns 200 with rows=[]. From the agent's point of view, the query "ran" and found nothing.

In a payroll-reconciliation flow, this looks identical to "no exceptions to report." We caught it during week two because a typo on a developer's branch quietly suppressed half the data set. The defensive pattern is to compare the row count against a known floor (we always expect at least N active employees) and to alert when the count is suspiciously round, like zero. We also keep a small contract test that fires a deliberately wrong filter every deploy and asserts the response is exactly what AFAS's empty-on-bad-filter behavior looks like, so we notice the day they ever decide to fix it.

3. The wijzigdatum trap on parent records

Most incremental syncs use AFAS's last-modified timestamp on the parent record to decide what to refetch. The trap: changes on child records (contract lines, wage components, address history) do not always bubble up to the parent's modification date. A salary change on a contract subrecord can leave the employee's wijzigdatum sitting at three months ago.

If you build incremental sync on parent timestamps alone, you will miss the exact updates you most care about. Either sync the child connectors directly with their own timestamps, or fall back to a full refresh on a schedule for high-value tables. We do both, and we cross-check the parent count against the child count weekly to catch drift before the controller does.

4. Numeric fields returning as European-comma strings

Wage amounts, percentage fields, hour totals: some come back as JSON numbers, some come back as strings in Dutch format ("1.234,56"). The decision appears to depend on the field definition inside AFAS, not the connector. We have not found documentation that tells us which is which up front.

The honest fix is a defensive parser at the edge of the agent that coerces any numeric-looking field through a Dutch-locale aware function before it touches business logic. Never trust the JSON type. Never feed the raw string into a downstream system that expects a float.

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 keep showing up as shells

Blocked or terminated records do not always drop out of the connector. Several of the HR connectors return shell rows where the primary key is intact but most fields are null or default values. A naive merge that treats those rows as updates will overwrite real data with empties.

The defensive read is to check for a status field (Blocked, IsActive, status code, depending on the connector) and to treat any row with a stripped-down payload as a tombstone, not a record. If you do not, your downstream copy slowly degrades and the only signal is a slow rise in "null where there should not be one" tickets from finance.

6. Connector configuration drift

The GetConnector configuration lives inside AFAS Profit, not in your codebase. An admin user with the right rights can add or remove a field, change a filter, or reorder columns. Your token still works. Your endpoint URL is unchanged. Your payload shape is different.

This is the integration equivalent of an unannounced schema change. The mitigations are unglamorous: a daily metadata-diff job that compares the connector's current field list against a checked-in snapshot, and a hard rule that any production connector is owned by one named person on the client side. We make that named ownership part of the AppConnector documentation we hand over at go-live.

7. Date format depends on where you put the date

Filter values in query parameters need "yyyy-MM-ddTHH:mm:ss" without a timezone suffix. Filter values inside an UpdateConnector body sometimes need Dutch "dd-MM-yyyy". Response bodies usually return ISO 8601 with a Z. Mix them up and you either get a 200 with no rows (see quirk 2) or a 400 with a message that does not mention the date field at all.

We standardize on ISO inputs everywhere we control and keep a small adapter for the fields that demand Dutch format. Worth writing once, in one place, and never touching again.

8. Pagination without a total count

AFAS REST takes skip and take parameters. It does not return X-Total-Count or an equivalent. The only way to know you have reached the end is to receive fewer rows than you asked for. If you asked for 100 and got exactly 100, you have to ask again.

This is fine until your reconciliation window happens to land on a multiple of your page size, at which point your loop terminates early. Always go round one extra time, or page with a take size you can prove the result set will not hit exactly. A prime number for take (we use 97 for HR connectors) makes the exact-multiple case effectively impossible.

9. Throttling under concurrent load returns stale cached payloads

Hit the same connector from three workers at once and one of the responses can come back instantly with what looks like fresh data but is actually a cached payload from minutes ago. There is no Cache-Control header that tips you off. The modification timestamp on the rows is the only clue, and only if you happen to know what "fresh" should look like.

We serialize per-connector reads through a token-aware worker pool. One in-flight call per connector per token. It removes the symptom completely and the throughput hit is invisible at our data volumes (around 30k rows per nightly sync).

10. Subsidiary leakage when environmentId is implicit

AFAS REST paths embed the environment ID: {envid}.rest.afas.online. If you maintain a token that has rights across multiple environments (some service-provider setups do), and the env ID becomes a config value somewhere upstream, swapping a single string can silently target a different company's data with the same token. No auth failure. No warning. Just a different company's payroll in your report.

Treat the environment ID as a hard, code-pinned constant per deployment. Never let it become an environment variable that a tired engineer can flip at 5pm.

11. AppConnector token rotation has no expiry signal

AFAS tokens do not expire on a clock. They expire when an admin revokes them, when the AppConnector profile is deleted, or when the user under whom the token was issued loses rights. Until then, they keep working. There is no 401 on a near-expiry warning, no rotation handshake, no refresh token.

Operationally this means: schedule your own rotations, monitor a known-good canary call hourly, and never tie a production token to a real employee's account (always to a service user that cannot be deactivated by HR). The day the controller's assistant leaves the company and HR removes her account is not the day you want to find out which token was hers.

The pattern under all eleven

The pattern under all eleven of these is the same. The AFAS REST surface was designed for healthy data flowing between AFAS-shaped systems. The moment an agent reads it as a source of truth and writes downstream consequences against it, every silent failure becomes a billing-grade incident. A clean 200 OK is not the same as a correct payload. Build the agent like it does not trust the response, even when the response looks fine. Especially when the response looks fine.

The defensive surface for an integration agent is not in the model that drives it. It is in the boring code between the API and the action: the validation, the floors, the canaries, the per-connector contract tests, the env-pinned constants. None of that is glamorous and all of it is what keeps a reconciliation job honest.

Warning

A 200 OK from AFAS Profit confirms only that the request was syntactically acceptable. It tells you nothing about whether the payload is complete, fresh, or pulled from the environment you expected. Validate downstream, always.

What to do tomorrow morning

If you are building anything against AFAS Profit and you run payroll, HR, or finance through it, run one diagnostic before lunch: pick a recently-terminated employee, pull them through your usual connector, and check whether the loonheffingsnummer is present. If it is not, you have at least one of the eleven quirks live in production. That single check costs five minutes and tells you whether your reconciliation is silently lossy.

When we built the payroll-reconciliation agent for the Apeldoorn HR group, the loonheffingsnummer gap is what forced us to rebuild the connector layer around defensive reads rather than trust the AFAS happy path. The same defensive patterns now ship with every new integration our AI agents work touches, which is the only reason we sleep through finance close.

Key takeaway

A clean 200 OK from AFAS Profit does not mean the payload is correct. Build the integration agent like it cannot trust the response, especially when it looks fine.

FAQ

Why does the loonheffingsnummer disappear on terminated employees?

The HrEmployee GetConnector blanks the field once end-of-service is in the past. Read it from HrEmployeeFiscaal instead, which keeps the value populated regardless of service status.

How do you catch silent 200 OK failures in AFAS REST?

Compare row counts against a known floor on every read, and keep a contract test that fires a deliberately wrong filter to confirm the empty-on-bad-filter behavior is still what you expect.

Do AFAS AppConnector tokens expire automatically?

No. They keep working until an admin revokes them, the AppConnector profile is deleted, or the underlying user loses rights. Schedule your own rotations and use a service account, never a real employee's.

ai agentsautomationintegrationsprocess automationcase studyarchitecture

Building something?

Start a project