This guide covers everything that is not invoicing, except the monthly run of payslips, detailed in Payroll cycle with the API. Same key, same base URL as in Getting started with the API:
BASE=https://<deployment>.convex.site/api/v1
KEY=ba_...1. 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: take 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 field (fr, de, en, it) is the payslip language: stored labels, PDF and sending email; if omitted, the payslip follows the account owner's language. A partial update recomputes the employee's payslips that are still drafts, for the current month and the following ones, 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.
DELETE …/employees/{employeeId} answers 204 and also deletes the employee's payslips, absences and employee portal links. It is refused (409 PAYSLIP_DECLARED) if one of their payslips is declared.
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, so 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. hoursWorked is required for an hourly-paid employee. 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>/pdfPreviews, validation, sending, correction, duplication and batch PDFs are detailed in Payroll cycle with the API.
4. Absences
Absences are company-addressed (/companies/{companyId}/absences, payroll module). 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 '{ "kind": "supplierInvoice", "vendorName": "Swisscom", "status": "toPay", "documentDate": "2026-02-28",
"currency": "CHF", "total": 129.9, "accountId": "<accountId>" }' \
$BASE/companies/$COMPANY/expenseskind, vendorName, status, documentDate, currency and total are required; the VAT breakdown (subtotal, vatRate, vatAmount) and the vendor identifiers are optional. status is toPay or paid. kind is supplierInvoice, expenseReport, vatReturn, taxAssessment, socialStatement, shareholderResolution, financingContract or other. taxAssessment and socialStatement require a documentNumber that states the type or scheme and the period.
year is derived from documentDate, and paidDate follows status: set to now when the expense is created as paid without a date, cleared as soon as the status is no longer paid. The list returns supplier invoices by default, ?kind= picks another type. 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 receipt downloads with the same 302 redirect as the PDFs; an expense without a receipt (hasDocument: false) answers 404:
curl -L -H "Authorization: Bearer $KEY" -o receipt.pdf \
$BASE/companies/$COMPANY/expenses/<expenseId>/documentTo attach it, send the file itself as the request body, without multipart (PDF, PNG, JPEG or WebP, 10 MB at most). This can only be done once: an expense that already has a receipt answers 409 DOCUMENT_ALREADY_ATTACHED.
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/pdf" \
--data-binary @receipt.pdf $BASE/companies/$COMPANY/expenses/<expenseId>/documentPOST …/expenses/bulk-status and POST …/expenses/bulk-delete process 1 to 100 ids and answer affected and skipped: an unknown id is counted in skipped, without blocking the others. If your subscription does not cover the Accounting module, also expect 403 SUPPLIER_BILL_LIMIT_REACHED once the monthly expense quota is spent and 403 CURRENCY_PRO_ONLY for a currency other than CHF.
Expense drafts
A draft is an expense awaiting review: it does not appear in GET …/expenses and only consumes the expense quota once accepted. A company keeps at most 100 pending drafts, Google Drive import included (409 EXPENSE_DRAFT_LIMIT_REACHED). Two ways to create one:
# Read the document with AI, in one call
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/pdf" \
--data-binary @receipt.pdf "$BASE/companies/$COMPANY/expense-drafts/extract?locale=en&filename=receipt.pdf"
# Without AI: upload the file, then create the draft with your own values
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/pdf" \
--data-binary @receipt.pdf $BASE/companies/$COMPANY/uploads/expense
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "kind": "supplierInvoice", "vendorName": "Swisscom", "status": "toPay", "documentDate": "2026-02-28",
"currency": "CHF", "total": 129.9, "uploadId": "<uploadId>" }' \
$BASE/companies/$COMPANY/expense-drafts…/extract answers 201 with draft, readabilityScore and quota. Each successful read consumes one unit of the monthly extraction quota (200 on Business), a failed read does not. Codes to expect: 403 EXPENSE_AI_QUOTA_REACHED, 422 SCAN_UNREADABLE (send a better scan), 429 AI_RATE_LIMITED and 502 AI_REQUEST_FAILED (retry).
…/uploads/expense returns an uploadId valid for one hour. POST …/expense-drafts consumes it, with no AI and no extraction quota, and DELETE …/uploads/expense/{uploadId} discards an unused file. An expired id answers 403 UPLOAD_EXPIRED, an id created by another user or for another company 403 FILE_ACCESS_DENIED.
For the review: GET …/expense-drafts (paginated), GET …/expense-drafts/{draftId}, GET …/expense-drafts/{draftId}/document for the file and PATCH …/expense-drafts/{draftId} to correct a field. Then accept or reject:
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "ids": ["<draftId>"] }' $BASE/companies/$COMPANY/expense-drafts/accept…/accept and …/reject take 1 to 100 ids and answer affected and skipped. An unknown id is counted in skipped, but a supplier invoice without an account makes the whole call fail with 422 CLASSIFICATION_ACCOUNT_REQUIRED: fix it first with PATCH. Rejection is final, the file goes away with the draft.
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": "January rent", "bankAccount": "CH93 0076 2011 6238 5295 7",
"bankReference": "CH9300762011623852957:2026-0001" }
] }' \
$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 a single database transaction, everything is written or nothing is. importBatchId groups the rows of one file; if omitted, it is generated and the response gives it to you. An accountId you send classifies the row, as a manual choice that the import heuristics will never overwrite.
{ "data": { "inserted": 1, "skipped": 0, "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 '{ "comment": "Check the rent", "color": "#3c8fc3" }' \
$BASE/companies/$COMPANY/bank-transactions/<transactionId>year is the calendar year of the booking date (date); a fiscal year that does not close in December overlaps two calendar years, so leave the filter out to page through the whole history. PATCH accepts the fields of the row, color (a #rrggbb tag, or "" to clear it) and accountId (null to remove the classification); DELETE answers 204.
For several rows: POST …/bank-transactions/bulk-delete, …/bulk-color and …/bulk-account take 1 to 100 ids and answer affected and skipped; …/bulk-accounts takes { id, accountId } pairs. …/matching/delete and …/matching/account act on what a filter selects within a year (year required), in slices: repeat the same body as long as done is false. …/categorize has the AI classify 1 to 100 rows and consumes the monthly prefill quota (100 on Business).
7. Annual accounts
The annual accounts are one read-only resource per company and fiscal year: fiscalYear is the closing year (the 2025/26 exercise of a company closing in June is 2026). The API serves the version the company finalised in the app, once the ledger has closed the year.
curl -H "Authorization: Bearer $KEY" \
$BASE/companies/$COMPANY/financial-statements/2026Reading returns the current version exactly as its PDF prints it: the balanceSheet and incomeStatement rows with their frozen labels and the amounts of the year and the prior year, the appropriation (SA, Sàrl) or the partnerAllocation (partnerships), along with the version number, the document language and the pdfStatus. 404 until the accounts are finalised, and again after the ledger reopens the year.
8. Trustee mandates
For a trustee, /mandates is a key-level route, with no plan or module. GET /mandates lists your mandates without pagination: the companies you own (kind: owned, id equal to the companyId), then your requests and accesses on companies managed by another account (kind: external, the id of the request, the companyId of the company). ?includeClosed=true adds archived mandates and closed requests. Creating a mandate creates a company:
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "name": "Muster AG" }' $BASE/mandatesThe 201 response returns the mandate. The body takes name (optionally with ide), ide alone if the company is already on Bill Alps, or companyId alone. A company that already belongs to another account is not duplicated: the response is 202, with a pending request, and its administrators receive an email. To expect: 402 SUBSCRIPTION_REQUIRED without an active trustee subscription, 403 FIDUCIAIRE_ONLY on an account that is not a trustee account, 409 IDE_ALREADY_USED and 422 INVALID_IDE on the UID number.
Four actions without a body complete the list, on /mandates/{mandateId}/…: archive and restore for a mandate you own, cancel to withdraw a pending request, leave to give up an accepted access.
From then on, every /companies/{companyId}/… route applies to the mandate with its companyId, employees, payslips and absences included, as long as the mandate's plan is business: 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, the payroll month in Payroll cycle with the API. Every field is detailed in your deployment's Swagger UI (…/api/docs).