Invoices
Writing and reading are independent permissions. A shop writes and never reads; an accountant reads and never writes. Neither implies the other.
Writing an invoice
Section titled “Writing an invoice”When someone buys, you send what you charged them. It appears in their account as an expense with its lines, without them doing anything.
invoices:writeRecord an invoice
Idempotent on externalReference: retrying the same purchase returns the same invoice rather than creating a second. The merchant is taken from the calling client's partner identity and cannot be set in the body. Amounts are sent positive; Costia stores personal expenses negative and does the sign itself.
Parameters
| Name | Type | Description |
|---|---|---|
locale | string | Language used for anything Costia resolves by name, such as categories. |
Body
| Field | Type | Description |
|---|---|---|
externalReferencerequired | string | the order id in the partner's own system. Required, and the idempotency key: retrying the same purchase must not produce a second expense. |
invoiceNumber | string | the number printed on the document, when there is one. Kept for the customer's records; Costia never derives anything from it. |
titlerequired | string | what the customer sees as the name of the expense. The merchant is separate and comes from the partner, so this is free to describe the purchase rather than the shop. |
issuedAtrequired | string (date) | the date on the document, not the date it was sent. An invoice recorded late still belongs to the day it was issued, which is what a tax return goes by. |
currencyCoderequired | string | ISO 4217, three letters. |
totalrequired | number | what the customer paid, positive. Costia stores personal expenses as negative and does the sign itself. |
totalTax | number | the tax total as printed. Optional: when absent Costia adds up the lines, and when present it is kept as sent, because a document that rounds differently is still the document the customer holds. |
defaultTaxRate | number | the rate that applies to any line not stating its own. |
taxIncludedInPrices | boolean | whether line amounts already contain tax. When false, Costia adds the tax lines so the invoice reconciles. |
notes | string | anything the customer should read alongside the invoice. |
linesrequired | PartnerInvoiceLine[] | at least one. An invoice with no lines is a total with nothing to explain it, which is what the customer opened the expense to see. |
Responses
| Status | Meaning |
|---|---|
200 | This externalReference was already recorded. The same invoice comes back with replayed=true, so a retry after a timeout is safe. |
201 | The invoice was recorded |
400 | VALIDATION_ERROR when the body breaks a rule, with fieldErrors naming each one; UNKNOWN_LINE_TYPE or UNKNOWN_QUANTITY_UNIT when a line carries a value outside the accepted set. |
401 | UNAUTHORIZED: the bearer token is not one Costia issued to a registered client. |
403 | ACCESS_DENIED when the token lacks invoices:write; NOT_A_PARTNER_CLIENT when the client has no partner identity; PARTNER_CANNOT_WRITE when the partner has no verified domain; PARTNER_DISABLED when it has been disabled. |
Three things worth understanding first
Section titled “Three things worth understanding first”externalReference is your idempotency key. Send the order’s identifier in your own
system. If you retry after a timeout, Costia returns the same invoice with
replayed: true instead of creating a second one. That is why retrying is safe, and why
it must not be a fresh value on every attempt.
The merchant comes from your partner, not from the body. There is no field for it. Your verified brand is what appears, and it is what makes the word “verified” mean something to whoever reads it.
Amounts are positive. Costia stores personal expenses as negative and handles the sign itself.
Ambrosia’s path
Section titled “Ambrosia’s path”The example shop does it like this, and two of its decisions are worth copying.
// The browser sends only the order identifier.const { orderUuid } = await request.json();
// The order is re-read from the shop's backend, not taken from the cart the// page sent.const order = await fetch(`${backend}/front/orders/${orderUuid}`, { headers });
// The token is obtained on the server, and refreshed only if needed.const { accessToken } = await auth.api.getAccessToken({ body: { providerId: 'costia' }, headers: request.headers,});
await fetch('https://api.costia.app/public/v1/invoices', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` }, body: JSON.stringify(invoice),});The order is re-read on the server. If you accepted the basket the browser sends, anyone could write whatever invoice they liked — into their own account, so it is not a breach, but it would turn every mirrored expense into fiction. And the whole point of the shop pushing its data is that it is its data.
The token never reaches the browser. A token with invoices:write is precisely what
does not belong in a page.
Reading invoices
Section titled “Reading invoices”All of the account’s, not just the ones you wrote. That is what makes this permission useful to an accountant.
invoices:readList the user's invoices
Every invoice on the account, not only the ones this client wrote. Ordered by date descending with a stable tiebreak, so paging through a whole tax year cannot repeat or skip a row.
Parameters
| Name | Type | Description |
|---|---|---|
dateFrom | string (date) | Earliest invoice date to include, inclusive. |
dateTo | string (date) | Latest invoice date to include, inclusive. |
categoryUuids | string (uuid)[] | Restrict to these categories. Their uuids come from GET /public/v1/categories. |
page | integer (int32) | Zero-based page number. |
size | integer (int32) | Page size. Larger values are capped at 200 rather than rejected, so a tax-year walk cannot be turned into one huge call. |
locale | string | Language for category and source names. |
Responses
| Status | Meaning |
|---|---|
200 | One page of invoices |
400 | VALIDATION_ERROR: page must be zero or greater and size must be at least one. |
401 | UNAUTHORIZED |
403 | ACCESS_DENIED: the token lacks invoices:read. |
Ordering is by date descending with a stable tie-break, so paging through a whole tax year neither repeats nor skips rows even if new invoices arrive while you page.
invoices:readGet one invoice
The full invoice including its lines and tax breakdown, which is what a return needs and the list does not carry.
Parameters
| Name | Type | Description |
|---|---|---|
uuidrequired | string (uuid) | The invoice uuid, as returned when it was recorded. |
locale | string | Language for category and source names. |
Responses
| Status | Meaning |
|---|---|
200 | The invoice |
400 | VALIDATION_ERROR: uuid is not a valid UUID. |
401 | UNAUTHORIZED |
403 | ACCESS_DENIED: the token lacks invoices:read. |
404 | RESOURCE_NOT_FOUND. An invoice belonging to another account is this and not a 403 — saying it exists would already say something about that account. |
The full invoice carries its lines and the breakdown by tax rate, which is what a tax return needs and what the list does not include.
