Back to guides

Invoicing with the API: from client to sent invoice

The whole invoicing path in REST calls: client, template, invoice, finalisation, sending, PDF, then quotes and recurring invoices.

Developers12 min read
Table of contents+

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" },
        "details": { "kind": "company", "contactPersons": [] } }' \
  $BASE/companies/$COMPANY/clients

The 201 response holds the client with its id. name, address and details are required: details.kind is company (with contactPersons, a list that may be empty, and optionally ideNumber and vatNumber) or individual. A client belongs to one company: using another company's client on an invoice answers 403 ACCESS_DENIED. On Business, clients are unlimited; 403 CLIENT_LIMIT_REACHED only appears if your subscription does not cover the Invoicing module.

Import clients in a batch

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "rows": [
        { "name": "Muster AG", "email": "billing@muster.example",
          "address": { "kind": "swiss", "country": "CH", "city": "Fribourg" },
          "details": { "kind": "company", "contactPersons": [] } },
        { "name": "Anna Beispiel",
          "address": { "kind": "foreign", "country": "FR", "city": "Annemasse" },
          "details": { "kind": "individual" } }
      ] }' \
  $BASE/companies/$COMPANY/clients/batch

The batch takes 1 to 200 clients and is atomic: everything is written or nothing is, and a batch that would exceed the client quota is refused as a whole (403 CLIENT_LIMIT_REACHED). A row whose email already exists in the company, or appears earlier in the batch, is skipped without an error; a row with no email is never treated as a duplicate. The 201 response gives both counters, inserted and skippedDuplicates. Reading the file (CSV, spreadsheet) stays on your side.

3. Pick a template

curl -H "Authorization: Bearer $KEY" $BASE/companies/$COMPANY/invoice-templates

The 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. In the JSON, hasLogo is read-only: the logo goes through its own route, as raw bytes (PNG or JPEG, 2 MB at most).

curl -X PUT -H "Authorization: Bearer $KEY" -H "Content-Type: image/png" \
  --data-binary @logo.png $BASE/companies/$COMPANY/invoice-templates/<templateId>/logo

PUT /companies/{companyId}/logo does the same for the company logo (owner or admin role required), which a template's logo replaces on its documents. DELETE on either route removes the logo.

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/invoices

clientId, 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).

On Business, invoices are unlimited and currencies other than CHF are available. 403 INVOICE_LIMIT_REACHED and 403 CURRENCY_PRO_ONLY only appear if your subscription does not cover the Invoicing module: the Starter plan limits then apply.

Reuse the product catalog

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "name": "Design consulting", "unitPrice": 150, "currency": "CHF", "vatRate": 8.1,
        "unit": "h", "reference": "DES-01" }' \
  $BASE/companies/$COMPANY/products

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "name": "Launch pack", "items": [{ "productId": "<productId>", "quantity": 2 }] }' \
  $BASE/companies/$COMPANY/product-lists

A product requires name, unitPrice, currency and vatRate; description, unit and reference are optional. A list keeps its products in array order (500 at most, each one only once, otherwise 422 PRODUCT_LIST_DUPLICATE_PRODUCT). Both resources can be listed with pagination, read, changed with PATCH (items replaces the whole list) and deleted; a deleted product disappears from the lists that contained it.

The API does not expand a list into invoice lines: always send description, quantity, unitPrice and vatRate on each line, and add productId to keep the link with the product. If the catalog is disabled on the company, these routes answer 403 PRODUCT_CATALOG_DISABLED: see the catalog guide to enable it.

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>/send

The 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 by email goes through POST …/send: kind: "invoice" only from ready (409 BILL_EMAIL_INVALID_STATUS otherwise), kind: "reminder" only from overdue (409 BILL_REMINDER_INVALID_STATUS otherwise). The PDF is attached and the email goes out in the invoice language; a client without an email address answers 422 CLIENT_EMAIL_MISSING. The SEND event of …/transition exists too, but it only marks a ready invoice as sent, without any email.

Cancel a payment

curl -X POST -H "Authorization: Bearer $KEY" \
  $BASE/companies/$COMPANY/invoices/<invoiceId>/cancel-payment

No body; the response is 204: the payment date is cleared and the invoice goes back to its previous status (sent or overdue). An invoice that is not paid answers 409 BILL_NOT_PAID. A payment recorded by bank reconciliation answers 409 BILL_PAYMENT_RECONCILED: to cancel it, undo the reconciliation in the app.

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.pdf

