Back to guides

Payroll cycle with the API

A payroll month in REST calls, from the calendar to the PDFs, with what each step does to the payslip status.

Developers9 min read
Table of contents+

This guide walks through a complete payroll month: preparing the month, creating the payslips, validating them, sending them, correcting them if needed, then fetching the PDFs. Creating employees and the detail of a payslip's fields are covered in Payroll, expenses and accounting with the API. Same key and same base URL as in Getting started with the API:

BASE=https://<deployment>.convex.site/api/v1
KEY=ba_...
COMPANY=<companyId>

1. Payslip statuses

All these routes require the Business plan and the payroll module (modules.payroll in GET /me). A payslip's status field drives the rest of the guide:

  • draft: editable and deletable. It is recomputed automatically if it covers the current month or a later one, when the employee's data changes.
  • validated: frozen, automatic recomputations no longer touch it. A payslip gets there through validate or by being sent. A correction brings it back to draft.
  • declared: immutable, validation, correction and deletion answer 409 PAYSLIP_DECLARED. No API route sets this status.

2. Prepare the month

These routes only require read access and write nothing. The calendar gives the public holidays falling on weekdays in each month, for the given canton or, without workCanton, the company's canton:

curl -H "Authorization: Bearer $KEY" "$BASE/companies/$COMPANY/payroll/calendar?year=2026"

curl -H "Authorization: Bearer $KEY" "$BASE/companies/$COMPANY/payroll/holiday-variants?year=2026&canton=FR"

weekdayHolidaysByMonth has one element per month (index 0 is January) and stays empty when hasCalendar is false. holiday-variants lists the public-holiday regimes of a canton (empty if the canton has only one); the company chooses its own with publicHolidayVariant in PATCH /companies/{companyId}. For an hourly-paid employee, the preview gives what the payslip will pay:

curl -H "Authorization: Bearer $KEY" \
  "$BASE/companies/$COMPANY/employees/<employeeId>/holiday-preview?year=2026&month=8&canton=FR"

source is calendar (with days, hours and the holidays list), percentage (with rate) or none. For a monthly-paid employee, data is null. Before hiring someone or changing a contract, the statutory preview checks conditions that are not saved yet:

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "year": 2026,
        "employee": { "salaryMode": "monthly", "grossMonthlySalary": 3200, "workPercentage": 100,
                      "weeklyHours": 42, "vacationWeeks": 4, "dateOfBirth": "2003-04-12",
                      "thirteenthSalaryEnabled": false, "workCanton": "GE" } }' \
  $BASE/companies/$COMPANY/payroll/statutory-preview

The response is a list of warnings (code, level, params), for example vacationBelowStatutoryMinimum or cantonalMinimumWageBelow. It blocks nothing: it is up to you to take it into account. Finally, the month's absences:

curl -H "Authorization: Bearer $KEY" \
  "$BASE/companies/$COMPANY/employees/<employeeId>/absence-lines?year=2026&month=7"

lines proposes the lines to put on the payslip, with label, amount, type and the flags subjectToCharges, subjectToIS and subjectToInsurance. Nothing is written: copy the ones you keep into variableItems. If missingParams is true, no indemnity parameters are loaded, so an empty list does not mean there is nothing to pay. Two checks complete the picture: GET …/employees/{employeeId}/absence-compliance?year=2026&month=7 and GET …/employees/{employeeId}/avs-gap?year=2026.

3. Create the month's payslips

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "payslips": [
        { "employeeId": "<id1>", "year": 2026, "month": 7, "canton": "FR",
          "variableItems": [{ "label": "Performance bonus", "amount": 500, "type": "bonus" }] },
        { "employeeId": "<id2>", "year": 2026, "month": 7, "canton": "FR", "hoursWorked": 120 }
      ] }' $BASE/companies/$COMPANY/payslips/batch

curl -H "Authorization: Bearer $KEY" "$BASE/companies/$COMPANY/payslips?year=2026&month=7"

The batch is not atomic: the response gives the result of each row (status is created or error, with a code). A variableItems line requires label, amount and type (overtime, bonus, commission, custom, absence, deduction). Payslips start as draft; read them again, with their lines, through GET …/payslips/{payslipId}.

4. Validate

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{}' $BASE/companies/$COMPANY/payslips/<payslipId>/validate

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "reason": "Hours to review" }' $BASE/companies/$COMPANY/payslips/<payslipId>/unvalidate

validate freezes the payslip without sending it. Its body is required, even if empty, and reason is optional in it. The response gives changed: false if the payslip was already validated. unvalidate brings it back to draft, with a required reason, recorded in the history. Neither of them recomputes the payslip: a payslip brought back to draft is only recomputed at the next change on the employee or the company.

5. Send

curl -X POST -H "Authorization: Bearer $KEY" $BASE/companies/$COMPANY/payslips/<payslipId>/send

No body; the response is 204. The PDF is emailed to the employee's address, in the language of the payslip, and a draft moves to validated; sentStatus and sentDate are filled in. A payslip that was already sent can be sent again. To expect: 422 EMPLOYEE_EMAIL_MISSING, 503 PDF_UNAVAILABLE and 502 EMAIL_SEND_FAILED, the last two worth retrying. On the sandbox, nothing is delivered but the status changes in the same way.

6. Correct

