Back to guides

Bill Alps API: conventions and errors

What holds on every route: envelope, pagination, dates, strict bodies, errors, usage, and what a sandbox reset does to your keys.

Developers9 min read
Table of contents+

The other guides walk through flows; this one gathers the rules that apply to every call, to keep at hand while you write your client.

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

1. The data envelope

Every successful response wraps its payload in data:

{ "data": [ ... ] }

Two bodiless exceptions: 204 No Content on deletions and on a few actions (PATCH …/payslips/{payslipId}, POST …/payslips/{payslipId}/send, POST …/quotes/{quoteId}/send, POST …/invoices/{invoiceId}/cancel-payment), and 302 on PDF, receipt and logo downloads, to follow with -L.

2. Pagination

Lists of company resources are paginated, newest first (employees follow their creation order):

{ "data": [ ... ], "nextCursor": "..." }

A few lists are unpaginated and answer a plain array under data: GET /companies, GET /mandates, GET /companies/{companyId}/invoice-templates, GET …/payroll/holiday-variants and GET …/employees/{employeeId}/payslips-to-update. POST …/payslips/pdf-batch is paginated too, but its cursor travels in the body, as cursor.

3. Dates and ids

Dates are accepted as epoch milliseconds or YYYY-MM-DD, and returned as epoch milliseconds. The from/to filters bound the issue date, both ends included. Two exceptions: absences and an employee's dates (startDate, endDate, dateOfBirth) are YYYY-MM-DD strings, in requests as well as in responses.

Ids are opaque strings. In the path, an unknown id, a malformed id and an id from another company all answer 404 NOT_FOUND: the three cases are deliberately indistinguishable, so that no id from another account leaks. In the body, a reference to another company gets a different answer depending on the route: 403 ACCESS_DENIED on an invoice or a quote, 422 CLIENT_NOT_FOUND or 422 TEMPLATE_NOT_FOUND on a recurring invoice, 422 EMPLOYEE_NOT_FOUND on an absence.

4. Strict bodies

  • JSON bodies are strict: any unknown key answers 400 VALIDATION_ERROR. On employee creation and update and on payslip creation, the rejection comes from the server-side check and names the field in message, without issues. The bodies of …/laa-periods and …/avs-choices, on the other hand, ignore unknown keys.
  • A body is always required on POST, PUT and PATCH ({} is a valid empty patch; an empty body is INVALID_JSON), except on bodiless actions such as POST …/default, …/convert, …/cancel-payment or …/payslips/{payslipId}/send; DELETE takes none. Note that POST …/payslips/{payslipId}/validate expects at least {}.
  • Over 1 MB, the body is refused: 413 PAYLOAD_TOO_LARGE.
  • Seven routes take raw bytes rather than JSON. POST …/expenses/{expenseId}/document, POST …/expense-drafts/extract, POST …/uploads/expense and POST …/uploads/invoice-import: PDF, PNG, JPEG or WebP, 10 MB. POST …/uploads/invoice-source: PNG, JPEG or WebP, 8 MB. PUT …/logo on a company and on an invoice template: PNG or JPEG only, 2 MB. Content-Type names the format and the body is the file; everything else travels in the query string. Any other format answers 415 UNSUPPORTED_MEDIA_TYPE, an empty body 400 EMPTY_BODY.
  • An upload to …/uploads/… returns an uploadId valid for one hour. Only POST …/expense-drafts consumes one today: no public route accepts the ones from invoice-source and invoice-import.
  • An unknown query key is a 400 VALIDATION_ERROR on every route that declares a query contract; the others, such as GET /me and GET /companies, ignore the rest, without that being a promise.
  • PUT on an absence is a full replacement: omitted optional fields are cleared. PUT on an invoice or a quote replaces all the lines and requires expectedVersion.

A validation failure lists every offending field under issues, as dotted paths, array indexes included:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid request body",
    "issues": [
      { "path": "subtotal", "message": "Unrecognized key \"subtotal\"" },
      { "path": "vatRates.0.rate", "message": "Too big: expected number to be <=100" }
    ]
  }
}

5. Errors

Errors carry a stable, machine-readable code:

{ "error": { "code": "PAYSLIP_ALREADY_EXISTS" } }

VALIDATION_ERROR adds issues, or a message for the fields checked server-side. INVALID_TRANSITION, BATCH_SIZE_INVALID and UNSUPPORTED_MEDIA_TYPE also add a message, and PAYROLL_CUMULATIVE_INCOMPLETE adds details.months; every other code answers alone. Switch on error.code, never on prose. This envelope holds on every path under /api/v1/, including an unknown path (404 NOT_FOUND) and an unsupported method (405 METHOD_NOT_ALLOWED, with an Allow header).

