Bulk exports
The reads of this API serve pages. They deliberately have no answer to "give me the four thousand employees": paging is right for keeping in step with a company day after day, and wrong for the first synchronisation, where it costs eighty calls and a cursor you have to hold.
Exports are that answer. You ask for a file, we produce it in the background, you download it once.
The three calls
POST /exports |
Ask for a file. Answers immediately with an export in Queued. |
GET /exports/{id} |
Where it stands. Poll this. |
GET /exports/{id}/content |
The file itself, once the status reads Ready. |
The body and the responses below are captured from the test suite, not transcribed — what you see is what the API received and returned.
POST /api/v1/client/exports
Idempotency-Key: 6a1f…-one-per-request
Content-Type: application/json
{
"type": "employees"
}
{
"id": "1VaKPa",
"type": "employees",
"status": "Queued",
"failureReason": null,
"rowCount": null,
"contentLength": null,
"createdOn": "<generated>",
"readyOn": null,
"expiresOn": null
}
Poll GET /api/v1/client/exports/{id} every few seconds. It is cheap and it is metered on your ordinary
bucket. When status reads Ready, fetch the content.
{
"id": "1VaKPa",
"type": "employees",
"status": "Ready",
"failureReason": null,
"rowCount": 2,
"contentLength": 353,
"createdOn": "<generated>",
"readyOn": "<generated>",
"expiresOn": "<generated>"
}
What you can export
The catalogue is a closed list of named types. You choose a type; you never compose a query.
| Type | What it contains | Scope you also need |
|---|---|---|
employees |
The rows of GET /employees, as CSV |
employees:read |
A type inherits the scope of the read it extracts. exports:write says you may ask for files;
employees:read says which rows you may be given. Asking for a type whose read scope you do not hold is
refused on the spot with 403 — not by a job that fails overnight.
This matters beyond authorisation: the file is produced by walking that very read, page by page. The
perimeter bound, the employees excluded from it and the fields withheld from you are the ones the listing
already applies. An export can never hand you a row or a field GET /employees would have kept back.
establishmentId narrows the extraction to one establishment of your perimeter. Like everywhere else on this
API it narrows and never widens — and it is the way out of the size limit below.
The file
employees is a CSV, and here is its exact form so you can parse it without guessing:
- comma separator,
CRLFline endings, UTF-8 without a byte-order mark; - RFC 4180 quoting — a field is quoted only when it contains a comma, a quote or a line break, and a quote inside a quoted field is doubled;
- one header line, naming the fields exactly as the JSON read names them;
- booleans are
true/false; an absent value is an empty field; - the identifiers are the same opaque ones
GET /employeesserves, so the two join.
The response is bytes under text/csv, with the file name in Content-Disposition. There is no JSON
envelope, no base64, and no ranged, chunked or resumable form of this download.
⚠️ This is integration data, not a spreadsheet. A value your customer typed that begins with =, +,
-, @, a tab or a carriage return is a formula to a spreadsheet, so such a value is written with one
apostrophe (') in front of it — a spreadsheet then shows it as text instead of evaluating it. This
applies to the typed fields only (fullName, civility, employeeNumber, professionalEmail,
companyName, jobFunction, managerFullName); the identifiers and isActive are never altered, so the
join with GET /employees holds. If you compare a typed field with the JSON read, strip that one leading
apostrophe when it is followed by one of those characters.
The size limit is functional
An extraction whose file passes the size this API serves in one response is refused rather than produced.
The export comes back Failed with failureReason TooLarge, and the content route then answers 413.
This is not a quota you wait out. Ask again one establishment at a time — pass establishmentId — or page
through GET /employees instead. The limit is configurable and your Staff & Go contact can tell you the value
in force on your environment.
Failure always has a name
A Failed export never leaves you guessing. failureReason is one of:
| Reason | What happened, and what to do |
|---|---|
PerimeterEmpty |
Every establishment your mandate designates has closed since you asked. There is nothing left to extract. |
ModuleNotSubscribed |
Your perimeter still resolves, but none of its establishments holds the module this type reads. Your client company opens it. |
TooLarge |
See above — narrow the request. |
Fault |
It failed on our side. Ask again. |
PerimeterEmpty is worth a word. You get it instead of an empty file, on purpose: an empty file cannot be
told from a company that has no employees, and the two call for opposite conduct.
Your perimeter is resolved when the file is produced
Not when you ask for it. A job waits in a queue, and a mandate can be reduced or revoked while it waits.
- Perimeter reduced in between → the file contains only what your mandate still covers, and a
partialblock on the status says the answer covers less of your perimeter than your credential was granted. - Perimeter emptied out →
FailedwithPerimeterEmpty. - Your perimeter is re-checked on every download too. A credential that no longer covers what the export
was created for gets
404— the same answer as an unknown id, never a403, which would confirm the export exists.
Fetch your files while your mandate stands.
Seven days, then the content is purged
An export is kept seven days after it becomes ready. expiresOn says exactly when.
Past it the export still answers — with Expired, not 404. "It existed and expired" and "it never existed"
are different facts, and only the first tells you to ask for a new one. rowCount and contentLength survive
the purge; the bytes do not. The content route then answers 410.
Asking twice
POST /exports requires an Idempotency-Key, like every protected write on this API — see
Idempotency. A replay of the same key and body hands back the export it already
created, rather than queuing a second walk of your perimeter.
It is also metered on a bucket of its own, far tighter than your plan rate. Creating is the expensive act; following and downloading stay on the ordinary bucket, so polling is not what will throttle you.
What an agent may do
create_export and get_export are MCP tools. create_export is non-idempotent, so an agent must ask you
before calling it.
The download is not a tool, and will not become one. An agent result is text a model quotes and recopies, and this file names every employee of your perimeter. Fetch it from code, store it as a file, and do not paste it into a model prompt. Every call to it is written to your audit trail.