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 throughvalidateor by being sent. A correction brings it back todraft.declared: immutable, validation, correction and deletion answer409 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-previewThe 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>/unvalidatevalidate 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>/sendNo 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-updateThe 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>204response.canton,reasonandchangedFieldsare required; an emptyreasonanswers422 REASON_REQUIRED. Send back thecantonread from the payslip.- Without
variableItemsoroverrides, the stored values stay. An array replaces them,[]clears them.overridestakes{ key, value }pairs, wherekeyis the key of a payslip line, for exampleaanp. 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: runvalidateorsendagain. changedFieldsis 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>/modificationsIf 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>/duplicateThe 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-linkThe 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-statusanswers{ "enabled": false, "unqualifiedMonths": [] }.validateandsendtherefore do not block on a missing previous month.…/employees/{employeeId}/year-openings/{year}reads (nullif there is no opening), saves (PUT, withthroughMonth,acGrossandacBase) 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-periodsreturns[]as long as no period is saved, and…/laa-periods/simulatecalculates 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'savsFranchiseWaivedfield.
For pagination, request bodies and error codes, continue with Conventions and errors. Every field is detailed in your deployment's Swagger UI (…/api/docs).