Checks run in this order: path and method (404, 405), body size (413), key and owner (401), company visibility (404), then archived companyplanmodulepermission (403). Body and query validation comes next (400), then the business rules. A company the key holder is not a member of is indistinguishable from a non-existent one.

  • 400 INVALID_JSON, 400 VALIDATION_ERROR: missing or unreadable body, invalid field. 400 BATCH_SIZE_INVALID: payslips/batch or bank-transactions/batch outside 1 to 100 items; the other batches are bounded by their schema and answer VALIDATION_ERROR.
  • 401 INVALID_API_KEY: missing, malformed, unknown, revoked or expired key; owner banned or deleted.
  • 402 SUBSCRIPTION_REQUIRED: creating a mandate without an active trustee subscription.
  • 403 PLAN_NOT_ALLOWED, 403 COMPANY_ARCHIVED, 403 ACCESS_DENIED, 403 ACCOUNT_DISABLED: plan, archiving, missing permission or role, disabled account.
  • 403 INVOICING_MODULE_DISABLED, 403 PAYROLL_MODULE_DISABLED, 403 ACCOUNTING_MODULE_DISABLED, 403 PRODUCT_CATALOG_DISABLED: module or catalog disabled on the company. 403 LEDGER_DISABLED, 403 FINANCIAL_STATEMENTS_REPORTING_DISABLED: feature closed on the deployment.
  • 403 *_LIMIT_REACHED: plan quota reached (clients, products, product lists, invoices, recurring invoices, employees, payslips, expenses). 403 EXPENSE_AI_QUOTA_REACHED, 403 AI_QUOTA_REACHED: AI quotas. 403 CURRENCY_PRO_ONLY: currency other than CHF under the Starter limits.
  • 403 FILE_ACCESS_DENIED, 403 UPLOAD_EXPIRED: uploadId from another user, already used or expired.
  • 409 INVALID_TRANSITION, 409 PAYSLIP_ALREADY_EXISTS, 409 PAYSLIP_DECLARED, 409 ABSENCE_OVERLAPS_EXISTING, 409 QUOTE_NOT_EDITABLE, 409 BILL_PAID_LOCKED, 409 BILL_NOT_PAID, 409 DOCUMENT_ALREADY_ATTACHED, 409 EXPENSE_DRAFT_LIMIT_REACHED: conflict with the current state. 409 BILL_VERSION_CONFLICT, 409 QUOTE_VERSION_CONFLICT: the document has changed since you read it.
  • 422 MODIFICATION_REASON_REQUIRED, 422 REASON_REQUIRED, 422 CLIENT_EMAIL_MISSING, 422 EMPLOYEE_EMAIL_MISSING, 422 CLASSIFICATION_ACCOUNT_REQUIRED, 422 VALID_UNTIL_IN_PAST, 422 SCAN_UNREADABLE: valid request, but inadmissible in this context.
  • 429 AI_RATE_LIMITED: the AI model limits calls, retry. 429 MANDATE_REQUEST_LIMIT_REACHED: too many mandate requests.
  • 503 PDF_UNAVAILABLE, 502 EMAIL_SEND_FAILED, 502 AI_REQUEST_FAILED, 500 INTERNAL_ERROR: retry. One exception only: after 502 QUOTE_EMAIL_SENT_TRANSITION_FAILED, the quote has already gone out, do not send it again.

Your deployment's Swagger UI and openapi.json publish the declared codes, route by route. Other business codes remain possible: always provide a default case on error.code.

6. Limits and usage

There is no rate limiting yet. Calls are counted, not throttled: one counter per key, endpoint, target company and UTC day, visible in My accountAPI keysUsage. Requests rejected by authentication are not counted; a 400 on an authorised call is.

Be a good neighbour anyway: page through lists rather than polling them in a loop, and back off on 500 or 503. Batches do not all behave alike. POST …/payslips/batch is not atomic and returns a result per item. POST …/bank-transactions/batch, POST …/clients/batch and POST …/{invoices,quotes,payslips}/bulk-delete are atomic, all or nothing. The bulk-* routes for expenses and bank transactions, and …/expense-drafts/accept and …/reject, process what they can and count the rest in skipped.

7. Sandbox and reset

The sandbox never writes to production. Emails, Stripe and team invitations are inert there, sign-up is closed, logos are not copied. Everything else runs, including the scheduled jobs: recurring invoices are generated there and payslips are computed on the copied data. The general ledger is open there, unlike in production, where its routes answer 403 LEDGER_DISABLED.

Back to the flows: Getting started with the API, Invoicing with the API, Payroll, expenses and accounting with the API, Payroll cycle with the API. Every field and every response is in your deployment's Swagger UI (…/api/docs).