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:

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.

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.