Payroll

Three reads, answering three different questions: is this month settled (closures), who has a payslip for it (payslips), and what was declared into the run (variable elements).

Two warnings before anything else. The payslip read carries no amount at all and cannot be made to — see below. And the amount on a variable element sits behind two locks, of which your scope is only the first.

The restricted envelope used on variable elements is the one described under Withheld fields in Companies, employees and contracts.

The three reads do not share an order

Read Order
Closures Most recent month first
Payslips Most recent period first
Variable elements Oldest period first

This is not a mistake to work around silently: a consumer that pages the three the same way and stops early gets the newest closures, the newest payslips and the oldest elements. Page each to nextCursor: null, or sort on the period yourself.

Closures — is this month settled?

GET /api/v1/client/payroll-closures (scopes: payroll:read)

{
  "items": [
    {
      "id": "dV3BQN",
      "companyId": "1VaKPa",
      "referenceMonth": "2026-03-01",
      "periodFrom": "2026-02-21",
      "periodTo": "2026-03-20",
      "isClosed": true,
      "isSynchronized": true
    },
    {
      "id": "qJYA5J",
      "companyId": "1VaKPa",
      "referenceMonth": "2026-02-01",
      "periodFrom": "2026-01-21",
      "periodTo": "2026-02-20",
      "isClosed": true,
      "isSynchronized": false
    }
  ],
  "nextCursor": "LrKGZuyxy"
}
Parameter What it does
from, to ISO dates matching the reference month by containment.
establishmentId Narrows the page to ONE establishment of your perimeter. Omitted reads all of it.
cursor, limit Paging. Pass nextCursor back; stop only when it is null.

A closure is the state of one payroll month for one establishment. It carries no personal data at all — no employee, no amount, no comment. This read tells you whether a month is settled, never what is in it.

referenceMonth and the period are two different windows

referenceMonth is the month the closure designates, normalised to its first day: 2026-04-01 is April 2026, and together with companyId it is what identifies the closure. Key your records on it.

periodFrom and periodTo are the dates that closure actually covers — and they are not derivable from the reference month. A company paying in arrears closes April over a window that starts in March. Read the period as the span, never as a restatement of the month.

The from/to filter matches the reference month, never the covered period. Matching the period would answer months you did not ask for.

isSynchronized is the flag to branch on

Flag What it means
isClosed No more input is accepted. A closed month can still change.
isSynchronized The closure has been handed to the payroll software — that month is definitive.

The two are independent. Do not infer one from the other, and do not consume a month as final because it is closed: consume it when it is synchronized.

There is exactly one closure per establishment and reference month, so a month missing from the listing has not been created yet rather than having been deleted.

This read triggers nothing. Distribution is a different operation, on a different scope.

Payslips

GET /api/v1/client/payslips (scopes: payslips:read)

{
  "items": [
    {
      "id": "GNn7BV",
      "employeeId": "GNnBNO",
      "companyId": "1VaKPa",
      "periodName": "Mars 2026",
      "periodFrom": "2026-03-01",
      "periodTo": "2026-03-31",
      "isPaper": false,
      "distributedOn": "<generated>",
      "status": "Distributed",
      "hasDocument": true
    },
    {
      "id": "rPXa1N",
      "employeeId": "rPX1No",
      "companyId": "1VaKPa",
      "periodName": "Mars 2026",
      "periodFrom": "2026-03-01",
      "periodTo": "2026-03-31",
      "isPaper": false,
      "distributedOn": null,
      "status": "PendingDistribution",
      "hasDocument": false
    }
  ],
  "nextCursor": "2XeZzfj0j"
}
Parameter What it does
from, to ISO dates matching the period start by containment — ask for a month, get that month's run.
establishmentId Narrows the page to ONE establishment of your perimeter. Omitted reads all of it.
cursor, limit Paging. Pass nextCursor back; stop only when it is null.

One entry is one employee for one payroll period.

There is no amount on this read, and there cannot be one

No gross, no net, no net payable. The figures exist elsewhere in Staff & Go, but they hang off no key to this record: matching them would be a best-effort reconciliation already known to fail, so a number served here would sometimes be another employee's.

Do not infer, estimate or compute pay from this read. If you need the figures, take them from the PDF, which is the document the employer actually issued.

The employee is named by employeeId only

No name, no employee number, no social security number, no job function. None of them is withheld pending a scope — they are not served at all, which is why they never appear in restricted either. Join on employeeId against the employee read if you need an identity.

status is one code, picked by precedence

One row carries one code, the first that applies — not the full history. In order:

  1. NoPayslip — no document was produced. This is tested first and wins over everything else, so a row that was distributed but carries no document reads NoPayslip, not a distribution code.
  2. PostalDistributed, ClientDistribution or Distributed — it reached the employee. The three say how, narrowest first: by post, by the company's own officer, or by Staff & Go.
  3. Downloaded — somebody fetched it from the screens. A payslip that reached its employee is described by that rather than by a download, which is why this ranks below the family above.
  4. PendingDistribution — none of the above.