Both routes answer 302 to a short-lived URL (15 minutes): always follow the redirect with -L and never cache the Location. Files are generated on demand, the first call after a change renders the document. 503 PDF_UNAVAILABLE means rendering failed: retry. An invoice imported from other software is never rendered: …/pdf serves its original file, which may be an image, so check the Content-Type.

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"

A paid invoice refuses any change of amount (409 BILL_PAID_LOCKED), and an imported invoice any change to what is printed (409 BILL_IMPORTED_LOCKED).

Save everything in one go

curl -X PUT -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "expectedVersion": 1772323200000,
        "header": { "clientId": "<clientId>", "lang": "fr", "currency": "CHF",
                    "issueDate": "2026-03-01", "dueDate": "2026-04-30" },
        "items": [
          { "id": "<itemId>", "description": "Design", "quantity": 3, "unitPrice": 150, "vatRate": 8.1 },
          { "description": "Support", "quantity": 1, "unitPrice": 90, "vatRate": 8.1 }
        ],
        "modificationReason": "Scope extended" }' \
  $BASE/companies/$COMPANY/invoices/<invoiceId>

PUT saves the header and all the lines in a single transaction, and returns the whole invoice. expectedVersion is the updatedAt of your last read: if the invoice changed in the meantime, the response is 409 BILL_VERSION_CONFLICT and nothing is written. header carries the required fields of the creation; without discount or shippingCosts, the stored values stay, send 0 to clear them.

items is the complete list, in order: a line that is missing is deleted. An id that does not belong to the invoice, or that is repeated, answers 422 BILL_ITEM_NOT_FOUND. Line ids change when the content of the invoice changes: read them again from the response, like the new updatedAt. On an invoice that has left draft, modificationReason is required as soon as something that is printed changes.

8. Quotes and recurring invoices

Quotes (/companies/{companyId}/quotes) have the shape of invoices, with validUntil instead of dueDate and one extra rule: a quote can only be changed in draft or expired. A sent quote answers 409 QUOTE_NOT_EDITABLE; send it back to draft with REVERT_TO_DRAFT or duplicate it. No quota applies to quotes, only the conversion consumes an invoice from the quota:

curl -X POST -H "Authorization: Bearer $KEY" $BASE/companies/$COMPANY/quotes/<quoteId>/convert

The 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).

To send a quote to the client, POST …/send takes no body and answers 204:

curl -X POST -H "Authorization: Bearer $KEY" $BASE/companies/$COMPANY/quotes/<quoteId>/send

The PDF goes to the client's address, in the language of the quote, then the quote moves to sent. Sending is only possible from draft (409 QUOTE_EMAIL_INVALID_STATUS), before the validity date (422 VALID_UNTIL_IN_PAST) and with a client email address (422 CLIENT_EMAIL_MISSING). 502 QUOTE_PDF_GENERATION_FAILED and 502 QUOTE_EMAIL_SEND_FAILED mean that nothing was sent: retry.

From sent or expired, POST …/transition accepts MARK_ACCEPTED (with an optional acceptedAt) and MARK_DECLINED. A sent quote moves to expired by itself on its validity date; a PATCH that pushes validUntil into the future reactivates it.

PUT …/quotes/{quoteId} also saves the header and the lines in one go, with the same rules on expectedVersion (409 QUOTE_VERSION_CONFLICT) and on items (422 QUOTE_ITEM_NOT_FOUND). Two differences: header may contain only the fields to change, and there is no modificationReason.

curl -X PUT -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "expectedVersion": 1772323200000,
        "header": { "validUntil": "2026-06-30" },
        "items": [{ "id": "<itemId>", "description": "Design", "quantity": 3, "unitPrice": 150, "vatRate": 8.1 }] }' \
  $BASE/companies/$COMPANY/quotes/<quoteId>

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>/items

9. Delete invoices and quotes

DELETE …/invoices/{invoiceId} and DELETE …/quotes/{quoteId} delete one document and answer 204. For several documents, send 1 to 100 ids:

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "ids": ["<invoiceId1>", "<invoiceId2>"] }' \
  $BASE/companies/$COMPANY/invoices/bulk-delete

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "ids": ["<quoteId1>", "<quoteId2>"] }' \
  $BASE/companies/$COMPANY/quotes/bulk-delete

The response gives affected and skipped, which counts repeated ids. Deleting the invoice created from a quote makes that quote convertible again.

For 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).