Start by measuring the effect of a correction. preview-update only requires read access and writes nothing:

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "canton": "FR", "variableItems": [{ "label": "Performance bonus", "amount": 800, "type": "bonus" }] }' \
  $BASE/companies/$COMPANY/payslips/<payslipId>/preview-update

The response gives currentNetSalary, newNetSalary and netDelta, or null if the calculation does not succeed, for example without a withholding-tax scale. The correction itself takes the same fields, plus a reason and the details of the changes:

curl -X PATCH -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "canton": "FR",
        "variableItems": [{ "label": "Performance bonus", "amount": 800, "type": "bonus" }],
        "reason": "Bonus corrected",
        "changedFields": [{ "field": "Performance bonus", "previousValue": "500", "newValue": "800" }],
        "createCorrection": true, "correctionLabel": "June bonus correction" }' \
  $BASE/companies/$COMPANY/payslips/<payslipId>
  • 204 response. canton, reason and changedFields are required; an empty reason answers 422 REASON_REQUIRED. Send back the canton read from the payslip.
  • Without variableItems or overrides, the stored values stay. An array replaces them, [] clears them. overrides takes { key, value } pairs, where key is the key of a payslip line, for example aanp. Correction lines coming from a previous month are always kept.
  • A validated payslip goes back to draft, and its PDF and sent flag are reset: run validate or send again.
  • changedFields is not computed: it is your own description of the change, copied into the history.

The history is read newest first, with reason, changedFields and createdAt:

curl -H "Authorization: Bearer $KEY" $BASE/companies/$COMPANY/payslips/<payslipId>/modifications

If it is an absence that changed, GET …/employees/{employeeId}/payslips-to-update?startDate=2026-03-01&endDate=2026-03-31 lists the issued payslips whose absence lines no longer match (proposedCount at 0: the line simply has to disappear). This route writes nothing, so the correction itself remains to be done.

To start over, DELETE …/payslips/{payslipId} deletes a payslip, with its lines and its history. POST …/payslips/bulk-delete takes 1 to 100 ids and is atomic: an unknown id, or one from another company, answers 404 NOT_FOUND and nothing is deleted. The response gives affected and skipped, which counts repeated ids.

7. Duplicate to another month

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "year": 2026, "month": 8 }' $BASE/companies/$COMPANY/payslips/<payslipId>/duplicate

The 200 response gives the id of the copy, a draft for the same employee, recomputed for its month with the variable lines of the original. The PDF, the sending status and the validation are not carried over. To expect: 409 PAYSLIP_ALREADY_EXISTS if that month already exists, 422 PAYSLIP_PERIOD_OUTSIDE_EMPLOYMENT outside the employment period.

8. Fetch the PDFs in batches

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "year": 2026, "month": 7, "limit": 25 }' $BASE/companies/$COMPANY/payslips/pdf-batch

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "year": 2026, "month": 7, "limit": 25, "cursor": "<nextCursor>" }' \
  $BASE/companies/$COMPANY/payslips/pdf-batch
{
  "data": [
    { "payslipId": "...", "url": "https://...", "error": null },
    { "payslipId": "...", "url": null, "error": "PDF_UNAVAILABLE" }
  ],
  "nextCursor": "..."
}

limit ranges from 1 to 25 (10 by default), and this route requires write access, because it generates the missing files. Repeat the call with the same period and the nextCursor you received until it is null. A failed row does not stop the batch: retry it on its own with GET …/payslips/{payslipId}/pdf. The url values are short-lived (15 minutes), download them right away.

9. Track the employee portal

curl -H "Authorization: Bearer $KEY" $BASE/companies/$COMPANY/employee-portal-links

curl -H "Authorization: Bearer $KEY" $BASE/companies/$COMPANY/employees/<employeeId>/portal-link

The list is paginated and gives, per employee, employeeId and status. status holds lastSentAt, expiresAt and active, or is null if no link has been sent. These routes only read, and never return the link itself: sending is done in the app, as explained in Manage the employee portal.

10. Opening balances, LAA and AVS: what responds today

In production, each month is computed on its own: the annual cumulation of the unemployment insurance (AC) ceiling, dated accident insurance (LAA) periods and the annual AVS choice do not enter the payslip calculation. The corresponding routes are still in the contract, and they respond:

  • GET …/payslips/{payslipId}/cumulative-status answers { "enabled": false, "unqualifiedMonths": [] }. validate and send therefore do not block on a missing previous month.
  • …/employees/{employeeId}/year-openings/{year} reads (null if there is no opening), saves (PUT, with throughMonth, acGross and acBase) and deletes a year-to-date opening. It changes no ordinary payslip: only payments tied to an earning year read it, namely those paid after the employee has left or earned before the reference age.
  • …/employees/{employeeId}/laa-periods returns [] as long as no period is saved, and …/laa-periods/simulate calculates without writing anything. …/laa-summary/{year} stays empty in that case.
  • …/employees/{employeeId}/avs-choices/{year} reads and saves an annual choice, with no effect on the calculation. It requires a valid AVS number (422 PAYROLL_AVS_IDENTITY_REQUIRED) and a year from 2023 on (422 PAYROLL_AVS_CHOICE_INVALID). The waiver of the allowance that counts today is the employee's avsFranchiseWaived field.

For pagination, request bodies and error codes, continue with Conventions and errors. Every field is detailed in your deployment's Swagger UI (…/api/docs).