Do not reconstruct this order by guesswork: NoPayslip at the bottom rather than the top is the mistake it invites, and it is the one row where hasDocument and status must agree.

Several kinds of payslip are never served

In error, skipped, superseded by a corrected one, never reconciled to an employee, or belonging to an employee this API does not serve at all. Confidential and archived records are absent entirely — not reduced, absent — exactly as on the employee read.

So a count from this read is not a headcount, and an employee missing for a period means one of those cases, not an empty payroll.

Fetching the PDF

GET /api/v1/client/payslips/{id}/document (scopes: payslips:read)

hasDocument says whether there is a file to fetch. The route is deliberately not an MCP tool: the file names a person, their social security number and their pay.

Answer What it means
404 The payslip is not reachable — stop asking.
503 + Retry-After It exists, but its bytes could not be fetched — come back.

The two mean opposite things. And the 503 cannot say why: on a permanent loss of the stored file it would repeat forever. Cap your retries and open a support ticket rather than looping.

Variable payroll elements

GET /api/v1/client/variable-payroll-elements (scopes: payroll:read)

{
  "items": [
    {
      "id": "lNpkMP",
      "employeeId": "GNnBNO",
      "employmentId": "BVo9PW",
      "companyId": "1VaKPa",
      "natureCode": "HSUP",
      "natureName": "Heures supplementaires",
      "natureType": "Quantity",
      "unit": "H",
      "isPayrollExportable": true,
      "period": "2026-03-01",
      "value": null,
      "restricted": [
        {
          "reason": "requires_scope",
          "fields": [
            "value"
          ]
        },
        {
          "reason": "requires_company_opt_in",
          "fields": [
            "value"
          ]
        }
      ]
    },
    {
      "id": "OJyr0P",
      "employeeId": "GNnBNO",
      "employmentId": "BVo9PW",
      "companyId": "1VaKPa",
      "natureCode": "PRIME",
      "natureName": "Prime exceptionnelle",
      "natureType": "Amount",
      "unit": "EUR",
      "isPayrollExportable": false,
      "period": "2026-03-01",
      "value": null,
      "restricted": [
        {
          "reason": "requires_scope",
          "fields": [
            "value"
          ]
        },
        {
          "reason": "requires_company_opt_in",
          "fields": [
            "value"
          ]
        }
      ]
    }
  ],
  "nextCursor": "0AWX5t9d6"
}
Parameter What it does
from, to ISO dates matching the period by containment.
establishmentId Narrows the page to ONE establishment of your perimeter. Omitted reads all of it.
cursor, limit Paging. Pass nextCursor back; stop only when it is null.

One entry is what was declared on one employee, for one nature, for one payroll period: a bonus, an overtime count, a meal allowance. It is the input a run consumes, never its result — this read does not say what was paid.

value is meaningless without unit and natureType

The same 12 is twelve hours, twelve days or twelve euros depending on the nature. Adding values across natures produces a number that is wrong and looks right.

Field What it tells you
natureType Whether the value is money (Amount) or a count (Quantity). Always served.
unit The exact unit the company configured (H, J, EUR). null only when the nature records none.

Group by natureCode first, read the unit, then read the number. unit and natureType are never withheld, whatever your credential carries — precisely so that a withheld amount still says what kind of quantity was declared.

value sits behind two locks, and the scope is only the first

payroll:read:amounts is required in addition to payroll:read, which never implies it. On top of it, each company decides for itself whether it opens its variable pay to its partners.

Granted the sub-scope, you still receive null for every row of a company that has not opened it. So one page legitimately serves the amount for some rows and withholds it for others — never conclude from one withheld row that your credential lacks the scope.

A withheld value comes back null and is named in the restricted block of that entry, with the reason: requires_scope (ask the client administrator), requires_company_opt_in (only that company can open it), requires_plan (the partner plan does not cover it). Several reasons can stand at once and they all matter — different parties settle them, so clearing one leaves the others.

A null value not named in restricted means no amount was recorded. That is a different fact from one you may not see.

The nature travels inline

natureCode, natureName and natureType come with each entry, and there is no referential of element natures on this API. Map your own payroll headings on natureCode rather than looking for a nature id to resolve.

isPayrollExportable says whether the nature is meant to reach the payroll software. Rows where it is false are served all the same — what to do with them is your decision rather than ours.

employmentId is the current employment

It may be null, and it changes between two calls — the day that employee signs a new contract. An element hangs on the employee and the period, so this pointer is not necessarily the employment in force during that period.

The element is a historical fact; that pointer is not. Do not diff on it, and do not report a change of employmentId as a change to the element.

When the page is short

An establishment that lacks the relevant module is absent from these reads without being named, and the answer says so through the partial block — see When a listing is shorter than your mandate in Discovering what you can call.

None of these three reads offers a modifiedSince filter: no maintained, indexed column backs one. Re-read the window instead. A row that vanishes between two reads is absent, not deleted — there is no delta feed and no tombstone.