Skip to content

Invoices

Writing and reading are independent permissions. A shop writes and never reads; an accountant reads and never writes. Neither implies the other.

When someone buys, you send what you charged them. It appears in their account as an expense with its lines, without them doing anything.

POST/public/v1/invoicesscope invoices:write

Record 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

NameTypeDescription
localestringLanguage used for anything Costia resolves by name, such as categories.

Body

FieldTypeDescription
externalReferencerequiredstringthe order id in the partner's own system. Required, and the idempotency key: retrying the same purchase must not produce a second expense.
invoiceNumberstringthe number printed on the document, when there is one. Kept for the customer's records; Costia never derives anything from it.
titlerequiredstringwhat 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.
issuedAtrequiredstring (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.
currencyCoderequiredstringISO 4217, three letters.
totalrequirednumberwhat the customer paid, positive. Costia stores personal expenses as negative and does the sign itself.
totalTaxnumberthe 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.
defaultTaxRatenumberthe rate that applies to any line not stating its own.
taxIncludedInPricesbooleanwhether line amounts already contain tax. When false, Costia adds the tax lines so the invoice reconciles.
notesstringanything the customer should read alongside the invoice.
linesrequiredPartnerInvoiceLine[]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

StatusMeaning
200This externalReference was already recorded. The same invoice comes back with replayed=true, so a retry after a timeout is safe.
201The invoice was recorded
400VALIDATION_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.
401UNAUTHORIZED: the bearer token is not one Costia issued to a registered client.
403ACCESS_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.

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.

The example shop does it like this, and two of its decisions are worth copying.

app/api/costia/invoice/route.js
// 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.

All of the account’s, not just the ones you wrote. That is what makes this permission useful to an accountant.

GET/public/v1/invoicesscope invoices:read

List 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

NameTypeDescription
dateFromstring (date)Earliest invoice date to include, inclusive.
dateTostring (date)Latest invoice date to include, inclusive.
categoryUuidsstring (uuid)[]Restrict to these categories. Their uuids come from GET /public/v1/categories.
pageinteger (int32)Zero-based page number.
sizeinteger (int32)Page size. Larger values are capped at 200 rather than rejected, so a tax-year walk cannot be turned into one huge call.
localestringLanguage for category and source names.

Responses

StatusMeaning
200One page of invoices
400VALIDATION_ERROR: page must be zero or greater and size must be at least one.
401UNAUTHORIZED
403ACCESS_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.

GET/public/v1/invoices/{uuid}scope invoices:read

Get one invoice

The full invoice including its lines and tax breakdown, which is what a return needs and the list does not carry.

Parameters

NameTypeDescription
uuidrequiredstring (uuid)The invoice uuid, as returned when it was recorded.
localestringLanguage for category and source names.

Responses

StatusMeaning
200The invoice
400VALIDATION_ERROR: uuid is not a valid UUID.
401UNAUTHORIZED
403ACCESS_DENIED: the token lacks invoices:read.
404RESOURCE_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.