← work

2026 — now · work

Employee and client app for a home-care organisation, keyed to AFAS

AFAS integration, authentication, the app rebuild and its native shell, ThuiszorgInHolland

One app for care workers, clients and applicants, with no database of its own: the website is the backend, and who may log in is decided by an hourly sync from the AFAS payroll system.

React · Vite · Capacitor · Next.js · Supabase · AFAS Profit REST · Resend

ThuiszorgInHolland provides household help and individual support under the Wmo, the Dutch social support act, in a number of municipalities. Around eight hundred care workers visit people at home; the office runs on AFAS Profit, the HR and payroll system most Dutch employers of that size use. In early 2026 the company started rebuilding its website in Next.js with a small in-house team, and the website grew a back office: nine role portals for planning, recruitment, care administration and the municipalities. I joined in March to do the parts that touch AFAS and, later, the app: who may log in, how they prove it, and how a shift or a job application gets from a phone to the payroll system.

The app has three doors. A care worker sees open shifts within a radius of home, signs up for one, sets availability, reads the newsletter and asks a chatbot questions about contracts and hours. A client sees the care log, pauses or cancels care, reports a missed visit, and reads what their municipality covers. Anyone can browse the vacancies and apply. It runs on the web, and the same build is wrapped in Capacitor for the App Store and Google Play.

The app has no backend

The first app was a second Next.js project with its own API routes, most of which forwarded requests to the website and copied cookies back and forth. Two servers for one product, with two copies of the secrets. In June I rebuilt it as a React SPA on Vite: static files on Vercel, a service worker, and every request going straight to the website at /api/v1. The app ships no Supabase key at all, not even the public one; vacancy listings come through a website endpoint too, so row-level security in the database is a second line rather than the only one.

That made the app a different origin from its backend, and a native app is a third one: capacitor://localhost on iOS, https://localhost on Android. SameSite cookies do not travel across sites, so sessions are a Bearer token the app keeps itself and attaches to each call. The website reads the header first and only then the cookie, because the native network layer quietly kept cookies from earlier logins, and when the server read those first an old cookie could outvote a fresh login and log someone out the moment they had logged in. On a device the token is mirrored into the Keychain or the Keystore, so on iOS a reinstall comes back signed in. It did not on Android for a while: the secure-storage plugin had never been synced into the Android project, the write failed silently, and the token lived only in the WebView.

The pieces and how they talk. The app owns nothing but its token; the website owns the database, the AFAS credentials and the e-mail. AFAS is read with one token and written with another.

AFAS decides who exists

There is no user table anyone types into. Every hour a cron on the website reads two AFAS GetConnectors, one for employees and one for persons flagged as clients, and rewrites two Supabase tables from them. An employee is loginable if the export says they are in service and not blocked; anyone missing from the export is deleted from the table, which is how offboarding works without the office doing anything in the app. Deleting on the strength of an export is dangerous, so a run that would remove more than a fifth of the table refuses and reports instead. A partial export from AFAS is a real thing, and without the valve it would log every care worker out.

The login address turned out to be the hard field. AFAS has a work e-mail and a private one; the work address is the account the office does not maintain, so many workers had none. The sync takes the private address first and falls back to the work one, a decision the office made in August after the first go-live attempt left a chunk of staff unable to log in. Private addresses are also shared, between partners, between a parent and a child who both work there. Two active rows with one address would make a login ambiguous, so the sync skips both and lists them for the office to fix in AFAS, and the login route refuses an address that matches more than one active employee rather than guessing.

Clients are synced with as little as possible: name, town, postcode, blocked flag, AFAS number. No phone number, no date of birth, and the e-mail is only written when AFAS has one, because a client can also register an address through the app and a sync must not blank it. AFAS itself issued two tokens rather than extending the recruitment connector; the client picks one by HTTP method, GET to read, anything else to write, and swapping them breaks the applicant sync, which is how that was discovered.

The lock is the database row, not the clock. A token proves who someone is; whether they may still get in is asked of the table AFAS rewrote an hour ago.

A six-digit code and a ten-year token

Nobody has a password. An employee types their e-mail, gets a six-digit code that is valid for ten minutes, and types it in; the code is in the subject line of the mail so it can be read off a locked phone, and the sixth digit submits. A client types their client number and e-mail and gets the same. The first time, a client also gives their postcode, which is checked against the address AFAS knows, and that binds the e-mail to the number. The two steps are separate routes on purpose: an earlier version let client number plus postcode rebind the login address of an account that already had one, which is an account takeover with information printed on every letter from the organisation.

