Dutch test data, from a script.
The BRP/GBA and UPA generators over REST and MCP. Every setting on the tool page is a field in the request body, and the same seed always returns the same data.
Why you would call these instead of clicking.
Three reasons, and they are different from each other.
Test automation. A seed fixes the output, so a suite can assert on a specific BSN and still pass next month. Rebuild your fixtures from scratch in CI instead of committing a CSV that slowly stops matching your schema.
Development. Point your seed script at the endpoint and get a filled database in one call, including the awkward cases: names with diacritics, addresses abroad with no Dutch postcode, house number additions, people who died mid-period. These are the records that break an import, and they are the ones nobody types by hand.
Reliable AI output. Ask a language model for Dutch test data and it will produce BSNs that look right and fail the elfproef, because it is guessing digits. Over MCP the model calls the generator instead, so the numbers validate. The same holds for UPA XML, which no model can produce correctly from memory.
Both endpoints need a free API key. Get one on the developers page, or add the MCP server to your assistant.
BRP and GBA test persons.
Returns people with valid BSNs, families, addresses and a life event each: married, widowed, divorced, deceased, living abroad, no fixed address, under investigation. Up to 5,000 per call, the same ceiling as the tool page.
curl -X POST https://toolforte.com/api/v1/tools/brp-test-data-generator \
-H "Content-Type: application/json" \
-H "X-API-Key: your_api_key_here" \
-d '{}'curl -X POST https://toolforte.com/api/v1/tools/brp-test-data-generator \
-H "Content-Type: application/json" \
-H "X-API-Key: your_api_key_here" \
-d '{
"format": "json",
"options": {
"count": 25,
"seed": 20270101,
"minAge": 60,
"maxAge": 80,
"referenceYear": 2027,
"eventMix": { "married": 5, "widowed": 3, "nearRetirement": 4, "plain": 2 }
}
}'// The seed pins the data, so this assertion stays valid next month.
const res = await fetch(
"https://toolforte.com/api/v1/tools/brp-test-data-generator",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": process.env.TOOLFORTE_KEY,
},
body: JSON.stringify({ options: { count: 10, seed: 42 } }),
},
);
const { data } = await res.json();
expect(data.people[0].bsn).toBe(data.people[0].bsn); // same every run
expect(data.options.seed).toBe(42); // the run is reproducible from this{
"format": "csv",
"options": {
"count": 100,
"outputMode": "own",
"headerRow": "burgerservicenummer;achternaam;geboortedatum",
"delimiter": ";",
"excelHint": true
}
}Set format to gba-totaalfile for the GBA totaalfile layout instead of a readable sheet.
Every option
Anything you leave out keeps the default below, which is the same value the tool page starts with. An option you misspell is refused rather than ignored, so you never get data that quietly differs from what you asked for.
| Field | Type and range | Default | What it does |
|---|---|---|---|
| options.count | integer, 1 to 5000 | 50 | How many people to generate. |
| options.seed | number | 20270101 | The same seed always produces the same people. Fix it in a test suite so assertions stay valid; change it for a fresh set. |
| options.minAge | integer, 0 to 120 | 0 | Youngest age in the set, measured at the reference year. |
| options.maxAge | integer, 0 to 120 | 95 | Oldest age in the set. Must not be lower than minAge. |
| options.referenceYear | integer, 1900 to 2099 | 2027 | The year ages and life events are calculated against. Set it to the year your test scenario plays out in. |
| Field | Type and range | Default | What it does |
|---|---|---|---|
| options.eventMix | object, keys: plain, married, registeredPartnership, divorced, widowed, remarried, deceased, deceasedWithFamily, minor, nearRetirement, livesAbroad, movedRecently, confidential, underInvestigation, noFixedAddress | {"plain":6,"married":5,"registeredPartnership":2,"divorced":3,"widowed":2,"re… | Relative weights per life event. Naming any event replaces the whole mix, so {married: 5, widowed: 2} gives you only those two. Omit the field entirely to keep the default spread. At least one weight must be above 0. |
| Field | Type and range | Default | What it does |
|---|---|---|---|
| options.includeParents | true | false | true | Add parent records to each person. |
| options.maxChildren | integer, 0 to 10 | 2 | Upper bound on children per person. |
| Field | Type and range | Default | What it does |
|---|---|---|---|
| options.pctExoticNames | integer, 0 to 100 | 15 | Percentage of unusual names: long, hyphenated, diacritics. Raise it to find where your system truncates or mangles input. |
| options.pctExoticStreets | integer, 0 to 100 | 15 | Percentage of unusual street names. |
| options.pctAffix | integer, 0 to 100 | 25 | Percentage of surnames with a tussenvoegsel (van, de, ter). A classic source of sorting and matching bugs. |
| options.pctHouseAddition | integer, 0 to 100 | 20 | Percentage of addresses with a house number addition (12-A, 3bis). |
| Field | Type and range | Default | What it does |
|---|---|---|---|
| options.dateFormat | "yyyymmdd" | "iso" | "dutch" | "yyyymmdd" | Date notation: 20270131, 2027-01-31, or 31-01-2027. Match whatever your system reads. |
| options.outputMode | "compact" | "own" | "mapped" | "compact" | compact returns a readable overview. own returns exactly your own header row. mapped fills your columns from ours using the mapping below. |
| options.headerRow | string | "" | Your own column names, separated by the delimiter. Required for outputMode own and mapped. |
| options.mapping | object of string to string | {} | For outputMode mapped: your column name to our field key. Columns you leave out come back empty. |
| options.delimiter | "," | ";" | a tab | "," | Column separator for the CSV formats and for headerRow. |
| options.excelHint | true | false | false | Prefix the CSV with a separator hint so Excel opens it in columns instead of one long string. |
Life events you can weight
| plain | A person with only the basic record. The control group. |
| married | A partnership of type H with a start date and a partner block. |
| registeredPartnership | A partnership of type P with a start date and a partner block. |
| divorced | A dissolved partnership with reason S in the previous partner block. |
| widowed | A dissolved partnership with reason O, the partner died. |
| remarried | A current partnership plus an earlier dissolved one, so both blocks are filled. |
| deceased | A date, place and country of death on the person themselves. |
| deceasedWithFamily | Death plus a surviving partner and a minor child, the classic survivor pension case. |
| minor | Under 18, for age rules and orphan scenarios. |
| nearRetirement | Aged 65 to 67. |
| livesAbroad | Address abroad in three free text lines with a country code, no Dutch postcode. |
| movedRecently | A current address and a previous address, both dated. |
| confidential | Confidentiality indicator set, which many systems must respect in correspondence. |
| underInvestigation | Investigation start date filled, so the record is in onderzoek. |
| noFixedAddress | Registered with the municipality but without a residential address. |
UPA pension declarations.
Returns UPA XML for one or more consecutive periods, with employments, scheme blocks and household situations. Up to 10,000 employments per call.
curl -X POST https://toolforte.com/api/v1/tools/upa-file-generator \
-H "Content-Type: application/json" \
-H "X-API-Key: your_api_key_here" \
-d '{
"format": "xml",
"options": {
"schemeType": "FPR",
"population": { "count": 50, "seed": 20270101 },
"period": { "startYear": 2027, "startMonth": 1, "months": 3 }
}
}'{
"format": "xml",
"options": {
"population": { "count": 10 },
"defects": { "invalidBsn": true, "totalsMismatch": true }
}
}
// The response warns you these are deliberate:
// "2 deliberate defects injected. These files are meant to be rejected."{ "format": "summary", "options": { "population": { "count": 200 } } }options.population
| Field | Type and range | Default | What it does |
|---|---|---|---|
| population.count | integer, 1 to 10000 | 25 | How many employments to generate. |
| population.seed | number | 20270101 | The same seed returns the same people, so a test can assert on a fixed BSN and a pipeline can rebuild its fixtures. |
| population.minAge | integer, 15 to 70 | 18 | Youngest employee. |
| population.maxAge | integer, 15 to 80 | 67 | Oldest employee. Must not be lower than minAge. |
| population.minSalary | integer, 0 to 1000000 | 24000 | Lowest annual salary in euros. |
| population.maxSalary | integer, 0 to 1000000 | 90000 | Highest annual salary in euros. |
| population.partTimeVariation | true | false | true | Vary part-time percentages instead of putting everyone on 100 percent. |
| population.holidayAllowanceMonth | integer, 0 to 12 | 5 | Month the holiday allowance is paid. 0 means none. |
| Field | Type and range | Default | What it does |
|---|---|---|---|
| population.pctForeignAddress | integer, 0 to 100 | 12 | Percentage living abroad. Foreign addresses have no postcode in the Dutch format, which is where address validation tends to break. |
| population.pctExoticNames | integer, 0 to 100 | 15 | Percentage of unusual names. |
| population.pctExoticStreets | integer, 0 to 100 | 15 | Percentage of unusual street names. |
| population.longNames | true | false | true | Include names long enough to hit column width limits. |
| population.hyphenAndApostrophe | true | false | true | Include names with hyphens and apostrophes. |
| population.diacritics | true | false | true | Include accented characters, to catch encoding problems. |
| population.tussenvoegsels | true | false | true | Include Dutch name prefixes (van, de, ter). |
| population.houseNumberAdditions | true | false | true | Include house number additions (12-A, 3bis). |
options.period
| Field | Type and range | Default | What it does |
|---|---|---|---|
| period.startYear | integer, 2000 to 2099 | 2027 | Year the first declaration covers. |
| period.startMonth | integer, 1 to 12 | 1 | Month the first declaration covers. |
| period.months | integer, 1 to 36 | 3 | How many consecutive periods to generate. |
| period.periodType | "MND" | "VWK" | "MND" | Monthly (MND) or four-weekly (VWK) periods. |
| period.declarationKind | "volledig" | "aanvullend" | "volledig" | A full declaration, or a supplementary one that assumes a full declaration for the period already exists. |
| period.splitMode | "single" | "chunk" | "perParticipant" | "single" | One file per period, files of a fixed size, or one file per participant. Use perParticipant when you need to trace a single case end to end. |
| period.chunkSize | integer, 1 to 10000 | 10 | Employments per file when splitMode is chunk. |
options.household
| Field | Type and range | Default | What it does |
|---|---|---|---|
| household.pctMarried | integer, 0 to 100 | 40 | Percentage married. |
| household.pctCohabiting | integer, 0 to 100 | 15 | Percentage cohabiting. |
| household.pctDivorced | integer, 0 to 100 | 15 | Percentage divorced. |
| household.pctWidowed | integer, 0 to 100 | 5 | Percentage widowed. |
| household.pctWithChildren | integer, 0 to 100 | 45 | Percentage with children. |
| household.partnerSameFund | true | false | true | Allow partners who are in the same pension fund. |
| household.partnerOtherFund | true | false | true | Allow partners in a different fund. |
| household.deceasedPartners | true | false | true | Include deceased partners. |
| household.deceasedExPartners | true | false | true | Include deceased ex-partners. |
| household.deceasedChildren | true | false | false | Include deceased children. |
Scenarios and defects
options.scenarioMix takes a weight per scenario, and options.defects takes a boolean per defect.
| standard | Unchanged employment for the whole run. The control group. |
| newHire | Starts inside the run. DatAanv falls in the period it first appears. |
| leaver | DatEind plus CdRdnEindArbov in the period the employment ends. |
| rehire | Leaves, then returns with a NEW NumIV, as the IKV rules require. |
| payrollSwitch | Keeps the same NumIV across the switch, as the IKV rules require. |
| deathInService | DatOvl filled and DatEind set to the same date. |
| marriage | CdBurgSt goes to 2 with a DtIngBurgSt in the period the marriage takes effect. |
| registeredPartnership | CdBurgSt goes to 5 with a DtIngBurgSt in the period the partnership starts. |
| divorce | CdBurgSt goes to 3 with a DtIngBurgSt in the period the divorce is registered. |
| widowed | CdBurgSt goes to 4 with DtIngBurgSt. |
| unpaidLeave | CdIncInkVerm O plus the Verlof group with CdSrtVerl OBD, and VerlofUReg from UPA 2025 onwards. |
| parentalLeave | CdIncInkVerm K (kindverlof) plus the Verlof group with CdSrtVerl OSP. Code G was replaced by K in UPA 2022. |
| sickness | CdIncInkVerm Z with a reduced RegLn. |
| earlyIva | CdIncInkVerm Z with RegLn 0 and AantVerlUPens 0. |
| onCall | IndOprov J, AantUCntrWk 0, and a period with RegLn 0. |
| intern | CdAard 7 and no RegelingGegevens at all. |
| dga | SrtIV 17 and no CdAard, so no scheme participation. |
| minor | Under 18 with a small salary, for entry-age rules. |
| nearRetirement | Aged 65 to 67, for retirement and AOW triggers. |
| partTimeChange | PtPerc and AantVerlUPens change halfway through the run. |
| backPayment | A pensionable payment in a period after DatEind. |
| invalidBsn | One employment gets a 9-digit BSN that does not pass the 11-check. |
| duplicateBsn | Two employments in one file carry the same BSN. |
| duplicateNumIv | Two employments in one file carry the same income relation number. |
| totalsMismatch | TotRegLn and TotPremieReg are off by 10 percent against the detail lines. |
| endBeforeStart | DatEindRegGeg is set before DatAanvRegGeg on one employment. |
| dateOutsidePeriod | One DatAanvRegGeg falls a month before the period start. |
| unknownRegVrnt | One employment uses a RegVrnt that does not exist in the scheme. |
| negativeAmount | One RegLn and PremieGrslg are negative. |
| zeroNormHours | AantUNormWk is 0 while scheme data is supplied, which the spec rejects. |
| missingMandatory | SofiNr is left out of one employment. |
The remaining sections
declaration, schemes, fieldStates and pinned take exactly the shape the tool page uses. Rather than describe them in prose, here is the complete default request. Send back any part of it with your own values.
{
"schemaVersion": "2024-01",
"schemeType": "FPR",
"declaration": {
"employerName": "TESTWERKGEVER ALFA BV",
"payrollTaxNumber": "100000015L01",
"contactPerson": "MW A. TESTBEHEERDER",
"phone": "06-10000001",
"relationNumber": "SWO00077",
"softwarePackage": "LOKET.NL",
"providerId": "PUO000001",
"administrationId": "UPA000086",
"messageIdPrefix": "TEST",
"affiliationNumber": "18018",
"collectiveAgreement": "316",
"sector": "17",
"taxTable": "014",
"jobDescription": "ALGEMEEN",
"normHoursPerWeek": "40",
"franchise": 18000,
"maxPensionableSalary": 80000
},
"schemes": [
{
"enabled": true,
"label": "Basisregeling",
"schemeCode": "U0574-3001",
"variants": "FPR00, FPR01, FPR02, FPR03",
"premiumPct": 27.6,
"useFranchise": true
},
{
"enabled": false,
"label": "Aanvullende regeling",
"schemeCode": "U0574-3002",
"variants": "FPR90",
"premiumPct": 4.2,
"useFranchise": true
},
{
"enabled": false,
"label": "Sociaal fonds",
"schemeCode": "U0574-9001",
"variants": "SF001",
"premiumPct": 0.6,
"useFranchise": false
}
],
"fieldStates": {
"ContPers": "fill",
"TelNr": "fill",
"RelNr": "fill",
"GebrSwPakket": "fill",
"IdLcr": "fill",
"NmLcr": "fill",
"NmIP": "fill",
"Voorl": "fill",
"Voorv": "fill",
"Nat": "fill",
"Gesl": "fill",
"DatOvl": "fill",
"CdBurgSt": "fill",
"DtIngBurgSt": "fill",
"TaalVoorkeur": "omit",
"Email": "omit",
"Adres": "fill",
"HuisNrToev": "fill",
"LocOms": "omit",
"DatEind": "fill",
"CdRdnEindArbov": "fill",
"CdAard": "fill",
"CdInvlVpl": "omit",
"CAO": "fill",
"IndArbovOnbepTd": "fill",
"IndOprov": "fill",
"LbTab": "fill",
"CdBeroep": "omit",
"FuncOms": "fill",
"CdIncInkVerm": "fill",
"AantUNormWk": "fill",
"AantUCntrWk": "fill",
"PtPerc": "fill",
"CdVerbIkv": "omit",
"IndGenReg": "fill",
"CollectieveAangifte": "fill",
"Werknemersgegevens": "fill",
"Avwb": "fill",
"Dgln": "omit",
"Verlof": "fill",
"Sector": "fill",
"DatEindSect": "omit",
"IndEindDln": "fill",
"PremieWkn": "fill",
"AantVerlUPens": "fill",
"VerlofUReg": "fill"
},
"pinned": []
}From an AI assistant.
Both generators are on the MCP server as generate_brp_test_data and generate_upa_files. Counts are capped lower there than over REST, because an assistant pays for every token it reads back. For bulk work, have it write a script against the REST endpoint instead.
"Generate 10 Dutch test people over 60, half of them widowed,
and put them in a CSV I can import."
"Make a UPA declaration for 20 employments over 3 months in 2027,
and include a file with a mismatched total so I can test the rejection."This page is generated from the same parameter definitions the API validates against, so it cannot fall behind the code.