This guide redoes, in curl, what the invoicing screen does with the mouse. Every call uses the same key and base URL as Getting started with the API.
BASE=https://<deployment>.convex.site/api/v1
KEY=ba_...1. Prerequisites
From GET /me, take the id of a company whose modules.invoicing is on, and keep it handy:
COMPANY=<companyId>2. Create the client
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "name": "Muster AG", "email": "billing@muster.example",
"address": { "kind": "swiss", "country": "CH", "street": "Rue du Lac",
"streetNumber": "12", "postalCode": "1700", "city": "Fribourg" } }' \
$BASE/companies/$COMPANY/clientsThe 201 response holds the client with its id. A client belongs to one company: using another company's client on an invoice answers 403 ACCESS_DENIED. The plan's client quota applies (403 CLIENT_LIMIT_REACHED).
3. Pick a template
curl -H "Authorization: Bearer $KEY" $BASE/companies/$COMPANY/invoice-templatesThe template list is unpaginated and takes no parameter. The one with isDefault: true is the template an invoice falls back to when you say nothing: templateId is optional on create. The logo is managed in the app, hasLogo is read-only.
4. Create the invoice
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "clientId": "<clientId>", "lang": "fr", "currency": "CHF",
"issueDate": "2026-03-01", "dueDate": "2026-03-31",
"items": [{ "description": "Design", "quantity": 2, "unitPrice": 150, "vatRate": 8.1 }] }' \
$BASE/companies/$COMPANY/invoicesclientId, lang, issueDate, dueDate and currency are required; templateId, discount, shippingCosts, notes, paymentTerms, showQrBill and items are optional. The number comes from the company counter and the invoice starts as a draft (draft).
Creation counts against the monthly invoice quota (403 INVOICE_LIMIT_REACHED), and a currency other than CHF needs a paid plan (403 CURRENCY_PRO_ONLY).
5. Finalise and send
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "event": "FINALIZE" }' $BASE/companies/$COMPANY/invoices/<invoiceId>/transition
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "kind": "invoice" }' $BASE/companies/$COMPANY/invoices/<invoiceId>/sendThe lifecycle is the app's: draftready (FINALIZE)sentpaid (MARK_PAID, with an optional paidDate). CANCEL cancels from draft, ready, sent or overdue; REOPEN brings a cancelled invoice back to draft. Any other pair answers 409 INVALID_TRANSITION, with the refused pair in message.
Sending is not a transition but POST …/send: kind: "invoice" only from ready (409 BILL_EMAIL_INVALID_STATUS otherwise), kind: "reminder" only from overdue. The PDF is attached and the mail goes out in the invoice language; a client without an email address answers 422 CLIENT_EMAIL_MISSING.
6. Download the PDF
curl -L -H "Authorization: Bearer $KEY" \
$BASE/companies/$COMPANY/invoices/<invoiceId>/pdf -o invoice.pdf
curl -L -H "Authorization: Bearer $KEY" \
$BASE/companies/$COMPANY/invoices/<invoiceId>/qr-bill-pdf -o qr-bill.pdfBoth routes answer 302 to a short-lived URL (15 minutes): always follow the redirect with -L and never cache the Location. Files are generated lazily, the first call after a change renders the document. 503 PDF_UNAVAILABLE means rendering failed: retry.
7. Change an issued invoice
Once it has left draft, an invoice only changes with a reason: every header or line change must carry a non-empty modificationReason, recorded in the invoice's log. Without it the answer is 422 MODIFICATION_REASON_REQUIRED and nothing is written.
curl -X PATCH -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "dueDate": "2026-04-30", "modificationReason": "Client asked for a longer term" }' \
$BASE/companies/$COMPANY/invoices/<invoiceId>Lines have their own routes (POST …/items, PATCH …/items/{itemId}, DELETE …/items/{itemId}), all returning the whole invoice with its recomputed totals. Since a DELETE has no body, the reason travels as a query parameter:
curl -X DELETE -H "Authorization: Bearer $KEY" \
"$BASE/companies/$COMPANY/invoices/<invoiceId>/items/<itemId>?modificationReason=Line%20billed%20twice"8. Quotes and recurring invoices
Quotes (/companies/{companyId}/quotes) have the shape of invoices, with one extra rule: only a draft can change. A sent quote answers 409 QUOTE_NOT_EDITABLE; send it back to draft with REVERT_TO_DRAFT or duplicate it. Quotes are unlimited on every plan, only the conversion consumes an invoice from the quota:
curl -X POST -H "Authorization: Bearer $KEY" $BASE/companies/$COMPANY/quotes/<quoteId>/convertThe conversion creates a draft invoice and answers 201 with invoiceId and invoiceNumber. A quote converts once (409 QUOTE_ALREADY_CONVERTED), and a declined or expired quote does not convert (409 QUOTE_NOT_CONVERTIBLE).
A recurring invoice (/companies/{companyId}/recurring-invoices) is not a document but a header, lines and a clock: startDate seeds the next run, endDate or maxOccurrences stop it, and with autoSend each generated invoice is finalised and sent to the client. There is no per-line route, PUT …/items replaces the whole set:
curl -X PUT -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "items": [
{ "description": "Abonnement", "quantity": 1, "unitPrice": 180, "vatRate": 8.1 },
{ "description": "Support", "quantity": 2, "unitPrice": 90, "vatRate": 8.1 }
] }' \
$BASE/companies/$COMPANY/recurring-invoices/<scheduleId>/itemsFor payroll and accounting, continue with Payroll, expenses and accounting with the API; for pagination, dates and error codes, read Conventions and errors. Every field is detailed in your deployment's Swagger UI (…/api/docs).