Neither route says whether an account exists. An unknown address, a wrong number, a mismatched pair all get the same success response and no mail; the app advances to the code step regardless. Rate limits and failed-attempt counters live in the database, not in memory, because a Vercel function forgets its memory between instances and a six-digit code is guessable by a patient script otherwise.

The token lifetime went around in a circle. The first version issued tokens for ten years. In June I cut that to thirty days, which is what a security checklist says, and then had to answer the real question, which is what happens to someone who leaves on a Tuesday. The answer became a live check: every request looks the employee up in the table the sync maintains, and if there is no active row the token is refused on the spot. Once that existed the expiry only protected against a stolen token, at the cost of logging the whole company out every month, so in August the lifetime went back to ten years, and the profile endpoint reissues any token with less than a year left, so the thirty-day tokens in the field rolled over unnoticed.

Clients got the same check late, and only because it failed. Apple requires that an account made in an app can be deleted in the app, so I built the unlink: the e-mail is cleared, push registrations and pending codes are deleted, the care record stays because the law says so. Testing it showed the old session still opened the care log afterwards, because client sessions had never been checked live. Now they are, and both tables carry a timestamp; a token issued before it is dead, which also survives the next sync writing the AFAS address back.

The app’s opening screen on a phone: the ThuiszorgInHolland logo and three cards, log in as employee, log in as client, and vacancies, each with a one-line description in Dutch.
Three doors on one front page.
The employee login on a phone: a green header reading Medewerker App, one e-mail field, a button to send a login code, and a link for help with logging in.
An e-mail address, then a code. No password anywhere.
The client login on a phone: a blue header, a toggle between already logged in before and first time, link my account, and fields for client number and e-mail address.
Clients log in with their client number; the first time also asks the postcode.
The vacancy list in the app: filters for function and city, a count of 41 open positions, and cards for household help in Amsterdam, Haarlem and Eindhoven with hours and an hourly wage range.
The vacancies, read from the same table the recruiters edit.

4 images — swipe or scroll sideways

Shifts within fifteen kilometres

An open household shift is a postcode, days, a part of the day and a number of hours. The employee sets a home postcode and a radius of up to fifteen kilometres, plus postcodes they will not go to and a few preferences about pets and households, and the shift list is filtered on the server by great-circle distance before it leaves. Signing up is one tap; when the third person signs up the shift flips to in progress and planning gets a mail. A new shift in someone’s area arrives as a push notification with a direct inschrijven button that signs the worker up without opening the app: the service worker reads the token from IndexedDB, because a service worker cannot see localStorage, and posts the registration itself. Actions made without signal are queued and replayed.

The client side is mostly forms that used to be phone calls: pause care for a holiday, cancel Thursday’s visit, report that nobody came. Those post to the website’s older endpoints, which accept a valid client token in place of the bot check the public forms use. The questions page is a Gemini chatbot with the organisation’s FAQ in its prompt.

From vacancy to AFAS applicant

Recruitment was where the AFAS work started, in March. A recruiter creates a vacancy in the portal; the route pushes it to AFAS through the HrVacancy connector and stores the sequence number AFAS answers with. A candidate applies on the website or in the app; the CV goes to blob storage, the row goes to Supabase, two mails go out, and then the application is posted to HrCreateApplicant with the CV base64-encoded inside it and the vacancy’s sequence number attached, so the recruiter finds the candidate under the right vacancy in AFAS without retyping anything. The AFAS step is last and never blocks: a failure is written to the row and shown in the portal, and the candidate has already had their confirmation. AFAS wants the house number as an integer with the addition separate, and a Dutch postcode is resolved to street and town through the PDOK geocoder before it goes in.

Two things went wrong that a candidate would have noticed. Double clicks produced double applicants in AFAS until the form started sending an idempotency key. And for a while nobody could apply from the native app: the bot check accepted requests from the app’s web origin, but a Capacitor app sends its requests natively, often with no origin at all, so every application from the App Store version was rejected as a robot. The client forms never showed the problem because they fall back to the login token; an applicant has no token.

Most of the website is not mine; the in-house team built the portals, the planning board and the public site, and I worked in the seam between the app, the login and AFAS. The go-live dry run counted 3,629 employment lines in AFAS, 979 of them active with an address to log in with, and 7,851 clients.

The website’s front page on a desktop: a headline about reliable home care, a panel with three choices, I am already a client, I am not yet a client, apply, and a line saying more than 800 employees are ready to help.
The website, where the same three audiences arrive.
The website’s careers page: a heading Werken bij, a paragraph about working as household help, in individual support or at the office, and buttons to view vacancies and to apply.
Vacancies published here are the same rows that reach AFAS.

2 images — swipe or scroll sideways