This guide covers everything that is not invoicing. Same key, same base URL as in Getting started with the API:
BASE=https://<deployment>.convex.site/api/v1
KEY=ba_...1. Two ways to address a company
The payroll routes are company-addressed like every other resource: /companies/{companyId}/employees and …/payslips. The same key runs payroll on every company you can access — pick the companyId from GET /companies.
Everything else is addressed explicitly: absences, expenses, bank transactions and financial statements live under /companies/{companyId}/…, and one key serves every accessible company there, provided the matching module is on.
COMPANY=<companyId>2. Employees
curl -H "Authorization: Bearer $KEY" $BASE/companies/$COMPANY/employeesThe list is paginated (?limit, ?cursor, creation order) and returns each employee with identity, address, contract, salary and insurance rates. Creation takes the field set of the app's form:
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{
"firstName": "Anna", "lastName": "Muster",
"address": { "kind": "swiss", "country": "CH", "street": "Rue du Lac", "streetNumber": "1",
"postalCode": "1700", "city": "Fribourg", "canton": "FR", "communeBfs": 2196 },
"permitType": "swiss", "contractType": "cdi", "maritalStatus": "single",
"grossMonthlySalary": 6500, "workPercentage": 100,
"startDate": "2026-01-01", "vacationWeeks": 5,
"thirteenthSalaryEnabled": true,
"language": "de"
}' $BASE/companies/$COMPANY/employeesThe 201 response holds id and slug. The optional language (fr, de, en, it) is the payslip language: stored labels, PDF and sending email; omitted, the payslip follows the account owner's language. A partial update recomputes the employee's unsent payslips, like an edit on screen:
curl -X PATCH -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "grossMonthlySalary": 6800 }' $BASE/companies/$COMPANY/employees/<employeeId>To expect: 403 EMPLOYEE_LIMIT_REACHED when the plan's employee quota is reached, 422 END_DATE_REQUIRED_FOR_CDD for a fixed-term contract without an end date, 422 END_DATE_BEFORE_START_DATE if it precedes the start.
3. Payslips
curl -H "Authorization: Bearer $KEY" "$BASE/companies/$COMPANY/payslips?year=2026&month=7"
curl -H "Authorization: Bearer $KEY" "$BASE/companies/$COMPANY/payslips?employeeId=<employeeId>"Paginated list, newest first. year and month always go together (one without the other is a 400 VALIDATION_ERROR); employeeId filters by person — a filtered page can be short, page until nextCursor is null. Creation computes the payslip with all the app's checks:
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "employeeId": "<employeeId>", "year": 2026, "month": 7, "canton": "FR" }' \
$BASE/companies/$COMPANY/payslipsOptional: variableItems (bonus, overtime, custom lines) and overrides. Answers to expect: 409 PAYSLIP_ALREADY_EXISTS if the period already exists for that employee, 422 PAYSLIP_PERIOD_OUTSIDE_EMPLOYMENT outside the employment window, 422 MISSING_IS_BAREME when no withholding-tax scale is loaded for that canton and year, 403 PAYSLIP_LIMIT_REACHED once the monthly quota is spent.
For a whole month, the batch takes 1 to 100 payslips and is not atomic: each item is processed on its own, and the response reports each one's outcome. An error mid-batch does not undo the payslips already created.
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "payslips": [
{ "employeeId": "<id1>", "year": 2026, "month": 7, "canton": "FR" },
{ "employeeId": "<id2>", "year": 2026, "month": 7, "canton": "FR" }
] }' $BASE/companies/$COMPANY/payslips/batch{
"data": {
"results": [
{ "index": 0, "status": "created", "id": "..." },
{ "index": 1, "status": "error", "code": "PAYSLIP_ALREADY_EXISTS" }
]
}
}The PDF downloads like an invoice's, with -L to follow the 302 redirect to a URL valid for 15 minutes; the first download of a payslip may take a few seconds while it is generated.
curl -L -H "Authorization: Bearer $KEY" -o payslip.pdf $BASE/companies/$COMPANY/payslips/<payslipId>/pdf4. Absences
Absences are company-addressed (/companies/{companyId}/absences, payroll module), unlike employees. Declaring a 50% sickness over two weeks:
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "employeeId": "<employeeId>", "motif": "maladie",
"startDate": "2026-03-02", "endDate": "2026-03-13",
"incapacityPercent": 50 }' \
$BASE/companies/$COMPANY/absencesemployeeId, motif and startDate are required. motif is maladie, accident, apg_maternite, apg_paternite, apg_adoption, apg_prise_en_charge, apg_service, maintien_salaire, conge_court or conge_non_paye. Dates here are YYYY-MM-DD strings, not milliseconds, because that is how absences are stored and ordered; an absence without endDate is ongoing.
dailyAmountOverride: the daily indemnity communicated by the insurer, which bypasses both the rate and the statutory cap.waitingDaysOverride: waiting days of the sickness policy.ratePercentOverride: rate from 0 to 100, default 80 or the employee's configured rate.takenAsIsolatedDays: paternity or adoption taken as isolated days (APG 7-for-5 top-up).incapacityPercent: 1 to 100, default 100; andnotes.
The 201 response returns the absence, always in status declared. 409 ABSENCE_OVERLAPS_EXISTING means another absence of the same employee already covers some of those days: an overlap would double the retention and indemnity lines, so split the ranges.
curl -X PUT -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "motif": "maladie", "startDate": "2026-03-02", "endDate": "2026-03-20" }' \
$BASE/companies/$COMPANY/absences/<absenceId>Status has its own sub-route: declaredapprovedpaid, a simple bookkeeping marker, any target is accepted from any state.
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "status": "approved" }' \
$BASE/companies/$COMPANY/absences/<absenceId>/statusThere is deliberately no period filter: an ongoing absence has no end, and a month filter would drop it. Page through the year and filter client-side, or narrow by employeeId. Deleting an absence does not retract lines already booked on a payslip; only recomputing the payslip does.
5. Expenses
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "vendorName": "Swisscom", "status": "toPay", "documentDate": "2026-02-28",
"currency": "CHF", "total": 129.9, "category": "telecom" }' \
$BASE/companies/$COMPANY/expensesvendorName, status, documentDate, currency, total and category are required; the VAT breakdown (subtotal, vatRate, vatAmount) and the vendor identifiers are optional. status is toPay or paid. category is one of 16 fixed categories: rent, insurance, goodsPurchases, subcontracting, officeSupplies, itSoftware, telecom, vehicle, travel, meals, marketing, professionalFees, utilities, bankFees, training, other.
year is derived from documentDate, and paidDate follows status: set to now when the expense is created paid without one, cleared as soon as the status is no longer paid. Listing a year, then marking an expense paid:
curl -H "Authorization: Bearer $KEY" "$BASE/companies/$COMPANY/expenses?year=2026"
curl -X PATCH -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "status": "paid" }' \
$BASE/companies/$COMPANY/expenses/<expenseId>The stored receipt is exposed read-only, with the same 302 redirect as the PDFs; an expense created without a receipt (hasDocument: false) answers 404:
curl -L -H "Authorization: Bearer $KEY" -o receipt.pdf \
$BASE/companies/$COMPANY/expenses/<expenseId>/documentOut of scope in v1: uploading a receipt (no multipart endpoint), AI extraction, and Drive-import drafts awaiting review, invisible here. Codes to expect: 403 SUPPLIER_BILL_LIMIT_REACHED once the monthly expense quota is spent, 403 CURRENCY_PRO_ONLY for a currency other than CHF on a Free plan.
6. Bank transactions
There is no single POST: the app only creates transactions in batches, one imported file is one batch, and the batch is the entry point.
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "fileName": "ubs-2026-01.csv",
"transactions": [
{ "date": "2026-01-15", "amount": -1250, "currency": "CHF",
"label": "Loyer janvier", "bankAccount": "CH93 0076 2011 6238 5295 7",
"category": "rent" }
] }' \
$BASE/companies/$COMPANY/bank-transactions/batchtransactions holds 1 to 100 rows (400 BATCH_SIZE_INVALID otherwise), each with at least date, amount, currency, label and bankAccount. amount is signed: a credit is positive, a debit negative. The batch is one database transaction, everything is written or nothing is. importBatchId groups the rows of one file; omitted, it is generated and the response tells you which. A category you send is recorded as a manual choice the import heuristics will never overwrite.
{ "data": { "inserted": 1, "importBatchId": "9f1c…" } }curl -H "Authorization: Bearer $KEY" "$BASE/companies/$COMPANY/bank-transactions?year=2026"
curl -X PATCH -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "category": "insurance", "color": "#3c8fc3" }' \
$BASE/companies/$COMPANY/bank-transactions/<transactionId>year is the calendar year of the booking date; an exercise that does not close in December spans two of them, so leave the filter out to page across the whole history. PATCH takes category (marked manual) and color, a #rrggbb tag or "" to clear it; DELETE answers 204.
7. Financial statement
The annual financial statement is one resource per company and fiscal year: fiscalYear is the closing year (the 2025/26 exercise of a company closing in June is 2026) and serves as the id, hence a GET/PUT pair on the year rather than a collection.
curl -H "Authorization: Bearer $KEY" \
$BASE/companies/$COMPANY/financial-statements/2026Reading returns the stored input, the ratesUsed snapshot and, once the app's generator has produced it, the computed document under snapshot. 404 if nothing is stored for that year. Writing is an upsert on (company, fiscal year):
curl -X PUT -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "input": { "accounts": [ { "id": "CH93…", "currency": "CHF", "openingBalance": 12000 } ],
"manual": { "tradeReceivables": 0, "otherShortTermReceivables": 0,
"fixedAssets": 0, "ownerCurrentAccountBalance": 0,
"tradePayables": 0, "otherShortTermDebts": 0,
"avsBalance": 0, "lppBalance": 0, "accruedLiabilities": 0,
"taxProvision": 0, "shareCapital": 20000, "legalReserve": 0,
"retainedEarnings": 0, "depreciation": 0,
"proposedDividend": 0, "proposedReserveAllocation": 0,
"employeeCount": 2 },
"closingRates": { "EUR": 0.93 } } }' \
$BASE/companies/$COMPANY/financial-statements/2026The body carries the generate-form input: the confirmed bank accounts with their opening balance, the manually entered items (manual), the closing FX rates and an optional period. The snapshot is derived data, produced by the generator, never in the request: a PUT that omits it leaves the stored document untouched. period is only needed for an exercise that is not the calendar year, and its to must fall inside fiscalYear (422 PERIOD_FISCAL_YEAR_MISMATCH otherwise).
8. Trustee mandates
For a trustee, /mandates is a key-level route, with no plan or module: it lists the client companies you own. mandateId is simply the mandate's companyId. Creating a mandate creates a company:
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "name": "Muster AG" }' $BASE/mandatesFrom then on, every /companies/{companyId}/… route applies to the mandate with that same id — employees, payslips and absences included: the same key lists a mandate's employees and runs its payroll.
For the rules that hold everywhere (pagination, dates, strict bodies, error codes, sandbox), read Conventions and errors; the invoicing path is in Invoicing with the API. Every field is detailed in your deployment's Swagger UI (…/api/docs).