New to this API? Read this section end to end — it is short, and it covers the four things every integration needs. Everything after it is reference material you can come back to.
Three concepts, and the relationship between them is where most early confusion comes from:
The point people most often get wrong: a batch is not tied to a worker.
A single batch routinely spans many workers, and BatchInput has no worker
field at all — it rejects unknown properties, so sending one is a validation
error rather than something that silently does nothing. The worker link
lives on each line, as worker.workerId.
Every request takes your API key as a bearer token:
Authorization: Bearer sk_live_…
Keys are environment-specific: sk_test_… for the sandbox, sk_live_… for
production. A key also carries the market (Finland or Sweden) it bills in —
you do not choose that per batch or per line.
Use your sk_test_… key for everything in this guide — see "Base URL"
below for why. If you get a 401 or 403 you cannot explain, tell us
rather than debugging it: a key can be provisioned without v2 access or
without the right scopes, and both failures look like a bad key from your
side.
Build against the sandbox. v2 is in dev testing and is not open for
production traffic yet, so a sk_live_… key does not currently reach it —
production keys are issued for v1. Talk to us when you are ready to go
live and we will sort the key out then.
sandbox https://api-dev.truster.com/api/v2
production https://api.truster.com/api/v2 (not yet open for v2)
Every path in this guide is relative to that base, so POST /batches means
POST https://api-dev.truster.com/api/v2/batches.
One navigation note that will save you time. In the generated API reference,
the Lines tag contains only the two read endpoints (GET /lines,
GET /lines/{externalRowId}). Everything that writes a line is grouped
under the Batches tag, because those paths are nested under a batch:
POST /batches/{batchId}/lines add lines
PUT /batches/{batchId}/lines/{externalRowId} edit a line
DELETE /batches/{batchId}/lines/{externalRowId} remove a line
POST /batches/{batchId}/lines/move move lines between batches
If you are generating a client from our OpenAPI file, note that LineInput
is a oneOf across the four line types. Some generators flatten that into a
type with no properties — if your generated line model looks empty, that is
why, and the worked examples below give you the real shapes.
1. Create the worker. Do this once per person. workerId is your
identifier — we resolve every line by it.
POST /workers
Authorization: Bearer sk_test_…
Content-Type: application/json
{
"workerId": "worker-1042",
"firstName": "Aino",
"lastName": "Virtanen",
"ssn": "DDMMYY-NNNC",
"email": "aino.virtanen@example.invalid",
"phone": "+358401234567"
}
ssn is the worker's national identity number in its own national format —
a Finnish HETU or a Swedish personnummer; the placeholder above is not a
real one. phone must be E.164 (+, country code, number): a local format
like 040 123 4567 is rejected with 400 invalid-phone. There is a second
way to establish a worker, using a shareCode instead of identity fields —
see "Batches, lines, and invoices" for when to use it.
Reference a workerId we do not know and the row is quarantined with
unresolved-worker — the rest of the batch still proceeds.
2. Open a batch.
POST /batches
{ "batchId": "2026-09-payroll-01", "currency": "EUR" }
batchId is yours too. Reusing one is how you retry safely — see "Retries
and idempotency".
3. Add lines. Each line carries its own worker and its own money.
POST /batches/2026-09-payroll-01/lines
{
"lines": [
{
"type": "work",
"externalRowId": "row-00417",
"worker": { "workerId": "worker-1042" },
"description": "Site survey, Espoo",
"workStartDate": "2026-09-01",
"workEndDate": "2026-09-01",
"money": {
"amount": 480.00,
"quantity": 6,
"unitPrice": 80.00,
"vatTreatment": "standard"
}
}
]
}
Two rules about money that are easy to miss:
amount is the row's net total, and we never derive it from
quantity × unitPrice. You always send it.quantity is anything other than 1, you must also send unitPrice,
and quantity × unitPrice must equal amount (tolerance 0.005). We check
that arithmetic before normalising, so a mismatch is rejected rather
than silently rounded.externalRowId is your idempotency key for the row — reuse it to update,
and it is what every read and every error refers back to.
4. Submit.
POST /batches/2026-09-payroll-01/submit
Submission is the point of no return: a submitted batch is locked. Rows that passed validation invoice; rows that were quarantined wait for you to fix them. See "Per-row outcome model" for how to tell the difference, and "Quarantine remediation" for the fix loop.
5. Read your rows back. GET /lines returns what we stored and how it
resolved, keyed by your own externalRowId.
type |
What it is | Money field |
|---|---|---|
work |
Hours or a fixed fee | money |
compensation |
Non-work pay (bonus, holiday pay) | money |
allowance |
Per-diem, meal, or mileage — statutory rates | money (optional override) |
purchase |
Reimbursed expense, receipt-backed | items[] |
Allowances are priced from statutory registries rather than by you, which is why their money field is an override rather than a requirement. Read "Allowance lines" before sending any.
Your company is enabled for a specific set of line types, configured by
Truster. By default that is work and compensation only — the allowance
kinds (perDiem, mealAllowance, mileage) and purchase are enabled
per company, because they are tax-free reimbursements with their own
compliance rules. A row of a type you are not enabled for is rejected on
its own ack as line-type-not-allowed and never stored; the rest of the
batch is unaffected.
Check what you are enabled for with GET /settings (scope
settings:read): it returns your effective allowedLineTypes, plus your
market, mock-mode state, rate limit, and the scopes of the key you called
with. If you need a type enabled, ask Truster. Never re-send a denied
allowance or purchase as a work or compensation row — that pays a
tax-free reimbursement out as taxable pay, withheld against and reported
to the income register under the wrong income type.
standard or construction-reverse, never market-prefixed. The same key
means "your market's version of this".One navigation note that will save you time. In the generated API reference,
the Lines tag contains only the two read endpoints (GET /lines,
GET /lines/{externalRowId}). Everything that writes a line is grouped
under the Batches tag, because those paths are nested under a batch:
POST /batches/{batchId}/lines add lines
PUT /batches/{batchId}/lines/{externalRowId} edit a line
DELETE /batches/{batchId}/lines/{externalRowId} remove a line
POST /batches/{batchId}/lines/move move lines between batches
If you are generating a client from our OpenAPI file, note that LineInput
is a oneOf across the four line types. Some generators flatten that into a
type with no properties — if your generated line model looks empty, that is
why, and the worked examples below give you the real shapes.
1. Create the worker. Do this once per person. workerId is your
identifier — we resolve every line by it.
POST /workers
Authorization: Bearer sk_test_…
Content-Type: application/json
{
"workerId": "worker-1042",
"firstName": "Aino",
"lastName": "Virtanen",
"ssn": "DDMMYY-NNNC",
"email": "aino.virtanen@example.invalid",
"phone": "+358401234567"
}
ssn is the worker's national identity number in its own national format —
a Finnish HETU or a Swedish personnummer; the placeholder above is not a
real one. phone must be E.164 (+, country code, number): a local format
like 040 123 4567 is rejected with 400 invalid-phone. There is a second
way to establish a worker, using a shareCode instead of identity fields —
see "Batches, lines, and invoices" for when to use it.
Reference a workerId we do not know and the row is quarantined with
unresolved-worker — the rest of the batch still proceeds.
2. Open a batch.
POST /batches
{ "batchId": "2026-09-payroll-01", "currency": "EUR" }
batchId is yours too. Reusing one is how you retry safely — see "Retries
and idempotency".
3. Add lines. Each line carries its own worker and its own money.
POST /batches/2026-09-payroll-01/lines
{
"lines": [
{
"type": "work",
"externalRowId": "row-00417",
"worker": { "workerId": "worker-1042" },
"description": "Site survey, Espoo",
"workStartDate": "2026-09-01",
"workEndDate": "2026-09-01",
"money": {
"amount": 480.00,
"quantity": 6,
"unitPrice": 80.00,
"vatTreatment": "standard"
}
}
]
}
Two rules about money that are easy to miss:
amount is the row's net total, and we never derive it from
quantity × unitPrice. You always send it.quantity is anything other than 1, you must also send unitPrice,
and quantity × unitPrice must equal amount (tolerance 0.005). We check
that arithmetic before normalising, so a mismatch is rejected rather
than silently rounded.externalRowId is your idempotency key for the row — reuse it to update,
and it is what every read and every error refers back to.
4. Submit.
POST /batches/2026-09-payroll-01/submit
Submission is the point of no return: a submitted batch is locked. Rows that passed validation invoice; rows that were quarantined wait for you to fix them. See "Per-row outcome model" for how to tell the difference, and "Quarantine remediation" for the fix loop.
5. Read your rows back. GET /lines returns what we stored and how it
resolved, keyed by your own externalRowId.
type |
What it is | Money field |
|---|---|---|
work |
Hours or a fixed fee | money |
compensation |
Non-work pay (bonus, holiday pay) | money |
allowance |
Per-diem, meal, or mileage — statutory rates | money (optional override) |
purchase |
Reimbursed expense, receipt-backed | items[] |
Allowances are priced from statutory registries rather than by you, which is why their money field is an override rather than a requirement. Read "Allowance lines" before sending any.
Your company is enabled for a specific set of line types, configured by
Truster. By default that is work and compensation only — the allowance
kinds (perDiem, mealAllowance, mileage) and purchase are enabled
per company, because they are tax-free reimbursements with their own
compliance rules. A row of a type you are not enabled for is rejected on
its own ack as line-type-not-allowed and never stored; the rest of the
batch is unaffected.
Check what you are enabled for with GET /settings (scope
settings:read): it returns your effective allowedLineTypes, plus your
market, mock-mode state, rate limit, and the scopes of the key you called
with. If you need a type enabled, ask Truster. Never re-send a denied
allowance or purchase as a work or compensation row — that pays a
tax-free reimbursement out as taxable pay, withheld against and reported
to the income register under the wrong income type.
standard or construction-reverse, never market-prefixed. The same key
means "your market's version of this".Every error this API returns is an RFC 9457 problem document
(application/problem+json) with a stable type URI of the form
https://api.truster.com/errors/<slug>. Match your error handling on the
type string — it is identical in every environment and never changes for
an existing error class. Each type URI also dereferences: open it (or
GET /errors/<slug> on this host) for a page describing what the error
means and what to do about it; GET /errors lists the whole vocabulary.
Row-level outcomes inside batch acks carry the same type URIs in their
per-row problem objects.
You submit a BATCH; each batch holds up to 5000 LINES — one billable row
each, of type work, compensation, allowance, or purchase (see
"Line types" below). Batches owns the whole batch lifecycle — create,
append, edit/delete/move individual lines, submit, cancel, read.
Lines is the ONE read home for every line's row, regardless of type,
addressed by your own externalRowId — see "Reading your rows" below.
Receipts attaches a receipt file to a purchase line. Invoices reads
the invoices a batch produced. The authoritative
field-level contract is the OpenAPI document served at
/api/v2/docs/json (browsable at /api/v2/docs). This section covers
what a partner needs to operate the surface — endpoints, the per-row
outcome model, VAT, the normalization policy, dedupe semantics, how to
recover a quarantined row, and the batch lifecycle through to a
dispatched invoice.
A batch can produce more than one invoice. A batch's workers split into
up to two invoices: one for workers billed as individuals (light
entrepreneurs), one for workers billed as businesses (sole proprietors).
Which invoice a worker's lines land on is decided by that worker's own
Truster agreement — never by the businessId you send on the line's
worker, and never by ssn (#5099). A batch whose workers are all one
entity still produces exactly one invoice, as before.
Symmetric ids: you address everything by your own id. A batch is
addressed by the batchId you chose; a line is addressed by the
externalRowId you sent it under. No Truster-internal id (a UUID or
similar) is ever surfaced anywhere on this surface — not in an ack, not
in a line view, not in an error. Whatever id you sent is the id you use
for every later call.
| Method | Path | Purpose |
|---|---|---|
POST |
/batches |
Create a batch (your batchId; lines optional) — up to 5000 lines (work, compensation, allowance, or purchase) — lines may be empty (or omitted) to create the billing-period container first and fill it later. 202s when at least one line is accepted or quarantined (or when lines is empty) — a rejected row never fails its siblings. When EVERY submitted line is rejected, the batch is not created: 422 all-lines-rejected, with the same per-line acks in lines; fix them and send the same batchId again. A 400 is an envelope-level problem. draft: true holds the batch DRAFT; otherwise a non-REJECTED intake auto-proceeds into processing — see "Batch lifecycle" below. The envelope does NOT take orderReference/invoiceDate/dueDate — those invoice params live on the submit call's body only, see below. |
POST |
/batches/{batchId}/lines |
Append more lines to a still-DRAFT batch — the one write path; it accepts every line type. Same per-row outcome model as the original POST, indices continuing from the batch's current total. 404 for a missing/foreign batch, 409 (batch-not-draft) once the batch has left DRAFT, 409 (batch-full) past the 5000-line total cap. |
DELETE |
/batches/{batchId}/lines/{externalRowId} |
Hard-deletes a line from a still-DRAFT batch and frees its externalRowId's dedupe slot for resubmission. 204 on success; 404 for an unknown/foreign/wrong-batch id, including a retried delete after the row is already gone (confirmation, not a new failure — unlike the batch-level cancel below, this is NOT idempotent); 409 (batch-not-draft) once the batch has left DRAFT. |
PUT |
/batches/{batchId}/lines/{externalRowId} |
Full replace of a line in a still-DRAFT batch, fully re-gated like intake (money, dates, period-aware VAT, worker/hints, cross-line conflicts, dedupe self-excluded). externalRowId and type are immutable (400 immutable-external-row-id/immutable-line-type — allowance and purchase are different types, so switching between them needs delete + re-add); an allowance row's kind may still change in place (e.g. mileage to perDiem), and an uploaded receipt survives a same-type edit. 404 for an unknown/foreign/wrong-batch id, 409 (batch-not-draft) once the batch has left DRAFT — see "Managing lines in a DRAFT batch" below. |
POST |
/batches/{batchId}/lines/move |
Atomically bulk-moves 1–500 lines (by externalRowId) out of the path (source) batch into another DRAFT target batch named in the body — the compose-then-submit mechanism for partial invoicing, see below. 404 for a missing/foreign source or target batch, or any externalRowId not found in the source (naming every miss); 409 (batch-not-draft) if either batch has left DRAFT, or (line-conflict) if the moved set collides with the target's rows or with each other (naming every conflicting id + slug). All-or-none: any failure moves nothing. |
POST |
/batches/{batchId}/submit |
Move a DRAFT/REVIEW_PENDING batch through processing on demand (idempotent on every other state — see "Batch lifecycle" below). Optional body: orderReference/invoiceDate/dueDate for the invoice this submit creates — the only place these three ever apply. 400 (empty-batch) if the batch has no stored lines yet. Requires invoices:write — the money trigger is deliberately a separate scope from lines:write data staging, so mint it only on the credential that should be able to bill. |
DELETE |
/batches/{batchId} |
Cancel a DRAFT batch: 204 and the batch becomes CANCELLED. Idempotent: repeating the call on an already-CANCELLED batch is a safe no-op 204. 409 (batch-not-draft) on any other non-DRAFT state. |
GET |
/batches |
Cursor-paginated, newest-first list of this company's batch receipts. Filterable by state. |
GET |
/batches/{batchId} |
One batch's receipt plus its full, reconstructed per-line acks (byte-identical to the original POST response, unless a line has since been edited, deleted, moved out, or new lines moved in — see "Managing lines in a DRAFT batch" below) and its lifecycle state. Always carries invoices: [{ invoiceId, entity, status }] — empty until the batch has been invoiced, one entry per billed entity (so one or two) once it has — see "Invoices: one per entity" below. |
GET |
/batches/{batchId}/invoices |
Each invoice a batch produced, with its current invoiceNumber, invoiceDate, dueDate, and totals (net/vat/gross/currency, plus vatBreakdown) — see "Invoices: one per entity" below. Requires invoices:read (financial outcomes are scoped separately from lines:read operational data). |
GET |
/lines |
Cursor-paginated, filterable (workerId, batchId, status, limit, cursor) list of rows across your batches — one read home for every line type (work, compensation, allowance, purchase). See "Reading your rows" below. |
GET |
/lines/{externalRowId} |
Fetch one line by your own id — the same per-item shape GET /lines returns, discriminated by type. 404 for an unknown/foreign id — see "Reading your rows" below for the quarantine-precedence resolution rule. |
POST |
/lines/{externalRowId}/receipt |
Attach a receipt file (multipart, 10 MiB cap) to a type: "purchase" line. 422 (not-a-purchase) for any work/compensation/allowance line, 409 (batch-processing) while the batch is actively processing, 409 (batch-closed) once the batch has finished (COMPLETED, REJECTED, DISPATCH_FAILED or CANCELLED), 409 (line-committed) for an invoiced or withdrawn line, 409 (duplicate-receipt) when that exact file already backs another of your lines, 503 (receipts-unavailable) where the feature isn't deployed. A second upload replaces the first — see "Receipts" below. |
POST |
/receipts/scan |
Stateless, extraction-only preview: upload a receipt, get the structured read back — nothing is stored, nothing is decided. 400 (not-a-receipt-file) for an unsupported content type, 413 over the 10 MiB cap, 429 past the 10-calls/minute preview quota, 502/504 (preview-scan-unavailable/preview-scan-timeout) when the extraction service can't be reached in time. See "Preview scan" below. |
GET |
/vat-treatments |
Lists the VAT treatments available for a work period (?from/?to, default today), each with its rate, an optional description, and its validity window. Requires lines:read — see "VAT" below. |
GET |
/allowance-rates |
Lists your market's tax authority per-diem, meal, and mileage rates for a work period (?from/?to, default today). Requires lines:read — see "Allowance rates" below. |
POST /batches's batchId is YOUR OWN identifier — required,
1-128 characters, unique per company — and it's what every later call
({batchId} in append/submit/cancel/read, plus every batch response) uses.
No internal id is ever surfaced anywhere on this surface. A schema-valid
re-POST of an already-established batchId is idempotent: you get back
that batch's current receipt unchanged, without its envelope or lines
being re-validated or re-processed — the same behavior POST /workers's own workerId re-POST already has. A schema-INVALID
repeat (missing/malformed required fields) still 400s validation-failed
first, same as any other request — the leniency only applies once the
body itself is well-formed.
Every line submitted must reference a worker.workerId already established
via POST /workers (firstName, lastName, ssn, email, and
phone are all required; phone must be international format, +
followed by country code and number (E.164), e.g. +358401234567; a local
format like 040 123 4567 is rejected — 400 invalid-phone;
businessId is an optional addition — Finnish Y-tunnus, or Swedish
organisationsnummer; for an enskild firma this is the worker's own
personnummer — supplying it marks the worker as invoicing as a business,
entity: business) and may carry a jobId — the partner's own id for
the gig/assignment/order being billed. Send a jobId whenever your
system has any job/gig/order id (it is what links a row back to your
records during reconciliation); omit it only if no such concept exists
on your side. For Sweden, a personnummer-shaped
businessId must identify the same person as ssn (a mismatch 422s
identity-mismatch).
Any line may also carry a free-form reference (an account number, an
order or PO reference, a cost center), and a reference is ALWAYS printed
on the invoice: the row text is {project} - {reference} - {description}
with absent parts simply dropped — e.g. 100238 - Interpreting 4.5h, or
SiteA - 100238 - Interpreting 4.5h with a project. When rows are
combined by project into a summed invoice row, that row prints
{project} - {reference}, and rows with different references are never
merged into one. The value is recorded and printed exactly as sent and
never interpreted: unlike externalRowId (the dedupe key) and jobId
(reconciliation linkage), it is not an identifier, and it may repeat
freely across lines.
A Swedish company may instead establish a worker with businessId ALONE,
no ssn, when that businessId is a genuine organisationsnummer (checksum-
valid, NOT personnummer-shaped) — this establishes the worker directly as a
sole-prop/company entity (entity: business), firstName/lastName/
email/phone still required as above. This bypass is Sweden-only: a
Finnish Y-tunnus can never substitute for ssn this way, since Finnish sole
proprietors need a personal Suomi.fi-valtuudet mandate keyed by ssn/hetu —
a Finnish sole proprietor supplies ssn and businessId together (the
"both identifiers" case above), never businessId alone.
Establishing without handling personal numbers — shareCode. If you
do not collect national identity numbers, establish a worker with their
Truster share code instead: a short code the worker finds in their
Truster app and gives to you. Send workerId + shareCode and nothing
else is required:
{ "workerId": "worker-1042", "shareCode": "AB12CD" }
Truster resolves the worker's identity from their own account — you never
see or store their personal number (the response's ssn stays masked as
always) — and the code itself is the worker's consent to be linked to your
company. The code is case-insensitive and surrounding whitespace is
ignored. shareCode is a strict ALTERNATIVE to ssn: supplying both
rejects 400 identity-axis-conflict, and businessId cannot accompany
it either — whether the worker invoices as a business is determined by
their own account. Profile fields (firstName, lastName, email,
phone) are optional on this axis; the linked account's own details take
precedence (a supplied phone is still format-validated). Two error
cases, both fixed on the worker's side: 400 invalid-sharecode — the
code resolved to no account; ask the worker to re-check it — and 400
disabled-sharecode — the worker has disabled the code; ask them to
re-enable or regenerate it, then retry. Because a share code always
belongs to an existing Truster account, a shareCode-established worker is
always linked from the start. The code only links the worker to that
account — it never changes a workerId.
The upgrade case works too: if you established a worker earlier (they
weren't on Truster yet) and they have since joined and given you their
code, re-POST the same workerId with the shareCode — the existing
worker is linked to that account in place, keeping your workerId and
everything you submitted before. A worker who is ALREADY linked ignores
the code entirely: re-POSTing your roster with stored codes is always
safe, even when a code has since been rotated or disabled.
Correcting a worker — PUT /workers/{workerId}. While a worker is
NOT linked to a Truster account, you can replace their details: send the
same fields as POST /workers, minus workerId (it's in the path) and
shareCode. It's a full replace, gated exactly like a new establishment
(same required fields, same slugs: invalid-establishment,
invalid-identifier, missing-name, missing-contact, invalid-phone,
identity-mismatch, the entity-* pair) — and an omitted businessId
or address clears the stored one. The response is 200 with the
worker as GET /workers/{workerId} shows it. Once the worker is linked,
their profile is their own and the call answers 409 worker-linked —
ask the worker to update their details in their Truster app. An unknown
workerId (or another company's) is 404 not-found. After a
successful PUT, Truster checks straight away whether the corrected worker
matches exactly one verified Truster account, using the same strict rules
as the nightly matching run. When it does, the worker is linked and you
receive the worker.status_changed webhook (ENROLLED); the PUT's own
response still shows the worker as you sent it. Otherwise the worker stays
unlinked and can still be linked by a re-POST with shareCode.
entity (individual/business) is an optional self-declaration on
establishment that must cohere with businessId's presence — declaring
business without a businessId 422s entity-requires-business-id,
declaring individual with one 422s entity-conflicts-with-business-id;
omit it entirely and no coherence check runs. This is a request-shape check
only, independent of the entity every response reports (see below) — a
partner can correctly declare entity: business for a businessId they
just supplied and still get back entity: "unknown", because the worker
isn't linked to a billing account yet. See
components.schemas.LineInput in the generated OpenAPI document
(/api/v2/docs) — a "One of" union of the work / compensation / allowance /
purchase variants — for the authoritative per-type field list.
The response's entity is how the worker is actually billed — never
derived from businessId or ssn (#5099). For an FI-market company it is
read off the worker's own Truster agreement: individual (light
entrepreneur, kevytyrittäjä), business (sole proprietor, toiminimi), or
unknown when the worker has no Truster account linked yet, or the linked
account's product doesn't resolve to either. It is the SAME value on every
workers route — POST /workers, POST /workers/batch, PUT /workers/{workerId}, GET /workers, GET /workers/{workerId} — and the
same value a batch's processing routes that worker's lines by, so what you
see on the worker is what you'll see on the invoice. A freshly established,
not-yet-linked worker reads unknown; one that establishment links right
away (a HIGH-match on POST /workers) reads its account's agreement-routed
value in that SAME response. A PUT that triggers linking is async, same as
status: that response still reads unknown (or the SE signal) — a later
GET reflects the resolved value once the link completes, same as the
worker.status_changed webhook signals it. For an SE-market company,
entity keeps the pre-#5099 signal instead —
business when the worker has a stored businessId, individual
otherwise — on every route; it is never unknown for an SE worker.
If the resolved person is already established under a DIFFERENT company on
Truster, POST /api/v2/workers simply establishes them here too — each
company gets its own independent worker row for that person.
When a worker is linked to a Truster account. An establishment with
ssn links the worker to their Truster account only when their name and
email or phone also match that account — the ssn alone is never enough,
so a mistyped number can't attach your worker to someone else. On a
mismatch the worker is established unlinked (status: PENDING) and
reviewed; once confirmed it links and you get a worker.status_changed
ENROLLED event.
Your workerId is the worker's identity. The same person can sit under
several of your workerIds — each one is its own worker, and a new
workerId for someone already on your roster establishes a new worker
rather than being rejected. Lines always resolve by workerId.
Every line is one of four types:
work — work performed. Carries a required description and
money.compensation — a typed compensation/payout (a bonus, a
cancellation fee, a referral reward). Carries compensationType (open
vocabulary, e.g. "BONUS") and money.allowance — a rate-based entitlement. Carries a kind:
perDiem, mealAllowance, or mileage. FACTS FIRST — you declare
what happened, never a total; Truster prices these at the statutory
registry rate when the batch is invoiced (see "Allowance rates" below),
unless you send an optional money rate override — unitPrice for
mileage/mealAllowance, per-component wholeDay/partialDay/night
for perDiem (see "Overriding the allowance rate" below).purchase — a receipt-backed out-of-pocket claim (a hotel, a
tool, a general purchase). NO kind — with allowance owning the
three rate-based kinds, purchase is the whole of the catch-all, so
there is nothing left to discriminate. Carries items[] — 1..50 receipt
line items, each with its own gross amount and optional per-item
vatTreatment/description (see "Purchase lines" below) — and is the
ONLY type a receipt can be attached to (see "Receipts" below).work/compensation/the allowance kinds/purchase are all submitted
the same way — as a line in POST /batches's lines[] or
POST /batches/{batchId}/lines — and go through the same worker-
resolution, identity-hint, and externalRowId-dedupe gates. See
"Reading your rows" below for how each type reads back, and "Allowance
lines" / "Purchase lines" further down for the full per-type field
reference.
POST /batches never rejects a batch because one row is bad. A
malformed or suspect row never fails its siblings — each of the (up to 5000) submitted lines gets its own outcome in the response's lines[]
array, keyed by its index:
accepted — the row passed every gate and now sits in the company's
work-line pool, ready to be invoiced.quarantined — the row is STORED, but held for operator review:
an unresolved workerId, a hints mismatch against the resolved worker
(identity-conflict), a duplicate externalRowId (duplicate-row), or
one of four allowance-line cross-line conflicts — a second mileage line
for the same worker in this batch (duplicate-mileage), a worker's
overlapping perDiem periods (perdiem-overlap), a mealAllowance on
a date a perDiem line already covers (meal-on-perdiem-day), or a
worker's same-date mealAllowance counts summing past 2
(meal-daily-limit) — see "Allowance lines" below for all four. Quarantine is
never silent data loss — see "Quarantine remediation" below for how a
partner clears one.rejected — the row is NEVER stored: a schema violation, an
unparseable or non-positive amount, an arithmetic mismatch between
quantity × unitPrice and amount (work/compensation), a non-positive
item amount or an unresolvable item VAT treatment on a purchase row
(receipt-item-invalid, naming the offending items[N] — see "Purchase
lines" below), an unparseable date, or (mileage only) an invalid
vehicle — a base your market doesn't offer at all
(vehicle-type-invalid-for-market), a base+variant pair your market
doesn't price (vehicle-variant-invalid), or extras
(trailer/tools/passengers) sent on anything but FI's plain car
(vehicle-extras-not-priced) — a drive that ends at or before it
starts (mileage-drive-window-invalid, naming the drive), or a
distance over the 2000 km per-line cap (mileage-exceeds-maximum; see
"Allowance lines" below for the full mileage vehicle rules). perDiem
and mealAllowance have their own rejects too — an ineligible or
malformed trip window (perdiem-not-eligible/perdiem-day-invalid/
perdiem-distance-ineligible/perdiem-origin-required), a
market-restricted kind for your company (perdiem-not-supported-for- market/not-valid-for-market), or a rate override Truster cannot
check — on a trip spanning more than one country
(perdiem-rate-override-multi-country, where an unlisted travel day
counts as a domestic one) or to a destination with no rate in this
year's table (perdiem-rate-override-unverifiable); both are about the
override alone, and both clear by omitting money — see "Allowance
lines" below for the full list, each one's remedy, and why. On a
Finnish-market company, any allowance line for a worker billed as a
business (sole proprietor) rejects allowance-for-business-worker:
send it as a work line with an amount. There is no
per-batch cardinality limit on allowance/purchase lines — submit as many as you
need per worker per batch — except mileage, capped at one per worker
per batch (see "Allowance lines" below for why).Whole-batch (envelope-level) rejection — a 400, not a per-row outcome —
is reserved for problems that make the whole submission meaningless: a
currency that doesn't match the company's billing currency, an invalid
batch-level VAT treatment (invalid-vat), more than 5000 lines, or
malformed JSON.
VAT is a single explicit TREATMENT, vatTreatment — a market-neutral,
semantic key: set a batch-wide default with the envelope's
vatTreatment, override per row with money.vatTreatment (work/
compensation), or per ITEM with items[].vatTreatment on a purchase
row — see "Purchase lines" below for why that one is per-item rather than
per-row. The same key means "your market's version of this", so a
statutory rate change never changes what you send. Which treatments exist
depends on your company's market:
standard, reduced, reduced-low, exempt,
healthcare-exempt, social-care-exempt, artist-exempt,
vessel-exempt, margin-scheme (each a VAT-exempt sale with its own
legal basis), construction-reverse (domestic construction reverse
charge), eu-service-reverse (EU reverse charge), out-of-scope
(outside the scope of VAT, non-EU).standard, reduced, reduced-low, exempt,
construction-reverse (omvänd byggmoms), eu-service-reverse (EU
reverse charge), export (export outside the EU).Rates are deliberately not listed here: which treatments are actually
available for a given work period — each with its current rate, an
optional description, and its validity window — is served by GET /api/v2/vat-treatments (?from=YYYY-MM-DD&to=YYYY-MM-DD, default
today). Query it rather than hardcoding a rate on your side.
vatTreatment uses the work period's
standard treatment, resolved the same way.export for a Finnish
company, out-of-scope for a Swedish one) is rejected: 400
invalid-vat at the envelope level, a per-row rejected outcome with
the same slug at row level (for work/compensation), naming the
treatments that ARE valid. There is no separate confirmation step for a
0%/reverse-charge/exempt treatment: picking one already IS the explicit
declaration. On a purchase row, the same problem rejects as
receipt-item-invalid instead (naming the offending items[N]) — see
"Purchase lines" below.Reads echo the resolved treatment back as an object, { treatment, rate }
— treatment is the key (e.g. "standard"), rate is its VAT rate as a
number (0 for exempt/reverse-charge/export treatments), read off the
stored row rather than a fixed table. For work/compensation, that
object is money.vat, one per line (a batch read carries the batch's
default vat, when one was sent). A purchase row has no row-level
money.vat at all — VAT is resolved and echoed PER ITEM instead, as
each items[i].vat — see "Purchase lines" below.
This whole section applies to work/compensation lines, and to each
INDIVIDUAL item of a purchase row's items[] — the only places this API
resolves a declared amount against a VAT treatment. allowance lines
(perDiem/mealAllowance/mileage) are VAT-free — their own optional
money is a per-unit rate override only (no vatTreatment accepted, no
vat echoed) — see "Overriding the allowance rate" below.
GET /allowance-rates (?from=YYYY-MM-DD&to=YYYY-MM-DD, default today) lists
your market's tax authority allowance table — per-diem, meal, and mileage
rates — for the requested work period: Verohallinto's table for Finnish
companies, Skatteverket's for Swedish ones. These are the rates your
declared mileage drives and per-diem trips (allowance lines' perDiem/
mealAllowance/mileage kinds — see "Allowance lines" below) are priced
at. It is also the authoritative, always-current source of which
mileage vehicle{base,variant?} values your market actually prices —
query it rather than hardcoding a table on your side.
A row is offered whenever ANY year the period touches falls inside that
row's effective period — the same end-date-year rule per-line pricing
itself uses (a work period spanning New Year can therefore return two
rows, one per year). No row for the requested period returns 200 with an
empty allowanceRates array, not an error.
{
"allowanceRates": [
{
"year": 2026,
"currency": "EUR",
"perDiem": { "whole": 51, "partial": 24 },
"meal": 12,
"mileage": [
{
"vehicle": { "base": "car" },
"ratePerKm": 0.29,
"carOptions": {
"withTrailer": 0.09,
"withTools": 0.05,
"perPassenger": 0.04
}
},
{
"vehicle": { "base": "motorcycle" },
"ratePerKm": 0.2
}
]
}
]
}
perDiem.night (SE: nattraktamente) is present only on regimes that have a
night rate — omitted entirely on Finnish rows. perDiem.foreign lists the
regime's own annual foreign-destination table (Verohallinto's country list
on FI rows, Skatteverket's utlandstraktamente on SE rows), one
{ country, whole, partial } entry per destination, sorted by ISO country
code — partial is always round(whole / 2, 2), the same derivation the
pricing engine applies. A per-diem line's trip.days[].country must name
one of these destinations (or your own market's country, a domestic day);
an unpriced country is rejected at intake (perdiem-country-not-priced)
rather than aborting later at invoice generation. carOptions (per-km
supplements: withTrailer, withTools, perPassenger) is present only on
FI's plain-car row, never on any Swedish row (Skatteverket's schablon
table prices no extras). All money fields are in the row's own currency
— per day for perDiem, per meal for meal, per km for ratePerKm/
carOptions.
Each mileage row's vehicle is its identity AND the shape to submit
back. vehicle: {base, variant?} mirrors a mileage line's own
vehicle{} field exactly (see "Allowance lines" below) — send that same
object back as vehicle on a mileage line to claim that row's rate.
There is no other vehicle key on the wire: internal registry tokens are
never exposed, so nothing on this response can be confused with a
submittable value.
Sweden's mileage rows are real, priced-today Skatteverket rates, and a
mileage claim priced against them is payable. An SE-market company's
mileage array is sourced live from the same schablon table Truster's
own Swedish payroll engine bills against (never a stale edit-surface
row):
{
"mileage": [
{ "vehicle": { "base": "car" }, "ratePerKm": 2.5 },
{
"vehicle": { "base": "car", "variant": "electric" },
"ratePerKm": 2.5
},
{
"vehicle": { "base": "motorcycle" },
"ratePerKm": 1.25
}
]
}
A submitted SE mileage line's vehicle is validated and accepted
against this exact table at intake and, as "Allowance lines" below
explains, proceeds from there to Truster's Swedish operator review
(Gate 1) like any other Swedish row: Sweden's vehicle types are
discoverable, submittable, and payable — after review, not
instantly. FI rows go straight to invoice with no such review step.
The row net is rounded to cents, half-up, at ingestion; VAT is computed on rounded nets; invoice totals are sums of rounded rows. Consequence: a partner summing raw values and rounding the total may differ from the invoice by ≤ a few cents — per-row rounding is authoritative, not the partner's own client-side total.
money.amount, money.quantity, and money.unitPrice (on
work/compensation lines) are JSON numbers, accepted at
full precision up to 6 fractional digits (49.999798 is valid). A
7th-or-later fractional digit fails the money gate as invalid-money
(the row is rejected, not silently truncated). A purchase row's
items[].amount follows the SAME 6-fractional-digit precision rule, but
through its own gate — a 7th-or-later digit, or a non-positive amount,
rejects as receipt-item-invalid (naming the offending items[N]), never
invalid-money, which stays reserved for work/compensation. An
allowance's rate override — money.unitPrice for mileage/mealAllowance,
or money.wholeDay/money.partialDay/money.night for perDiem — is a
plain positive number per field; none of them run through either gate
(see "Overriding the allowance rate" below).
Every mutating request (POST) accepts an optional x-idempotency-key
header: resending a request with the same key within 24 hours returns the
original response — byte-for-byte — instead of re-executing it. Send a
fresh unique value (a UUID is ideal) per logical submission, and retry
with the SAME value after a timeout or network error; a retry can then
never double-submit, and you always receive the first attempt's per-line
acks even if you never saw them. Keys are capped at 256 characters.
The header composes with two identity layers that protect you even
without it: re-POSTing a batch with the same batchId returns the
existing batch (existing, never a duplicate), and every line's required
externalRowId dedupes rows across batches (below). The header is the
only layer that replays the exact original response, so use it whenever
your client retries automatically.
Every line carries a required externalRowId — your unique id for that
row (a line number in your export, a UUID; if your jobs map 1:1 to lines
it can equal jobId). The server remembers (companyId, externalRowId)
for every row that reaches an accepted outcome — cross-batch as
well as in-batch. A later batch (or a later line in
the SAME batch) that reuses an already-accepted externalRowId is
quarantined duplicate-row, never silently merged or overwritten.
Critically, a quarantined row never itself claims the dedupe slot: an
unresolved-worker or identity-conflict quarantine is, by definition, not a
row the server has accepted yet, so it must never block a corrected
resubmission under the same externalRowId (see "Quarantine remediation"
below). Nor does a rejected row — it was never stored at all. Only an
accepted row (and, once it is billed, an invoiced row) ever occupies a
dedupe slot.
A quarantined row is not a dead end. The typical loop:
POST /batches a line whose workerId is not yet
established for this company — the row comes back quarantined with
problem.type .../unresolved-worker, and IS stored (visible via
GET /lines?status=quarantined).POST /workers (or fix the mismatched
hints on the partner's side, for an identity-conflict quarantine).externalRowId in a later batch. Because
dedupe only ever considers accepted/invoiced rows (see above), the
corrected resubmission is judged fresh on its own merits and, if it now
passes every gate, comes back accepted — it is never quarantined again
as a false duplicate-row against its own unresolved predecessor.The original quarantined row is left in place as a record of the rejected attempt; it is not deleted or mutated by the later, successful resubmission.
receipt-missing is a different kind of remediation — no PUT, and no
resubmission while the batch is open. A purchase line quarantines this
way at PROCESSING (never at the original ack), once it was already
accepted, when it reaches that point with no receipt attached — see
"Purchase lines" above. Nothing about the row's own data was wrong, so
there's nothing to fix on the row itself: for a batch still
DRAFT/REVIEW_PENDING, attach the receipt via POST /lines/{externalRowId}/receipt (see "Receipts" below) and re-drive
processing, which re-checks the row for a stored receipt and clears the
quarantine the moment one is present. Once the batch has finished, its
row no longer takes a receipt (409 batch-closed): resubmit the SAME
externalRowId in a new batch and attach the receipt to that new line
instead. Still missing, it simply stays
quarantined; a row quarantined for any OTHER reason is never touched by
this presence re-check.
One edge case: if that purchase line was its batch's ONLY row, there is
no re-drive to fall back on — with nothing else left to invoice, that
first processing attempt already converges the whole batch to the generic
terminal REJECTED (nothing-to-invoice) rather than parking it, so
resubmitting the same externalRowId in a fresh batch (receipt attached
this time) is the only remaining option for that row. The re-drive path
above only helps when at least one sibling row in the same batch also
reaches processing.
allowance-for-business-worker (processing) is not cleared in place.
The worker's agreement changed after intake to bill them as a business, so
the allowance can't be invoiced; the rest of the batch invoices without it.
Resend the amount as a work line in a new batch, under a NEW
externalRowId — the held line's id stays claimed by its original batch,
so reusing it can come back duplicate-row. If every line of the batch was
held this way, nothing is left to invoice and the batch ends REJECTED
(nothing-to-invoice).
This resubmit loop remains the ONLY option once a row's batch has left
DRAFT — parked for review, submitted, or in a terminal state, none of
which allow line mutation (see "Managing lines in a DRAFT batch" below).
But for a row still sitting in a DRAFT batch, PUT /batches/{batchId}/lines/{externalRowId} is usually simpler: it fixes
the SAME row in place, under its SAME externalRowId, with no new batch
and no new dedupe bookkeeping — see that section for the full re-gating
rules. Use PUT while the batch is still DRAFT; fall back to resubmission
once it isn't.
Every batch starts life DRAFT the instant POST /batches
accepts it — intake (per-row gating, storage, dedupe) always runs first,
independent of everything below. What happens AFTER intake persists depends
on the envelope's draft field:
draft: true — the batch stays DRAFT. Nothing else happens; the ack
body's state comes back "DRAFT".false (default) — unless there is nothing to process, the
POST auto-proceeds: it immediately hands the batch to processing, and
the ack body's state reflects wherever that call landed it (see the
state table below). "Nothing to process" is an EMPTY container with no
lines submitted at all (lines: [] or omitted — status: "ACCEPTED":
nothing was submitted, so nothing failed either). A new batch whose every
submitted row was rejected is not created at all — it answers 422
all-lines-rejected (with the per-line acks) instead of a 202. On an
append, an all-rejected call still 202s with status: "REJECTED": the
batch already exists and keeps its earlier rows.Append more lines to a still-DRAFT batch with
POST /batches/{batchId}/lines — same per-row outcome model as
the original POST, with indices continuing from the batch's current total
(stored rows plus rejected rows so far). The append body carries only
lines; batch-level fields (vatTreatment, currency, …) are fixed at the
original POST and cannot be changed via append. The 5000-line cap applies
to the batch as a whole — the original POST plus every append combined,
not each call individually (409 batch-full once a call would push the
total past it). Once a batch has left DRAFT (auto-proceeded, submitted, or
cancelled), appending returns 409 batch-not-draft.
Move a DRAFT or REVIEW_PENDING batch through processing on demand with
POST /batches/{batchId}/submit — the same underlying call the
non-draft POST makes automatically, useful for a batch that was submitted
draft: true, or one that parked for review and is ready to retry. Its body
is optional and carries orderReference/invoiceDate/dueDate — invoice
vocabulary that applies to the invoice this submit creates. This is the
ONLY place these three fields apply — the batch envelope (POST /batches)
does not accept them. Omitted: invoiceDate defaults to the processing
date and a default dueDate is applied at process time.
Calling /submit on a
batch that's already PROCESSING, or sitting in any terminal state
(COMPLETED, REJECTED, DISPATCH_FAILED, CANCELLED), is a no-op — it
never re-triggers processing and never applies the body's fields either, it
just echoes the batch's current state back with a 200. Safe to call
repeatedly. The response is a slim { batchId, state, reviewPendingReason? }
— no per-line acks, since /submit never submits new lines.
Cancel a DRAFT batch with DELETE /batches/{batchId} — 204
on success, the batch's state becomes CANCELLED (terminal), and every
stored accepted/quarantined row on it is stamped withdrawn. Rows are
never deleted: a cancelled batch's lines stay visible via
GET /lines (now with status: "withdrawn"), kept for audit. Cancel
only succeeds from DRAFT — anything else, including REVIEW_PENDING,
returns 409 batch-not-draft — with one exception: repeat-DELETEing an
already-CANCELLED batch is a safe, idempotent no-op 204 rather than a
409 (it re-sweeps any rows the batch still has left accepted/
quarantined, so a retry after a partial failure finishes the job instead
of getting stuck). After a cancel, the batch receipt's ack replay
(GET /batches/{batchId}) reports the withdrawn rows under
their ORIGINAL intake outcome (accepted/quarantined, whichever they were
before the cancel), while the live line list (GET /lines) shows
their CURRENT status, "withdrawn" — the receipt is a historical record of
what intake decided, the line list is live status.
state |
Meaning |
|---|---|
DRAFT |
Intake-only. Appendable, submittable, cancellable. |
REVIEW_PENDING |
Parked for operator/worker-link resolution. reviewPendingReason is "worker_link_needed" when an unresolved worker is the blocker, "relationship_unresolved" when an accepted row's worker has no usable Truster agreement to bill them by (e.g. not linked), "invoice_needs_review" when the batch is held for Truster to review an invoice already created for this batch, "se_gate_pending" for a Swedish-market batch awaiting Truster's manual review, "receipt_scan_pending" while a purchase line's receipt scan is still running (the batch resumes on its own once the scan finishes), "receipt_review_pending" while a purchase line's held receipt awaits Truster's review (the batch resumes when that receipt is approved, rejected or removed from the review queue, or when a Truster operator processes it), "business_id_not_available" when a sole-proprietor worker in the batch has no business ID on Truster (have it added, then /submit again), "company_details_missing" when your company's details on Truster can't be invoiced (e.g. no business ID — contact Truster, and /submit again once they are fixed), or omitted entirely for a plain manual-review park with no specific machine reason. /submit retries processing from here. |
PROCESSING |
A processing attempt is in flight or has claimed the batch. /submit is a no-op here. |
COMPLETED |
For an FI-market batch, invoiced — invoices lists one entry per billed entity (so one or two), see "Invoices: one per entity" below. If your company's invoices aren't auto-sent, this is also the state a batch reaches once its invoices are created as drafts, each awaiting_send until Truster sends it. A Swedish-market batch reaches COMPLETED too (once Truster's reviewer approves it) but its invoices stays empty: see "Swedish-market batches" below. |
REJECTED |
Terminal: the batch's content itself failed processing, or — for a Swedish-market batch — Truster's reviewer rejected it. rejectionReason is always present here: "nothing-to-invoice" (no billable rows survived intake/dedupe) or "se-gate-rejected" (Truster's Swedish review rejected the batch — ask your account manager for details). |
DISPATCH_FAILED |
Terminal: processing passed validation, every entity's invoice was created, but sending at least one of them failed — that invoice's own status is send_failed; an invoice that did send still reports sent_to_customer. Truster resends the failed one; /submit stays a no-op here, so poll invoices[].status, not the batch state, to see it go out. |
CANCELLED |
Terminal: the result of a DELETE. |
reviewPendingReason is only ever present on a response when it's a
non-empty string — never emitted otherwise, whether on a POST/append ack,
a /submit response, or a batch read.
rejectionReason is present only on a batch read whose state is
REJECTED — omitted on every other state, including DISPATCH_FAILED and
CANCELLED. Note the two fields don't share a casing convention:
reviewPendingReason values are snake_case (worker_link_needed,
se_gate_pending, receipt_scan_pending, receipt_review_pending, business_id_not_available, company_details_missing); rejectionReason values are kebab-case
(nothing-to-invoice, se-gate-rejected). That's not a typo — they're
separate fields with separate histories.
/submit needs the processing feature enabled at all to do anything: if the
deployment hasn't been configured for it, every call returns 503
processing-unavailable — a batch a partner tries to /submit in that
environment simply cannot move past DRAFT/REVIEW_PENDING yet. Once the
feature is enabled but the processing call itself fails outright (a network
error, or a non-2xx/unparseable response from the processing service),
/submit returns 502 processing-unavailable instead — same problem
type, different HTTP status: 503 means "this environment isn't
processing-enabled," 502 means "processing is enabled but temporarily
unreachable, try again shortly." A client-side timeout is handled
differently again: /submit re-reads the batch's stored state (the
processing service may already have flipped it — typically to PROCESSING
— before the timeout fired) and returns that as a normal 200 rather than
an error; poll GET afterward to see where it actually landed.
POST /batches's auto-proceed never surfaces either status to
the partner — the intake acks are already durable by the time processing is
even attempted, so a processing failure there is swallowed into the ack
response: an outright failure comes back as a normal 202 with
state: "DRAFT" (processing never got far enough to change it), and a
timeout comes back 202 with the freshly-re-read state (which may already
be PROCESSING). Either way, the partner can /submit later to retry.
Every batch read (GET /batches and GET /batches/{batchId}) carries a
period: { from, to } — the date span the batch's stored lines cover,
computed as the min/max across every date every line carries. All line
types count: a work or compensation line's workStartDate/workEndDate;
a mileage line's date and each drive's start and end dates as written; a
per-diem line's date plus its trip window, read on your market's
clock (Europe/Helsinki or Europe/Stockholm; or, for the
days/origin/destination form, the days the claim covers); a meal
line's date and each item's day; a
purchase's date. It is omitted while no stored line carries a date
(e.g. an empty container), and it updates live as lines are appended,
replaced, deleted, or moved. Each invoice the batch produces covers its own
billed entity's lines, derived by the same date rule — except an
individual invoice folded into your weekly invoice (see "Folded into your
weekly invoice" below), which covers the whole weekly invoice.
A batch's workers split into up to two invoices by billed entity: one for
workers billed as individuals (light entrepreneurs), one for workers billed
as businesses (sole proprietors) — see "A batch can produce more than one
invoice" above. A batch read (GET /batches and GET /batches/{batchId},
and POST /batches/{batchId}/submit's own response) always carries
invoices: [{ invoiceId, entity, status }] — empty until the batch has been
invoiced, one entry per billed entity (so one or two) once it has, and it
stays empty for a batch that produces no invoice at all:
entity is individual or business — which group of workers this
invoice bills.status is awaiting_send (created, not sent to you yet),
sent_to_customer (sent to you), or send_failed (created, but sending
it to you failed).If your company's invoices are not auto-sent, /submit still creates
them — as drafts, awaiting_send — and stops there; the batch reaches
COMPLETED with its invoices waiting. Truster sends each draft from its
mass-invoice dispatch; there is nothing further for you to call. Poll
invoices[].status (via a batch read) to see awaiting_send turn into
sent_to_customer once Truster sends it.
Each invoice's own fields — invoiceNumber, invoiceDate, dueDate,
totals: { net, vat, gross, currency } — are not embedded in the batch
response; fetch them from GET /batches/{batchId}/invoices, which lists
every invoice of the batch with these live fields, scope invoices:read.
The integration pattern is: poll GET /batches/{batchId} until state is
COMPLETED (or a terminal failure state), read invoices[].invoiceId off
that response, then GET /batches/{batchId}/invoices for each invoice's
fields. GET /invoices/{invoiceId} is gone — a split batch can hold more
than one invoice, so there is no single id left to address it by.
totals.vatBreakdown lists one bucket per VAT class on the invoice
(treatment key, percent rate, and net/VAT/gross per bucket), sorted by
rate descending. Invoices completed before this field existed omit it.
Folded into your weekly invoice. When Truster has enabled it for your
company and you have an open weekly (scheduled) invoice, a batch's
individual lines are added to that invoice instead of a new one. The
batch's individual entry then names the shared weekly invoice, so several
batches can list the same invoiceId; its totals, invoiceDate and
dueDate in GET /batches/{batchId}/invoices are the whole weekly
invoice's, not this batch's share. It stays awaiting_send until Truster
sends the weekly invoice from its scheduled dispatch, whatever your
auto-send setting, and the submit body's invoiceDate/dueDate do not
apply to it. The business invoice is unaffected.
A Swedish-market batch's invoices stays empty, even once COMPLETED —
see "Swedish-market batches" below for where its billable rows go instead.
Your company's market (FI or SE) is set when Truster provisions your
API key — it is not something you choose per batch or per line, and it is
what decides which VAT treatments you can use (see "VAT" above).
A Swedish-market batch runs through the same endpoints and the same state vocabulary as above, with one difference: once processing picks it up, a Swedish-market batch is reviewed by Truster before it goes any further, and it settles through the Swedish salary lane rather than a Truster-issued consolidated invoice.
REVIEW_PENDING with
reviewPendingReason: "se_gate_pending" — a Truster reviewer decides
whether it proceeds, rather than an automated gate.state becomes COMPLETED, but — unlike an
FI-market batch — its invoices stays empty. There is no invoice to
fetch: the batch's rows are billed through Truster's Swedish salary lane
on its own periodic schedule, outside the Invoices endpoints entirely.state becomes
REJECTED, the same terminal state any other rejected batch reaches.Swedish row checks at processing. Before the review step, each
accepted row is checked once more for what the Swedish salary lane needs.
A row that fails is quarantined (quarantineReason below, details in
quarantineDetail) and left out; the rest of the batch goes on. If every
row fails, the batch ends REJECTED (nothing-to-invoice). The batch has
left DRAFT by then, so fix the row and resubmit it in a new batch (see
"Quarantine remediation" above). Besides the se-perdiem-* reasons (see
"Allowance lines" below), se-expenses-unsupported, se-invalid-km and
se-non-exact-minor, a row can quarantine with:
| Slug | Meaning | Fix |
|---|---|---|
se-missing-external-line-id |
Defensive: the stored row has no externalRowId. |
Should not occur — intake requires one; contact Truster if seen. |
se-external-line-id-ambiguous |
The externalRowId contains ::, which the Swedish lane reserves. |
Resubmit under a NEW externalRowId without ::. |
se-missing-work-date |
A work/compensation row has neither workStartDate nor workEndDate, so it can't be placed in a pay period. |
Resubmit with workStartDate and/or workEndDate. |
se-missing-description |
The row's invoice text is blank — e.g. a description of only spaces. |
Resubmit with a non-blank description. |
se-vat-unmapped |
Defensive: the row's VAT did not resolve to a Swedish VAT rate. | Should not occur — intake resolves vatTreatment; contact Truster if seen. |
se-invalid-hours |
A work row with a unitPrice: quantity (the hours) is not positive or has more than 2 decimals. |
Resubmit with quantity rounded to at most 2 decimals (and amount to match). |
se-missing-amount |
Defensive: the row has neither a unitPrice nor an amount to bill. |
Should not occur — intake requires amount; contact Truster if seen. |
se-non-positive-minor |
The amount, unitPrice or rate override comes to 0 öre — e.g. unitPrice: 0, or an amount under 0.005 that rounds to 0.00. |
Resubmit with a value of at least 0.01. |
GET /batches accepts an optional state query parameter
(any value from the vocabulary above) to filter the returned page. The
filter is applied AFTER pagination, over the page the server already
fetched — so a filtered page can legitimately come back with FEWER items
than limit while still carrying a nextCursor. Keep paging on
nextCursor's presence alone; a short batches array on its own is never a
signal that you've reached the end.
GET /lines is the ONE read home for every line you've submitted —
work, compensation, allowance, and purchase rows all read back through
it, cursor-paginated and filterable by workerId, batchId, status
(accepted / quarantined / invoiced / withdrawn), limit
(1-200, default 50), and cursor. Each item's type tells you which
kind of row it is; status mirrors the batch lifecycle a row's outcome
went through (a quarantined row's quarantineReason names why, same
vocabulary as the original batch ack; a cancelled batch's rows read back
withdrawn, kept for audit — see "Batch lifecycle" above). Filters
combine: ?workerId=X&batchId=Y&status=accepted narrows to exactly that
slice.
There is no ?type= filter today. Allowance and purchase rows both
store under the same internal bucket, and the specific kind lives in a
JSON fragment rather than a queryable column — filtering by type at
the database level isn't possible without a data-model change. This is a
stated deferral, not an omission: read the page and filter client-side by
type if you need only one kind.
Two motivating flows:
GET /lines?workerId=worker-1042
returns that worker's rows regardless of which batch they landed in —
each row carries its own batchId, so you see which batches hold that
worker's lines from one call. Useful for a worker-centric reconciliation
view without knowing the batch ids up front.GET /lines?workerId=worker-1042&batchId=sept-2026-01
narrows to exactly that worker's rows inside one batch — useful once
you already know which batch you care about.Fetch a single row directly via GET /lines/{externalRowId} — the same
shape, one item — and, while its batch is still DRAFT, edit, delete, or
bulk-move it; see "Managing lines in a DRAFT batch" below.
Addressing by externalRowId — the quarantine-precedence rule.
externalRowId is unique per company only among accepted/invoiced
rows — a quarantined row is stored but never claims the dedupe slot (see
"Dedupe semantics" above), so a quarantined attempt and a later corrected,
accepted row can transiently share the same externalRowId. Every
single-row lookup by externalRowId (GET /lines/{externalRowId}, the
PUT/DELETE mutation routes, and the receipt-upload route) resolves
deterministically:
externalRowId, if one exists
(at most one — the dedupe guarantee), elseA rejected submission was never stored, so it never participates. Note
this precedence applies ONLY to single-row lookups — GET /lines's LIST
view can legitimately show both a quarantined row and its later,
accepted replacement side by side, sharing an externalRowId, for as
long as both remain stored.
Every line's worker link is worker.workerId — the partner's own
id, the only resolution key (see "Batches, lines, and invoices" above).
Money is nested under money on both write AND read — symmetric —
for work/compensation; a purchase row carries its amount(s) under
items[] instead (see below). When you submit a work or
compensation line (POST /batches or the append route), its amount
fields live under a nested money object:
{
"type": "work",
"worker": { "workerId": "worker-1042" },
"externalRowId": "row-01",
"jobId": "job-778",
"description": "Interpreting 4.5h",
"money": { "amount": 180, "quantity": 4.5, "unitPrice": 40 }
}
When you read that same row back via GET /lines or
GET /lines/{externalRowId}, the money fields read back the SAME way —
nested under money — plus a read-only money.currency (the company's
market currency, EUR or SEK for an SE-market company) and the
resolved money.vat:
{
"batchId": "sept-2026-01",
"externalRowId": "row-01",
"workerId": "worker-1042",
"type": "work",
"status": "accepted",
"jobId": "job-778",
"description": "Interpreting 4.5h",
"money": {
"amount": 180,
"quantity": 4.5,
"unitPrice": 40,
"vat": { "treatment": "standard", "rate": 25.5 },
"currency": "EUR"
},
"createdAt": "2026-09-09T12:00:00.000Z"
}
What money carries — and which sub-fields are present — varies by
type. money.currency is the one field EVERY row carries, always:
work / compensation — money.amount is the row's NET total.purchase — money carries ONLY currency. This type's gross
amount(s) and VAT live entirely on items[] instead (see "Purchase
lines" below) — there is no row-level money.amount/money.vat to read
for a purchase row at all, uniform-rate receipt or not.allowance (perDiem/mealAllowance/mileage) — NO
money.amount/money.quantity/money.vat, ever: allowances stay
VAT-free and take their quantity from the line's own facts, never a
declared total. For mileage/mealAllowance, money.unitPrice is
present ONLY when you sent a rate override (see "Overriding the
allowance rate" below) — omit it and money carries just currency.
perDiem never carries unitPrice, on read OR write (issue #3553,
ruling A, 2026-09-16). It reads back symmetric with how you send it:
money.wholeDay/money.partialDay/money.night, one key per component
you actually overrode (see "Per-diem money: component overrides" below)
— each stored component echoes independently and verbatim, so a mixed
trip overriding BOTH wholeDay and partialDay gets BOTH back; a
component you never sent is simply absent; money carries just
currency when no component override was sent at all. The row
otherwise reports FACTS:
derived ({wholeDays, partialDays}) + origin/destination
(structured waypoints) + trip ({departureAt, returnAt, days}) for
perDiem, km (or drives) + vehicle for mileage, count
(+workHours/location) for mealAllowance — see "Allowance lines"
below. Any row may also carry a read-only advisories array — see
"Allowance lines" below for the one value this slice ships.A purchase row reads back like this — note money carries only
currency, and the gross amount + resolved VAT live on items[], one
entry per submitted item:
{
"batchId": "sept-2026-01",
"externalRowId": "row-02",
"workerId": "worker-1042",
"type": "purchase",
"status": "accepted",
"description": "Hotel, Helsinki",
"items": [
{ "amount": 100, "vat": { "treatment": "standard", "rate": 25.5 } }
],
"money": { "currency": "EUR" },
"createdAt": "2026-09-09T12:00:00.000Z"
}
An allowance row (here, mileage, registry-priced — no rate override
was sent) reads back with money carrying only currency — no
amount/vat/unitPrice — plus the vehicle{base,variant?} originally
submitted:
{
"batchId": "sept-2026-01",
"externalRowId": "row-03",
"workerId": "worker-1042",
"type": "allowance",
"kind": "mileage",
"status": "accepted",
"km": 42,
"vehicle": { "base": "car" },
"money": { "currency": "EUR" },
"createdAt": "2026-09-09T12:00:00.000Z"
}
If the partner had instead sent a money.unitPrice rate override on
write (mileage/mealAllowance only), the same field echoes back next
to currency — see "Overriding the allowance rate" below. (perDiem's
own override echoes back under its OWN field names instead —
money.wholeDay/money.partialDay/money.night, never unitPrice —
see "Per-diem money: component overrides" below.)
You write rows via POST /batches / POST /batches/{batchId}/lines,
and read them back via GET /lines. To correct a row's money (or any
other field), PUT /batches/{batchId}/lines/{externalRowId} while the
batch is still DRAFT — a full replace, re-gated exactly like intake:
PUT /batches/sept-2026-01/lines/row-01
{
"type": "work",
"worker": { "workerId": "worker-1042" },
"externalRowId": "row-01",
"jobId": "job-778",
"description": "Interpreting 5h",
"money": { "amount": 200, "quantity": 5, "unitPrice": 40 }
}
Once the batch has submitted and left DRAFT, the line is invoiced and
immutable — PUT then 409s batch-not-draft; see "Managing lines in a
DRAFT batch" below for the full rules, and "Quarantine remediation" above
for the resubmit path once a batch is no longer DRAFT.
Allowance lines (perDiem, mealAllowance, mileage) are a
Finnish-market feature, with two exceptions: mileage and, as of
2026-09-14, perDiem also reach storage AND an invoice on a
Swedish-market company (the separate purchase type does too — see
"Purchase lines" below). A Swedish mileage or perDiem row is accepted
and stored the same way as any other row — GET /lines reads it back —
then proceeds to Truster's Swedish operator review (Gate 1), exactly like
any other Swedish row (see "Swedish-market batches" above): once accepted
there, it invoices through the Swedish salary lane. GET /allowance-rates
(above) can show a Swedish company its real, currently-priced mileage AND
per-diem rates, and a submitted SE vehicle{} is validated against that
exact table at intake. mealAllowance alone still rejects outright at
INTAKE for a Swedish-market company — it has no SE terminus:
mealAllowance rejects not-valid-for-market (Sweden has no
meal-allowance instrument at all — meal cost is priced into the SE
schablon mileage/per-diem rates themselves, structurally, not merely
unimplemented — see that kind's own section below). That row is never
stored for an SE-market company; se-expenses-unsupported (the SE
bridge's quarantine slug) now covers mealAllowance should it ever reach
processing instead of being rejected earlier at intake, plus a handful of
defensive edge cases (an unrecognized vehicle token, an ambiguous
multi-kind bundle) — perDiem, on Sweden, has its OWN dedicated
se-perdiem-* processing-quarantine vocabulary instead (see
perDiem's own section below). If your company bills on the Swedish
market, mileage, perDiem, and purchase lines are payable today;
mealAllowance lands in a later release — ask your account manager for
the current status on that.
Submit an allowance or purchase line on POST /batches or the append
route, exactly like a work or compensation line; it goes through the same
worker-resolution, identity-hint, and externalRowId-dedupe gates every
other line does. There is no limit on how many allowance/purchase lines
one worker can have in one batch — one claim, one line, submit as many as
you need — EXCEPT four cross-line constraints, each enforced per worker
within a batch (and carried across an append call too, not just within
one request):
mileage line (see that kind's own section below for why);perDiem lines may not cover overlapping periods;mealAllowance line may not land on a date a perDiem line already
covers;mealAllowance counts for the same date may not sum past 2,
even across separate lines.Each of these is a quarantined outcome (never rejected) with its own
named slug — see the per-kind sections below.
Allowances are for workers billed as individuals. On a Finnish-market
company, an allowance line for a worker whose agreement bills them as a
business (sole proprietor — see "A batch can produce more than one
invoice" above) is rejected allowance-for-business-worker; the batch's
other lines are unaffected. Send it as a work line with an amount. If the
worker's agreement changes after the line was accepted, processing holds
that line instead (quarantined, same slug) and invoices the rest of the
batch — see "Quarantine remediation" above.
Every allowance and purchase line shares four fields: worker, an
optional jobId (same send-it-when-you-have-one rule as work lines), a
required externalRowId (the dedupe key, same rule as every other
line), a date (when the expense was incurred — the meal date, the
per-diem/mileage day, or the receipt date), and a description —
optional for mealAllowance and mileage, but REQUIRED for a purchase
line (say what the expense was for) and, unconditionally, for a perDiem
line (state the trip's purpose — matkan tarkoitus — specifically,
naming the assignment and client, and the mode of travel when relevant;
Verohallinto requires a stated purpose for a tax-free per diem — see
that kind's own section below). A mileage line submitted via drives
also requires a description PER DRIVE — see "Diary-grade mileage drives"
below.
perDiem, mealAllowance, mileage (all type: "allowance")
are FACTS FIRST — you declare what happened (how many days, how many
meals, how far), never a total amount. By default Truster prices
these at the statutory registry rate (per diem and meal-allowance
rates, the mileage rate for the vehicle type) when the batch is
invoiced. Optionally, each accepts a money RATE override that replaces
the registry rate for that line — money.unitPrice (per meal for
mealAllowance, per km for mileage); perDiem instead accepts up to
three independent per-component overrides, money.wholeDay/
money.partialDay/money.night (see its own paragraph just below and
"Per-diem money: component overrides" further down) — omit money
entirely and the registry rate applies exactly as before. It's a rate,
not a total:
amount/quantity/vatTreatment aren't accepted on allowance
money (strict — sending one rejects the row, invalid-line); the quantity
still comes from the line's own facts, and allowances stay VAT-free
either way. See "Overriding the allowance rate" below.purchase is the one type with a real, partner-known total — but it
carries that total on items[], not money (which for this type is
never anything but currency). Each of the 1..50 items has its own
amount and optional vatTreatment/description — because a receipt
has a real total (what the receipt says) for EACH thing it covers,
unlike the rate-only override the other three accept. See that type's
own section below for the full shape and the GROSS-vs-net distinction on
items[].amount.perDiem — a daily allowance, priced at the statutory registry rate
unless you send an optional per-component money override (wholeDay/
partialDay/night — never unitPrice; see "Per-diem money: component
overrides" below). Accepted on BOTH markets, on the same wire
shape — but FI and SE derive day counts, eligibility, and pricing three
genuinely different ways, so the rest of this subsection (through the
duration table, the rate-override rules, and "Reading a perDiem row
back") describes FI's rules specifically; see "perDiem on a
Swedish-market company" further below for SE's own rules, which reuses
the identical trip/destination/origin shape described here but
diverges on almost everything else (day-counting, the 50 km rule, money,
and what GET /lines echoes back).
You declare a real trip window, trip: {departureAt, returnAt, days?} —
there is no flat days/partial count to send; Truster derives day
counts (and eligibility itself) purely from the window's duration, per
Verohallinto's hour rule:
| Trip duration | Days earned |
|---|---|
| Under 6 hours | Not per-diem eligible — rejected outright, perdiem-not-eligible, naming the 6-hour floor |
| 6 to 10 hours (inclusive) | 1 partial day |
| Over 10 hours, under 24 hours | 1 whole day |
| 24 hours or more, remainder over 6 hours | ⌊duration ÷ 24h⌋ + 1 whole days |
| 24 hours or more, remainder over 2 and up to 6 hours | ⌊duration ÷ 24h⌋ whole days, plus 1 trailing partial day |
| 24 hours or more, remainder 2 hours or less | ⌊duration ÷ 24h⌋ whole days |
("remainder" is the duration left over after subtracting whole 24-hour
blocks. A trip earns at most one trailing partial day, however long it
runs; a genuinely mixed trip — N whole days plus a trailing partial one —
is a normal, common shape, not something you request explicitly.)
departureAt/returnAt are RFC3339 timestamps (returnAt strictly after
departureAt; the window may not exceed 31 days, else rejected). A
multi-trip journey is multiple lines, one trip each.
destination (the required, structured {address, postCode, city}
waypoint — same shape used elsewhere on this API, e.g. a mileage drive's
own stops) is where the worker traveled TO. origin — where the worker
traveled FROM — is OPTIONAL: send it, or omit it and Truster falls back to
the worker's own home address on record (established via POST /workers). The fallback is used ONLY for eligibility/distance purposes,
never stored as though the partner had sent it — GET /lines omits
origin from a fallback line's echo entirely (see "Reading a perDiem
row back" below), and instead flags it via the read-only advisories
array: origin-fallback-worker-home. If the worker doesn't exist yet, or
exists with no home address on record, there is nothing to fall back to
and the line is rejected outright, perdiem-origin-required — either send
origin explicitly on the line, or establish the worker (with a home
address) first and resubmit. This is a genuine REJECT, not a quarantine:
no row is stored, so there is nothing for a later PUT to target —
resubmission is a fresh line, not an edit. Per-diem eligibility depends on
the distance FROM where the trip started, so a trip with no knowable
origin at all cannot be priced — this isn't an arbitrary gate.
description is REQUIRED on every perDiem line — it is the trip's
purpose (matkan tarkoitus), and Verohallinto requires one, specific,
stated purpose per trip for a tax-free per diem (name the assignment and
client, and the mode of travel when relevant).
A worker's perDiem lines in one batch may not cover overlapping periods
(adjacent, back-to-back periods are fine — a line ending exactly when the
next starts is not an overlap): a second, colliding line is quarantined,
perdiem-overlap. A perDiem line also may not cover a date the same
worker already has a mealAllowance claimed for — see
meal-on-perdiem-day under mealAllowance below (the check is
two-way; whichever kind lands first for a given worker+date wins).
{
"type": "allowance",
"kind": "perDiem",
"worker": { "workerId": "worker-042" },
"jobId": "job-778",
"externalRowId": "exp-2026-0142",
"date": "2026-09-03",
"description": "Client workshop, Rovaniemi office",
"trip": {
"departureAt": "2026-09-01T07:00:00+03:00",
"returnAt": "2026-09-04T12:00:00+03:00"
},
"origin": { "address": "Mannerheimintie 12", "postCode": "00100", "city": "Helsinki" },
"destination": { "address": "Hallituskatu 7", "postCode": "96100", "city": "Rovaniemi" }
}
A trip crossing a border declares its foreign travel days on
trip.days[] — up to 40 {date, country} entries (ISO-3166-1 alpha-2),
one per travel day spent abroad; a date not listed is domestic, and
omitting days entirely means the whole trip is domestic. Each date must
be one of the trip's OWN derived travel days (from the table above) and
may not repeat — either violation rejects perdiem-day-invalid. Pricing
follows per day, per country: a domestic day prices at FI's statutory
rate, a declared foreign day prices at that country's own statutory
foreign per-diem rate.
On an FI-market trip, date is the Finnish local calendar date
(Europe/Helsinki) the travel day's own 24-hour block starts on. (SE
derives travel days a different way — see below — but also on local
dates.) Truster slices the trip into 24-hour blocks measured from
departureAt itself (not from midnight), and dates each block by the
Helsinki date of its start instant. To work out the dates: take
departureAt's date on a Helsinki clock, then add 24 hours per block.
A worked example. A single-day trip departing
2026-09-01T02:00:00+03:00 and returning 2026-09-01T20:00:00+03:00 is
18 hours long, so it earns one whole day, dated 2026-09-01 — the
date on the Helsinki clock when the trip left. A longer trip from the
same departure has travel days 2026-09-01, 2026-09-02, and so on. The
day is read on a Helsinki clock whatever offset you write:
2026-08-31T23:00:00Z is the same instant and the same 2026-09-01
travel day. The rate year follows the same date, so a block starting at
00:30 on 1 January Helsinki time prices at the new year's rate.
{
"trip": {
"departureAt": "2026-09-01T00:00:00Z",
"returnAt": "2026-09-04T00:00:00Z",
"days": [{ "date": "2026-09-02", "country": "DE" }]
}
}
That example is a three-whole-day trip with ONE foreign day: 2026-09-01
and 2026-09-03 are domestic days (FI), 2026-09-02 is German. Truster
prices each at its own country's statutory rate. Note what that means for
an override: this trip spans TWO countries, so it may not carry a money
override — see just below.
Very short straight-line trips are rejected as a distance sanity
check, independent of the duration table above: a trip whose origin and
destination are a straight-line distance under 5 km apart — below the
floor no real road route could stretch past the statutory 15 km
per-diem-eligibility distance from — rejects perdiem-distance-ineligible.
A straight-line distance between 5 km and 15 km is not rejected (a real
route can plausibly exceed 15 km even when the straight line doesn't) but
does carry a distance-unverified advisory. A trip Truster cannot geocode
either endpoint for (an unverifiable address, or a geocoding outage) is
never rejected on distance grounds either — it's accepted with an
address-unverifiable advisory instead; see "Reading a perDiem row
back" below for the advisories array in general.
Overriding the rate on a foreign-day trip: a money override
(wholeDay and/or partialDay) is rejected outright,
perdiem-rate-override-multi-country, on any trip that SPANS more than
one country — no single rate is correct for a trip spanning two different
statutory rates. Count the countries over the trip's derived travel days,
not over trip.days[]: a day you did not list is a domestic (FI) day
and counts. So a three-day trip with one declared German day spans FI
and DE and may not be overridden, while the same trip with all three days
declared DE spans one country and may. An all-domestic trip (no days
at all) is one country too. Declaring a day as "FI" explicitly is
allowed and changes nothing — it is the same domestic day it would have
been if you had left it out. See "Per-diem money: component overrides"
below for the full reasoning and the fix (omit money).
An override to a destination Truster cannot quote a rate for is
rejected, perdiem-rate-override-unverifiable. Every destination in
that year's published Verohallinto table (the one GET /allowance-rates
exposes) can be quoted, so this is rare in practice — it means the table
carries no rate for that country this year. Without a rate there is no
ceiling to check your override against, and an unchecked override fails
later, when the invoice is generated, rather than here. The trip itself is
fine: omit money and the same line is accepted and priced normally.
This reject is only ever about the override.
Reading a perDiem row back: GET /lines reports derived: { wholeDays, partialDays } (both counts, honestly — never collapsed to a
single ambiguous flag) instead of the old flat days/partial, plus
origin/destination as the same structured waypoint objects you sent
(or, for destination, always did — it's required), and trip: { departureAt, returnAt, days } — departureAt/returnAt echo
byte-identical to what you sent, never normalized, and days[] lists only
the trip's declared FOREIGN travel days (a domestic day is simply absent).
trip.waypoints[] from the pre-2026-09-11 form is gone — the destination
address now lives on the row's own top-level destination instead.
One thing does NOT round-trip on origin/destination: a waypoint's own
description is accepted on the wire but not stored (per-diem's internal
place has no slot for it — a mileage drive's own waypoints keep theirs).
lat/lng, when sent, DO round-trip, same as on a mileage drive's own
waypoints.
A perDiem row stored before 2026-09-11 (via the old
days+partial+origin(string)+destination(string) form, now retired)
omits trip from its read echo entirely, rather than reconstructing one:
that form's window was Truster-SYNTHESIZED internally, never a real
partner-submitted instant, and echoing a synthesized value under a field
documented as "as originally submitted" would be misleading regardless of
how many such rows exist.
This read echo is honest but INCOMPLETE for an SE-market row with any
foreign trip.days[]. derived.wholeDays/derived.partialDays sum
only each stored trip's DOMESTIC counts — an SE row's foreign-day/night
breakdown is stored internally (for core-service's own pricing) but isn't
summed into derived and has no other read-back field today. origin/
destination/trip.days[] still echo exactly as described above (days[]
lists your declared foreign days with their country), so your own
submission remains the record of what was foreign — derived just isn't
the place to total it.
perDiem on a Swedish-market company (since 2026-09-14) — the SAME
wire shape as FI's (trip{departureAt, returnAt, days?}, a required
destination, an optional origin, a required description), but
Skatteverket traktamente is derived and gated on entirely different
rules. Read this subsection instead of the FI rules above for an
SE-market company.
Day-counting: Stockholm-local calendar days, not 24-hour blocks. Every
travel day is the Europe/Stockholm wall-clock calendar date the instant
falls on — contrast FI's 24-hour blocks above. An övernattning
(overnight stay) is required: a trip must span at least one complete
Stockholm-local 00:00–06:00 window — a trip that never covers a full
00–06 night (including one that crosses midnight but returns before
06:00) derives no travel days at all and is rejected outright,
perdiem-not-eligible. Otherwise:
| Day | hel dag (whole) / halv dag (half) |
|---|---|
| Departure day | hel dag if local departure time is strictly before 12:00, else halv dag (12:00 itself is halv) |
| Return day | hel dag if local return time is strictly after 19:00, else halv dag (19:00 itself is halv) |
| Every full calendar day in between | Always hel dag, regardless of the two rules above |
One natt (night) is earned per full Stockholm-local 00:00–06:00 window
the trip covers — NOT simply per calendar-day boundary crossed (issue
#3544): the final night only counts if the return is at/after 06:00
local. Night i prices in the country of the day it follows — the day
the worker went to sleep in, never the day it leads into.
Foreign days: trip.days[] works the same way as FI's — a day not
listed is domestic ("SE") — but each listed country must be "SE"
itself (a no-op, same as omitting the entry) or a destination GET /allowance-rates' perDiem.foreign prices for Sweden (Skatteverket's
own utlandstraktamente/normalbelopp table); otherwise the row is rejected
at intake, perdiem-country-not-priced, same slug FI uses for the same
reason. A day's halv dag/natt price abroad is exactly half that
country's whole-day normalbelopp.
money IS accepted on an SE perDiem line (since 2026-09-15) — as a
per-component override, DOMESTIC components only. Send money.wholeDay/
money.partialDay/money.night (öre-exact SEK — see below) to override
Skatteverket's own domestic schablon (300/150/150 SEK for hel dag/halv
dag/natt, 2026) for that component; each is capped at that year's own
schablon rate (allowance-rate-exceeded above it, same posture as FI).
A foreign day always prices at that country's own normalbelopp,
regardless of money — there is no partner-set rate slot for a foreign
day, so an SE override never reaches past the domestic components it
names, and a multi-country trip is never ambiguous the way it is for FI
(no perdiem-rate-override-multi-country on SE). See "Per-diem money:
component overrides" below for the full FI/SE breakdown, and "Overriding
the allowance rate"'s own SE-öre-rounding note (se-non-exact-minor) —
every SE money override, per-diem included, must convert to a whole
number of öre.
The 50 km rule is verified at PROCESSING, not at intake — and against a
different address than you might expect. Skatteverket requires the
trip's destination to be at least 50.0 km by ROAD from the worker's home.
Truster does zero distance work at intake (no geocoding, no rejection on
this ground here) — the row is accepted and stored, then verified later
when the batch is handed to Truster's Swedish processing lane, using
the worker's own linked Truster account profile home address (set by
the worker themselves in their Truster app) — never this line's
origin, and never the address you may have supplied for this
worker via POST /workers, which is a completely different record. The
verdict then arrives as a per-line quarantine, resolved the same way any
other post-DRAFT quarantine is: resubmit the corrected row's
externalRowId in a NEW batch (see "Quarantine remediation" above) —
none of these are retried by re-driving the same batch.
| Slug | Stage | Meaning | Fix |
|---|---|---|---|
perdiem-not-eligible |
Intake (reject) | The trip covers no full Stockholm-local 00:00–06:00 window — no övernattning. | Send a trip that actually requires an overnight stay. |
perdiem-day-invalid |
Intake (reject) | A trip.days[] date isn't one of the trip's own derived (Stockholm-local) travel days, or repeats. |
Fix the date(s). |
perdiem-country-not-priced |
Intake (reject) | A trip.days[] country isn't "SE" and isn't priced in GET /allowance-rates' perDiem.foreign. |
Check the country code, or ask Truster to price that destination. |
se-perdiem-unsupported-shape |
Processing (quarantine) | Defensive: the stored bundle doesn't have the shape this package's own intake always writes. | Should not occur through normal use of this API; contact Truster if seen. |
se-perdiem-empty |
Processing (quarantine) | Defensive: the row derives zero hel/halv days and zero nights. | Should not occur once intake's own övernattning gate has passed; contact Truster if seen. |
se-perdiem-country-not-priced |
Processing (quarantine) | A foreign day's country passed intake's own gate, but Truster's internal Skatteverket schablon table doesn't yet carry that year/country — OR the year's schablon doc isn't seeded at ALL yet, which this same gate also catches for a DOMESTIC-only per-diem row carrying a money override (the override-drift check just below needs that year's schablon rates too, whether or not the trip has any foreign day). Either way, an operational seeding gap, not something your data got wrong. |
Resubmit this row in a new batch once Truster has re-seeded the rate/year. |
se-perdiem-override-exceeds |
Processing (quarantine) | A supplied money component (wholeDay/partialDay/night) exceeds THAT YEAR's Skatteverket schablon rate for that same component — registry drift: intake's own cap (allowance-rate-exceeded) checked against a different, or since-changed, schablon snapshot than the one processing resolved. |
Resubmit the corrected row (at or below that component's current schablon rate) in a new batch. |
se-perdiem-worker-unlinked |
Processing (quarantine) | The worker has no linked Truster user account — the 50 km rule needs that account's own profile address. | Have the worker link their account (e.g. redeem a shareCode, or sign up), then resubmit. |
se-perdiem-no-home-address |
Processing (quarantine) | The linked account exists but its profile has no usable home address (needs at least a street and a city). | The WORKER must complete their own profile — this is not a field the partner API can set — then resubmit. |
se-perdiem-measure-cap |
Processing (quarantine) | This processing run already verified 20 distinct home→destination distances — a per-run cap. |
Resubmit the remaining rows in a separate batch. |
se-perdiem-distance-unverifiable |
Processing (quarantine) | The road-distance measurement failed or returned no figure (transient) — fail-closed, never silently accepted. | Resubmit this row in a new batch. |
se-perdiem-under-50km |
Processing (quarantine) | Measured under 50.0 km by road — genuinely ineligible. | Not fixable by resubmitting the same trip; Skatteverket's distance floor wasn't met. |
Rate discovery: GET /allowance-rates (above) shows a Swedish
company's live perDiem.whole/.partial/.night (the domestic
schablon) and perDiem.foreign (the utlandstraktamente table, one
{country, whole, partial} entry per priced destination) — the exact
same response FI companies get, just Skatteverket's numbers instead of
Verohallinto's. There is no separate SE-specific rates endpoint.
After the gates above, an accepted SE perDiem row proceeds to Truster's
Swedish operator review (Gate 1) exactly like any other Swedish row (see
"Swedish-market batches" above) — once accepted there, it invoices
through the Swedish salary lane.
A worked example — a three-day trip from Sweden into Norway and back, Monday morning to Wednesday evening, with the middle day spent in Oslo:
{
"type": "allowance",
"kind": "perDiem",
"worker": { "workerId": "worker-se-1077" },
"jobId": "job-2201",
"externalRowId": "exp-2026-0431",
"date": "2026-09-07",
"description": "Tjänsteresa till Oslo — kundworkshop, tåg och taxi",
"trip": {
"departureAt": "2026-09-07T08:00:00+02:00",
"returnAt": "2026-09-09T20:00:00+02:00",
"days": [{ "date": "2026-09-08", "country": "NO" }]
},
"destination": {
"address": "Karl Johans gate 14",
"postCode": "0154",
"city": "Oslo"
}
}
This trip departs before 12:00 (hel dag) and returns after 19:00 (hel dag), with one full calendar day in between (always hel dag) — 3 hel dagar and 2 nätter total. Tuesday (2026-09-08) is declared Norwegian: Monday and Wednesday price at Sweden's own domestic schablon (2 hel dagar, 1 natt — the night following Monday, priced in Sweden), Tuesday prices at Norway's own normalbelopp (1 hel dag, 1 natt — the night following Tuesday, priced in Norway). The row is accepted, stored, and (assuming the worker's linked account has a usable home address measuring ≥ 50 km by road from Oslo) survives processing's own gates undisturbed.
mealAllowance — a meal allowance, priced at the statutory registry
rate unless you send an optional money.unitPrice override (per meal —
see "Overriding the allowance rate" below). Finland/Åland-only, enforced
at intake: unlike perDiem and mileage (both fully supported on a
Swedish-market company now — see this section's own intro above), a
mealAllowance line from an SE-market company is rejected outright,
not-valid-for-market — Sweden has no meal-allowance instrument at all;
meal cost is priced into the SE schablon mileage/per-diem rates
themselves, structurally, not merely unimplemented here. Don't submit
mealAllowance lines for an SE-market company. count,
1 or 2 on this line — but at most 2 total per worker per DATE, summed
ACROSS every mealAllowance line for that date: a third line (or a second
line whose count would push the sum past 2) is quarantined,
meal-daily-limit. workHours (3-24) is the hours worked on the meal
date — REQUIRED when count is 2, and must then be MORE than 11 (a second
tax-free meal allowance is only earned past an 11-hour workday); optional
for a single allowance. location (the same
{address, postCode, city, description?} waypoint shape as mileage stops)
is where the work was done / the meal was taken — optional, but recommended
tax evidence: meal allowances are Finland/Åland-only and the address is
verified downstream, and the waypoint's own description carries the
visited party, as elsewhere on this API. A meal allowance also may not land on a date a
perDiem line already covers for the same worker (double-claiming the
same day as both a meal and a per-diem day) — quarantined,
meal-on-perdiem-day. This check is TWO-WAY: it applies whichever kind
was established (accepted or appended) FIRST for that worker+date — a
mealAllowance line arriving after a conflicting perDiem line is
quarantined, and equally a perDiem line arriving after a conflicting
mealAllowance line is quarantined. Whichever line loses the race can be
recovered by resubmitting once the earlier line is adjusted or removed;
submission order matters here, same as any other dedupe gate on this API.
{
"type": "allowance",
"kind": "mealAllowance",
"worker": { "workerId": "worker-042" },
"jobId": "job-778",
"externalRowId": "exp-2026-0158",
"date": "2026-09-03",
"count": 2,
"workHours": 12.5,
"location": {
"address": "Hämeentie 100",
"postCode": "00550",
"city": "Helsinki",
"description": "Asiakas Oy / Anna Virtanen"
}
}
mileage — a distance-based travel claim, priced at the statutory
registry rate for the resolved vehicle unless you send an optional
money.unitPrice override (per km — see "Overriding the allowance rate"
below). Carries a required vehicle{} alongside km.
vehicle — three orthogonal axes: a required base, an optional
market-specific variant, and optional extras. base (car /
motorcycle / other) is a stable identity shared across both markets;
other is FI's catch-all bucket and isn't offered on SE at all. variant
narrows base to a specific, separately-priced sub-type your market
offers — electric is valid only with base:"car" (SE today), light
only with base:"motorcycle" (FI today). Omitting variant is not
"unspecified" — for base:"car" it means the combustion form
specifically (SE has no third, un-priced bucket to fall into); base: "other" has no valid variant at all. registration (an optional plate
number, any vehicle, never gated) and the extras — trailer (boolean),
tools (boolean), passengers (integer count, 0-8) — are a THIRD,
independent axis: priced add-ons on top of the resolved vehicle, not
alternate identities of it. Extras are priced for FI's plain car ONLY
(base:"car", no variant) — named passenger evidence isn't captured on
this contract, count only. Sending trailer:true/tools:true/a nonzero
passengers on any other resolved vehicle rejects the row
(vehicle-extras-not-priced); false/0/omitted never does, regardless
of vehicle.
The valid (base, variant) pairs per market, today:
| Market | base |
variant |
Meaning |
|---|---|---|---|
| FI | car |
(none) | Car (combustion) |
| FI | motorcycle |
(none) | Motorcycle |
| FI | motorcycle |
light |
Light motorcycle |
| FI | other |
(none) | Other (catch-all) |
| SE | car |
(none) | Car (combustion) |
| SE | car |
electric |
Electric car |
| SE | motorcycle |
(none) | Motorcycle |
This table is a snapshot — query GET /allowance-rates (above) for the
always-current, discoverable version; each of its mileage rows carries the
exact vehicle{base,variant?} shape to submit back, so your own table
never has to be kept in sync by hand.
Two distinct rejects when vehicle doesn't fit your market — know which
half was wrong. A base your market doesn't offer AT ALL (today, only
SE + base:"other") rejects vehicle-type-invalid-for-market — the
vehicle itself isn't sold here. A base that IS right for your market,
but the variant you asked for isn't priced for it (e.g. FI
car+electric, or SE motorcycle+light) rejects
vehicle-variant-invalid — the vehicle is right, the sub-type isn't.
Both, like every other vehicle gate, reject outright (never quarantine):
not operator-recoverable, resend a corrected vehicle.
km, the distance driven — any distance above 0 bills; there is no
minimum (kilometrikorvaus is owed for every kilometer driven for work —
the 15 km rule in Vero's guidance applies to per diems, not mileage). At
most 2000 km per line (above that, rejected mileage-exceeds-maximum). At most
ONE mileage line per worker per batch: a second one is quarantined for
review (duplicate-mileage) — merge the distances into a single line
before submitting. Because of that one-per-worker limit, a distance over
2000 km can't be split across two lines in the SAME batch either — submit
the remainder in a later batch instead.
On a Swedish-market batch specifically, km is also capped at ONE
decimal place. Truster's own Swedish mileage pricing works in tenths of
a kilometer (the schablon rate table's own unit, "mil" = 10 km); a km
with a second decimal (e.g. 10.55) is accepted at intake but quarantined
during processing (se-invalid-km) rather than silently rounded away —
resend the row with km rounded to one decimal. km of zero or negative
also quarantines the same way (se-invalid-km) for an SE row, on top of
the outright mileage-exceeds-maximum reject above. This cap is SE-only; an FI km has no decimal-place restriction of
its own.
{
"type": "allowance",
"kind": "mileage",
"worker": { "workerId": "worker-042" },
"jobId": "job-778",
"externalRowId": "exp-2026-0171",
"date": "2026-09-03",
"vehicle": { "base": "car" },
"km": 100
}
FI's plain car with extras — a trailer and one passenger, plate number as evidence:
{
"vehicle": {
"base": "car",
"trailer": true,
"passengers": 1,
"registration": "ABC-123"
}
}
SE's electric car — base:"car" + variant:"electric", no extras (SE
prices no extras at all):
{ "vehicle": { "base": "car", "variant": "electric" } }
By default, every perDiem/mealAllowance/mileage line is priced at
the statutory registry rate GET /allowance-rates publishes (see
"Allowance rates" above) — the common case, and what you get by omitting
money entirely. mealAllowance and mileage each take a single-rate
override, money.unitPrice — described in this section. perDiem uses
a genuinely different shape, money.wholeDay/money.partialDay/
money.night — never unitPrice — because a single trip can earn up to
three independently-priced components at once; see "Per-diem money:
component overrides" below for the full breakdown.
For mealAllowance/mileage, send the override as money.unitPrice: a
per-unit RATE, not a total — per km for mileage, per meal for
mealAllowance. The quantity still comes from the line's own facts
(km, count) — you never send a total, and
amount/quantity/vatTreatment aren't accepted on allowance money
(strict; sending one rejects the row, invalid-line).
The rate is capped at the statutory registry rate for this line's
market and date (the same table GET /allowance-rates shows you) — for
mileage, specifically the rate for the line's own resolved vehicle,
never a fixed vehicle's rate regardless of what was actually declared —
paying (or billing) LESS than statutory is a legitimate, ordinary case
and is always accepted; a money.unitPrice ABOVE that rate is rejected
at submit, allowance-rate-exceeded, and never stored — resend with a
corrected rate. Check GET /allowance-rates for the exact ceiling before
sending an override you're unsure about. perDiem's own components are
capped the same way, independently per component — see below.
perDiem accepts up to THREE independent per-component rate overrides —
money.wholeDay, money.partialDay, and (SE only) money.night — never
a single unitPrice. A trip can earn a whole day, a trailing partial
day, and (for Sweden) a night, all in the SAME trip; one flat rate cannot
name which of those it prices, which is why perDiem has its own money
shape instead of reusing unitPrice (a perDiem line's money is
strictObject-validated — sending unitPrice on it fails schema
validation, invalid-line; see the migration note below). Omit any
component (or money entirely) and Truster prices it at the statutory
registry rate; send only the components you want to override — a
component this trip doesn't happen to earn (a trip with no partial day,
say) is accepted and simply unused, never an error. Like every allowance
override, these are rates, never totals: amount/quantity/
vatTreatment aren't accepted here either (invalid-line), and per-diem
stays VAT-free either way.
Each supplied component is capped independently against that year's
statutory rate for this line's market and date (GET /allowance-rates)
— a component above its own ceiling is rejected at submit,
allowance-rate-exceeded, naming which component (whole-day/partial-day/
night), and never stored; a component at or below its own ceiling is
accepted, same posture as mileage/mealAllowance above.
wholeDay/partialDay is the statutory rate
of the country the trip is spent in: FI's own domestic rate for a
domestic day, that country's own foreign per-diem rate for a foreign
day (which can be LOWER than FI's own — Verohallinto's table has many
such destinations, and the ceiling drops accordingly; a trip to a
country rated below FI's own rate cannot be overridden up to FI's
rate). If that country has no rate in the year's table at all, there is
no ceiling to check against, and the override is rejected rather than
accepted unverified — perdiem-rate-override-unverifiable (the trip
itself is unaffected: omit money and it's accepted and priced at the
statutory rate). money.night is REJECTED OUTRIGHT on an FI-market
row, whatever its size — perdiem-night-not-supported-for-market —
Finland has no statutory per-diem night rate at all; omit night and
wholeDay/partialDay are unaffected.GET /allowance-rates' perDiem.whole/.partial/.night shows your
company's own live domestic ceilings; perDiem.foreign (unaffected by
money) shows the per-country normalbelopp table. Every SE money
override, per-diem included, must also convert to a whole number of öre
— see this section's own SE-mileage öre-rounding note below
(se-non-exact-minor).A perDiem override is rejected outright on a trip that SPANS more
than one country, perdiem-rate-override-multi-country — FI ONLY. No
single rate is correct for a trip spanning two different statutory
rates: count countries over the trip's own DERIVED travel days, not over
trip.days[] — a day you did not list is a domestic (FI) day and counts,
so a three-day trip with one declared German day spans FI and DE and may
not be overridden, while the same trip with all three days declared DE
spans one country and may. An all-domestic trip (no days at all) is one
country too; declaring a day as "FI" explicitly changes nothing. This
can look like an arbitrary restriction if you only see "rejected", so
here's the reasoning: a single override rate can never be correct for a
trip that spans more than one country's own statutory rate — whichever
ceiling you pick is wrong for some of the days, and the rate you send is
applied to EVERY day of the trip, including the ones in the other
country. Omitting money already prices the trip correctly, for free:
each day is priced individually, at its own country's statutory rate —
that's exactly what a multi-country trip needs, and it's what happens
automatically once you don't send money. A trip spent entirely in one
country (all-domestic, or every derived travel day declared as the same
foreign country) is unaffected by this reject; there, a single ceiling —
that one country's own statutory rate — genuinely is correct, and an
override is accepted exactly as described above. This reject never
applies to an SE-market row — an SE override only ever prices the
trip's domestic components, so a foreign day in the same trip is never
ambiguous the way it is for FI: it simply prices at its own normalbelopp
regardless of what money said.
{
"type": "allowance",
"kind": "perDiem",
"worker": { "workerId": "worker-042" },
"jobId": "job-778",
"externalRowId": "exp-2026-0192",
"date": "2026-09-03",
"description": "Client workshop, Rovaniemi office",
"trip": {
"departureAt": "2026-09-01T07:00:00+03:00",
"returnAt": "2026-09-04T12:00:00+03:00"
},
"origin": { "address": "Mannerheimintie 12", "postCode": "00100", "city": "Helsinki" },
"destination": { "address": "Hallituskatu 7", "postCode": "96100", "city": "Rovaniemi" },
"money": { "wholeDay": 45, "partialDay": 20 }
}
A Swedish trip overriding all three domestic components at once (a foreign day in the same trip, if any, is unaffected and still prices at its own normalbelopp):
{ "money": { "wholeDay": 280, "partialDay": 140, "night": 140 } }
GET /lines echoes the override back per component, symmetric with the
write side (issue #3553, ruling A, 2026-09-16). money reports each
STORED component independently — money.wholeDay/money.partialDay/
money.night — never the flat unitPrice key mileage/mealAllowance
use. If you sent BOTH wholeDay and partialDay on a mixed trip (a
normal, common override — both price INDEPENDENTLY at invoicing), BOTH
echo back here too, each under its own key. A component is omitted only
when you never sent it — never because the trip's own derived shape
didn't happen to earn that day type: a wholeDay override you sent on a
trip that lands entirely as partial days still echoes as
money.wholeDay (this is a verbatim echo of what you sent and what's
stored, not a reflection of what the trip actually earned — see
derived for that). money.night echoes the same way, on either
market, whenever one was sent. money carries none of the three (just
currency) when no component override was sent at all — the common,
registry-priced case. Your own submission remains the authoritative
record either way. This is never a computed total either way —
partner-api doesn't run the pricing engine, so a registry-priced
component you didn't override reports nothing.
GET /lines reads the mixed-trip example above back like this — both
components, alongside currency:
{ "money": { "wholeDay": 45, "partialDay": 20, "currency": "EUR" } }
Migration note (2026-09-15): money.unitPrice is no longer accepted on
a perDiem line. Before this date, perDiem shared the SAME single-
rate money.unitPrice shape mileage/mealAllowance still use;
perDiem's money is now its own strictObject of wholeDay/
partialDay/night only, so sending unitPrice on a perDiem line
today fails schema validation and rejects the row, invalid-line.
Replace a money.unitPrice per-diem override with money.wholeDay or
money.partialDay (whichever component it priced) — see the FI/SE
breakdown above for which components each market can override, and note
that SE's blanket money reject (se-perdiem-override-unsupported) is
retired at the same time: an SE perDiem line can carry money now,
domestic components only.
On a Swedish-market mileage line specifically, a money.unitPrice
override must convert to a WHOLE number of öre per km — Truster's SEK
minor-unit math never carries fractional öre. 0.25 SEK/km (25 öre/km) is
fine; 0.255 SEK/km (25.5 öre/km) is not — that row is accepted at intake
(like the decimal-km case above) but quarantined during processing
(se-non-exact-minor) rather than silently rounded to the nearest öre.
This applies to every SE money override, not just mileage — but mileage is
where you're most likely to hit it, since a real-world per-km rate
negotiated in whole SEK (e.g. 2.50 kr/km) is exact, while one derived by
splitting a per-mile or other non-metric rate often isn't. FI has no such
constraint (EUR cents tolerate the same two-decimal-place inputs this
override already expects).
{
"type": "allowance",
"kind": "mileage",
"worker": { "workerId": "worker-042" },
"jobId": "job-778",
"externalRowId": "exp-2026-0171",
"date": "2026-09-03",
"vehicle": { "base": "car" },
"km": 100,
"money": { "unitPrice": 0.25 }
}
GET /lines echoes the override back as money.unitPrice, alongside the
money.currency every row carries:
{ "money": { "unitPrice": 0.25, "currency": "EUR" } }
This is never a computed total — partner-api doesn't run the pricing
engine, so a registry-priced allowance's money carries no total either
(just currency; see "The line shapes — write vs. read" above). The
echo is exactly the rate you sent, never quantity × unitPrice or any
other derived figure — if you need the price Truster will actually bill,
ask your account manager; it isn't returned by this API today.
While the batch is still DRAFT, correct a rate the same way you'd
correct any other field on the row — a full
PUT /batches/{batchId}/lines/{externalRowId} replace carrying the new
money (unitPrice for mileage/mealAllowance, wholeDay/
partialDay/night for perDiem — see "Managing lines in a DRAFT
batch" below). Once the batch has submitted, the rate is locked with the
rest of the row: PUT then 409s batch-not-draft, same as any other
post-submit edit attempt.
A purchase line bills a general receipt claim (a hotel, a tool, a
purchase) — as 1..50 independently-priced, independently-VAT-resolved
items[], not one flat amount. Beyond the shared four fields, it carries
items (REQUIRED, 1-50 entries) and no other field; note description
(the shared field) is REQUIRED for this type specifically (what the
expense was for overall; it substantiates the claim alongside the receipt
and renders on the invoice row).
Every purchase line must carry a receipt before it can invoice. The
items[]/description fields above only price the claim — they don't
substitute for the actual receipt file. A purchase row reaching
processing with no receipt attached (via POST /lines/{externalRowId}/receipt — see "Receipts" below) quarantines
receipt-missing; every OTHER line type (work, compensation, and the
three allowance kinds — perDiem/mealAllowance/mileage) is unaffected,
since none of them carry a receipt slot at all. This is enforced at
processing time, one row at a time — a batch with nine priced-and-receipted
purchases and one still missing its receipt invoices the nine and
quarantines only the tenth, never the whole batch. Attach the missing
receipt and resubmit (or, for a batch still DRAFT, re-drive processing)
to clear it — see "Quarantine remediation" below.
Each entry in items[] is:
amount (required) — this ONE item's RECEIPT GROSS — what you
actually paid for it, INCLUDING VAT — not a net figure, the one
exception to every other amount field on this API (work/compensation
amounts, and this same concept in every OTHER context, are always net).
Truster bills each item's net by dividing its declared gross by
(1 + that item's resolved VAT percent ÷ 100). Up to 6 fractional
digits, same precision rule as every other money field on this API; a
non-positive or over-precise amount rejects the WHOLE row as
receipt-item-invalid, naming the offending items[N].vatTreatment (optional) — this ONE item's VAT treatment override.
Every item resolves independently — omit it and that item falls back to
the batch's default vatTreatment or the market standard, exactly like
a work/compensation row's money.vatTreatment — but because
resolution runs per item, a single receipt can genuinely mix rates (see
the multi-item example below). A treatment your market doesn't offer
rejects the same way, receipt-item-invalid.description (optional) — a label for this ONE item ("Taxi",
"Hotel — night 1"), distinct from the row's own required description
above (which is what's rendered on the invoice line, unchanged).A single-rate receipt is a 1-element array:
{
"type": "purchase",
"worker": { "workerId": "worker-042" },
"jobId": "job-778",
"externalRowId": "exp-2026-0183",
"date": "2026-09-03",
"description": "Hotel, Helsinki",
"items": [{ "amount": 100 }]
}
A single receipt covering several differently-taxed things is a
multi-item array — each item resolves its OWN treatment, so nothing stops
a hotel invoice's room charge (standard) and a VAT-exempt city tax line
item on the same bill from sitting on one purchase row:
{
"type": "purchase",
"worker": { "workerId": "worker-042" },
"jobId": "job-778",
"externalRowId": "exp-2026-0184",
"date": "2026-09-03",
"description": "Hotel, Helsinki — 2 nights + city tax",
"items": [
{ "amount": 180, "vatTreatment": "standard", "description": "Room, 2 nights" },
{ "amount": 6, "vatTreatment": "exempt", "description": "City tax" }
]
}
GET /lines reads all of this back, allowance and purchase alike — see
"Reading your rows" above for the general read shape. Each item carries
kind (allowance rows only), date, and description back, plus
status/quarantineReason/quarantineDetail for a quarantined row.
Every row's money carries at least currency; what ELSE it carries
depends on type/kind: a purchase row's money carries currency
ONLY — its gross amount(s) and resolved VAT live entirely on items[]
instead, one entry per submitted item, { amount, vat?, description? }
(vat is that item's resolved { treatment, rate }, omitted for a
legacy item stored before per-item VAT tracking — never guessed); the
mileage/mealAllowance rows' money carries unitPrice ONLY when you
sent a rate override (see "Overriding the allowance rate" above); a
perDiem row's money instead carries wholeDay/partialDay/night,
one key per component you overrode (issue #3553, ruling A, 2026-09-16 —
see "Per-diem money: component overrides" above), NEVER unitPrice —
none of the three allowance kinds' money ever carries
amount/quantity/vat (allowances take their quantity from their own
facts and stay VAT-free either way). Beyond money/items, a perDiem
row carries derived{wholeDays,partialDays}, structured origin/
destination waypoints, and trip{departureAt,returnAt,days} — see
"Allowance lines" above for the full shape; a mealAllowance row carries
count (plus workHours and location,
when they were sent); a mileage row carries km (plus drives,
likewise, when submitted that way) and vehicle{base,variant?}, the
vehicle as originally submitted. date reads back as a full ISO-8601
instant
(2026-09-03T00:00:00.000Z, midnight UTC of the calendar date you sent),
not the bare YYYY-MM-DD string you submitted — a genuine format change
across the request/response boundary, not a byte-identical echo. kind
(and every kind-adjacent field) can be ABSENT on a row stored before this
flat shape existed, or on one whose stored data doesn't unambiguously
match exactly one kind (e.g. a pre-redesign multi-kind bundle) — the row
still reads back (never a 500), just without those fields.
Every line type — work, compensation, allowance, and purchase — reports its
OWN invoiceId once it is actually invoiced: present once the row's
status is invoiced, absent otherwise (including for every row of a
Swedish-market batch, which never invoices through this surface — see
"Swedish-market batches" above). A line carries its own id rather than one
derived from its batch because a split batch (see "A batch can produce more
than one invoice" above) produces up to two invoices, and a given row is
only ever on one of them. Fetch that invoice's own fields — number, dates,
totals — from GET /batches/{batchId}/invoices, which lists every invoice
of the row's batch.
A row may also carry warnings, present only when at least one applies:
expense-billed-as-work means this row — a purchase expense — landed
on a sole-proprietor (business-entity) invoice, where it
was billed as a plain work line at its own amount rather than as an expense
reimbursement (its worker's agreement routes them as a business, and a
business invoices its own expenses). The amount billed is unchanged; only
how it was billed differs from what you sent.
mileage lines also accept a diary-grade alternative to a single flat
km total: drives with their real start and end times and real
waypoints. Use this when you track the underlying trips yourself and
want each one to round-trip back through GET /lines, not just the
claimed total distance. (perDiem's
own trip window — always a real trip{departureAt, returnAt, days?},
never a flat total — is documented under "Allowance lines" above, not
here; unlike mileage, per-diem has no separate "flat" form to contrast
against.) Declared drives are priced at the statutory rates GET /allowance-rates publishes — see "Allowance rates" above — unless the
line also carries a money.unitPrice rate override, exactly like the flat
km form (see "Overriding the allowance rate" above).
Mileage — drives — an alternative to a single km total: an array
of 1-40 drives, each {startDateTime, endDateTime, waypoints, km, description}. startDateTime and endDateTime are REQUIRED: when the
drive actually started and ended, as RFC3339 instants with Z or an
explicit offset (2026-09-03T08:10:00+03:00) — the same rule as a
per-diem trip's departureAt/returnAt. A time without an offset is
refused (invalid-line), and so is a drive that sends a date instead.
endDateTime must be strictly after startDateTime; a drive that ends at
or before it starts rejects its line, mileage-drive-window-invalid,
naming the drive (drives[1].endDateTime …) — the rest of the batch is
unaffected. Truster stores each drive's window exactly as sent, and a
drive's days are the dates as written in its startDateTime and
endDateTime (2026-09-21T01:00:00+03:00 is a 2026-09-21 drive).
description is REQUIRED: it is the drive's purpose (Finnish matkan
tarkoitus, Swedish ärende), and tax authorities expect it to be
specific — name the assignment and the client ("Lähitulkkaus asiakkaalle
X, Mikonkatu 13"), not a generic "client visit". A tax-free km allowance
is only defensible with a stated purpose per trip.
waypoints is 2-30 stops, in driving order (start, any via points, end),
each {address, postCode, city, description?} — city may never contain
a comma (it's the last piece of an internal composite address; a comma
there would make the split back into address/city ambiguous), but
address itself may. Put WHO OR WHAT WAS VISITED on each stop's own
waypoint description ("Asiakas Oy / Anna Virtanen") — Skatteverket's
körjournal requirements include the visited party, and Verohallinto's
matkakohtainen selvitys is strongest when each address is attributable.
Each waypoint also accepts optional lat/lng (decimal degrees,
real-range-validated: lat -90..90, lng -180..180 — send both together
or neither), and they round-trip back on drives[].waypoints[] exactly as
sent — the same is true of a per-diem origin/destination waypoint and
a mealAllowance line's own location (see "Allowance lines" above);
only a waypoint's own description is ever dropped, and only outside a
mileage drive (a drive's waypoint keeps its description too). The line's
total distance is the SUM of every
drive's km, subject to the same 2000 km claim cap as the bald km
form — send km or drives, never both. vehicle is still required
alongside drives, exactly as with a bald km total.
{
"type": "allowance",
"kind": "mileage",
"worker": { "workerId": "worker-042" },
"jobId": "job-778",
"externalRowId": "exp-2026-0196",
"date": "2026-09-03",
"vehicle": { "base": "car" },
"drives": [
{
"startDateTime": "2026-09-03T08:10:00+03:00",
"endDateTime": "2026-09-03T08:35:00+03:00",
"waypoints": [
{
"address": "Mannerheimintie 12",
"postCode": "00100",
"city": "Helsinki",
"lat": 60.1699,
"lng": 24.9384
},
{
"address": "Hämeentie 100",
"postCode": "00550",
"city": "Helsinki",
"description": "Asiakas Oy / Anna Virtanen"
}
],
"km": 8.4,
"description": "Lähitulkkaus asiakkaalle Asiakas Oy, Hämeentie 100"
},
{
"startDateTime": "2026-09-03T15:40:00+03:00",
"endDateTime": "2026-09-03T16:05:00+03:00",
"waypoints": [
{
"address": "Hämeentie 100",
"postCode": "00550",
"city": "Helsinki",
"description": "Asiakas Oy / Anna Virtanen"
},
{
"address": "Mannerheimintie 12",
"postCode": "00100",
"city": "Helsinki"
}
],
"km": 8.4,
"description": "Lähitulkkaus asiakkaalle Asiakas Oy, paluumatka"
}
]
}
GET /lines reads a drives-submitted mileage row back with a drives
array alongside its (summed) km total and the required
vehicle{base,variant?} — each entry the same {startDateTime, endDateTime, waypoints, km, description?} shape you sent,
address/postCode/city/km and the window all included exactly as sent,
plus lat/lng on any waypoint that carried them (a comma
inside a waypoint's own address round-trips intact). A bald km-only
mileage line never carries drives at all on the response — the field is
absent, never an empty array. (perDiem's own read shape — trip{},
structured origin/destination — is documented under "Allowance lines"
above.)
Declared distances are subject to route verification.
POST /lines/{externalRowId}/receipt attaches a receipt file to a
purchase line — multipart/form-data, one file field, up to 10 MiB, PDF
or JPEG/PNG/HEIC/HEIF only. externalRowId is your own id for the line,
as echoed on the batch ack and every line view — resolved with the same
quarantine-precedence rule as every other single-row lookup (see "Reading
your rows" above).
POST /lines/{externalRowId}/receipt
Content-Type: multipart/form-data; boundary=...
--...
Content-Disposition: form-data; name="file"; filename="hotel-receipt.pdf"
Content-Type: application/pdf
<file bytes>
--...--
A successful upload returns 201 with { externalRowId, filename } —
filename is your uploaded file's name (sanitized: path components
stripped, capped at 255 characters), which then also appears on that
row's GET /lines entry as receiptFilename. Absent on a row with no
receipt attached yet.
An externalRowId that doesn't exist, or belongs to a different company,
404s — checked before anything else, so a foreign line never leaks
whether the receipt feature itself is even available (see the 503 case
below).
Only type: "purchase" has a receipt slot. A work, compensation,
or allowance line (per diem, meal allowance, mileage) is not a receipt
claim — there is nothing internal for a file to attach to. Uploading to
one of those rows 422s not-a-purchase — the row itself may well
exist, it just can't take a receipt; check type on GET /lines before
uploading. This also covers a legacy row whose stored bundle carries more
than one kind at once (a pre-redesign artifact): since it's ambiguous
which kind it actually is, it's treated the same as a non-purchase row
and never accepts a receipt.
One receipt per line — a second upload REPLACES the first. There
is no way to attach more than one file to the same line; upload
again with the new file and the old one is gone (the response's
filename reflects whichever file is currently attached).
...and one line per receipt — 409 duplicate-receipt. The same rule
holds in the other direction: one receipt is evidence for exactly one
claim. Uploading a file that is already attached to another of your lines
is rejected, and the problem detail names the line that already holds
it, so you can tell at once which claim the file belongs to. Re-uploading
the same file to the SAME line is unaffected — that's the replace path
above, not a duplicate.
Getting this instead of a 201 usually means one receipt covering
several charges was being split across several lines. That split belongs
INSIDE one line: a purchase line carries items[], each with its own
amount and VAT rate (see "Purchase lines" above), so a single mixed-rate
receipt is a single line with several items — which is also what makes
its VAT resolve correctly per item.
The check compares file CONTENT, not filenames, so renaming a file
changes nothing. A second PHOTO of the same paper receipt is different
bytes and passes this gate — where receipt scanning is enabled it is
caught later instead, as duplicate-evidence (see below). Cancelled
(withdrawn) lines release their receipts, and so do lines in a batch
that processing terminally REJECTED: after cancelling a batch (or after
a batch rejection) you can attach the same file to a resubmitted line.
When the 409 names a line that is currently quarantined, the detail says
so — the file is parked on a row pending review, not backing a live
claim, and resolving that row's quarantine is the way to free it.
A receipt is required, and you attach it while the batch is still
open. Every purchase line must carry a stored receipt before it can
invoice: a purchase row reaching processing without one quarantines
receipt-missing (see "Purchase lines" above), and this route is how you
supply it. An upload is accepted only while the line's batch is DRAFT or
REVIEW_PENDING and the line itself is accepted or quarantined:
PROCESSING right now 409s
batch-processing — retry shortly, once the batch has settled;COMPLETED, REJECTED, DISPATCH_FAILED or
CANCELLED) 409s batch-closed;invoiced or withdrawn line 409s line-committed.A receipt on a finished batch's line could never bill: which of that batch's lines bill was settled when it processed. If a line still needs its receipt after its batch has finished, send the line again in a new batch and attach the receipt to the new line. Nothing is stored on a refused upload.
503 receipts-unavailable. Receipt storage is an opt-in deployment
feature; in an environment where it hasn't been configured, every call to
this route 503s receipts-unavailable rather than accepting an upload
it can't durably store. This is the same distinction /submit's own 503-
vs-502 note above draws for invoice processing: 503 means "this
environment isn't configured to accept this yet," not that anything is
broken with your request. Retry later or ask your account manager whether
receipt upload is enabled for your environment.
Where receipt scanning is configured for your environment (a separate,
independently-enabled deployment feature from plain receipt storage
above), every accepted upload is also submitted for asynchronous AI
scanning. The 201 response gains scanStatus: "processing", and the
row's GET /lines/GET /lines/{externalRowId} view gains a receiptScan
block:
{
"status": "processing" | "verified" | "rejected" | "error",
"checkedAt": "2026-09-09T08:15:00.000Z",
"merchant": "Statoil",
"receiptDate": "2026-09-08",
"currency": "EUR",
"grossTotal": 42.5,
"vendorCountry": "DE",
"rows": [
{
"description": "Fuel",
"grossAmount": 42.5,
"vatTreatment": "foreign-vat",
"vatCountry": "DE",
"observedVatPct": 19
}
],
"problems": ["gross-mismatch"]
}
Field presence tracks status — no other field renders until the async
scan lands (processing). Both a verified AND a rejected scan echo the
extract itself — merchant/receiptDate/currency/grossTotal, plus
vendorCountry when the extraction pipeline could resolve one, plus
rows, a lightweight per-row summary of what the scan read off the
receipt — since GET /lines is the source of truth for what the scan
found even if you happen to miss the corresponding webhook delivery (and
for a rejected scan, the underlying file is about to be deleted — see
below — so this may be the only place that extract survives at all).
problems is present only on rejected (there are none on a clean scan).
An error status carries only status/checkedAt — the scanner
infrastructure failed, not a verdict on your evidence, so there is nothing
to report, and error is terminal until you upload again. receiptScan
is absent entirely on a row with no receipt ever attached, or in an
environment where scanning isn't configured.
A rejected scan quarantines the line — but the scan owns ONLY its own
reason, in both directions. A scan that runs and finds a problem (see
the gating list below) flips the row to "status": "quarantined",
"quarantineReason": "receipt-scan-failed", with quarantineDetail
naming the problem slug(s) — the same quarantine vocabulary "Quarantine
remediation" above already describes — UNLESS the row is either (a)
already invoiced (a scan queued before the line invoiced still gets its
verdict stamped and its webhook fired, but the billed line is NEVER
quarantine-flipped; no new receipt can be uploaded to an invoiced line), or (b)
already quarantined for a DIFFERENT, unrelated reason (an unresolved
worker link, for instance) — that standing reason is left completely
untouched; a receipt-scan rejection never overwrites or erases a
different quarantine (the receiptScan block itself still gets stamped
either way, so you can see the scan's own verdict even while the row
stays parked for its original reason). Re-uploading a corrected (or
simply better-photographed) receipt while the batch is still open is the
fix path: it replaces the
file, re-runs the scan, and a verified result on the NEXT scan clears
the quarantine automatically — but, symmetrically, only when the standing
reason is still receipt-scan-failed.
Only a verified receipt's file persists. A rejected scan deletes the
stored object and clears receiptFilename off the row (re-upload starts
fresh); a verified scan keeps the file (it's now the tax evidence backing
a billable line); an infrastructure error also keeps the file (the
scanner being unavailable is Truster's problem, not evidence of anything
wrong with your receipt). One exception to the delete: a rejection from a
re-scan your own EDIT triggered (the foreign-currency fallback above)
keeps the file — the receipt itself verified once; only the edited amount
failed.
Once accepted, the receipt also appears in the worker's own records — a
purchase line's evidence isn't only company-side bookkeeping; the worker
sees it too, the same way any other receipt they submit themselves does.
Gating criteria — a scan rejects the evidence when ANY of these fire (non-exhaustive future additions are always named slugs, never silent):
| Slug | Condition |
|---|---|
not-a-receipt |
The uploaded image/PDF doesn't look like a receipt at all. |
low-confidence |
The extraction's own confidence in what it read is too low to trust. |
fx-unavailable |
The receipt is in a different currency than your claim and no exchange rate could be resolved for it (an unrecognized or unquoted currency). A foreign-currency receipt in a QUOTED currency is fine — see below. |
gross-mismatch |
Your claimed amount EXCEEDS the receipt's total by more than the tolerance — max(0.05, 1%) when the currencies match, max(0.50, 4%) after a foreign receipt is converted. Claiming LESS than the receipt total is always accepted (see partialClaim below). |
duplicate-evidence |
This receipt (same merchant, date, and total) is already attached to another of your rows — the line is quarantined as duplicate-evidence, the file is kept, and receiptScan.duplicateOfExternalRowId names the other row. Reaches here only for a re-photographed or re-scanned copy: a byte-identical file is rejected earlier and more directly, at upload (409 duplicate-receipt, above). |
unreadable-evidence |
The image could not be read after several attempts — the scanner retries every orientation, so this means the photo itself (too blurry, too dark, or several documents in one frame). The line is quarantined, the file is kept; upload a sharper photo of the receipt alone. |
A receipt held for review waits for a Truster reviewer. A review
verdict quarantines the line as receipt-needs-review and keeps the
file. A submitted batch carrying such a line waits in REVIEW_PENDING
with reviewPendingReason: "receipt_review_pending" while that held
receipt awaits Truster's review, and resumes when the receipt is approved,
rejected or removed from the review queue. A held line whose scan recorded
no receipt to review does not hold the batch: it stays quarantined and the
rest of the batch invoices without it. In rare cases (a receipt rejected
before this behaviour shipped, or one whose link to the line was not
recorded) no decision can reach the line, and the batch stays parked until
a Truster operator processes it. Two markers in problems record the
reviewer's decision:
| Slug | Meaning |
|---|---|
released-after-review |
On a verified outcome: the reviewer accepted the receipt and the line was released for billing. |
rejected-after-review |
On a rejected outcome: the reviewer rejected the receipt, or removed it from the review queue. The line moves to receipt-scan-failed and the file is kept; the batch invoices without it. Re-uploading a corrected receipt re-runs the scan. |
Editing a line after its receipt verified. The gross comparison above
runs when you upload, and the figure it compared against is stored with
the verdict — already converted into YOUR claim currency, foreign
receipts included. Every later PUT re-checks the edited amount against
that stored figure, so a PUT that would raise the line above it is
rejected — 422 gross-mismatch — and nothing is written: the line keeps
the content its verdict actually describes. The amount named in the 422's
detail is that stored comparable in your claim currency; for a
foreign-currency receipt this is the ECB-converted total, NOT the total
printed on the receipt itself. Everything else is unaffected: lowering
the amount (always safe — see partialClaim above), fixing a
description, or re-splitting items[] without changing the total all
behave exactly as before, and the verdict rides along unchanged. To
legitimately claim more, upload the receipt that covers the higher amount
— that re-runs the scan.
One narrow fallback exists for OLDER verdicts: a foreign-currency receipt
whose verdict predates the stored comparable cannot be re-checked
arithmetically, so a raise there is accepted and the receipt is
re-scanned against the new amount instead. The line returns to
receiptScan.status: "processing" (opening with the usual
expense.receipt.processing event) and the expense.receipt.processed
verdict follows, as after a re-upload — with one difference: if that
re-scan comes back gross-mismatch, the line is quarantined but the
receipt FILE IS KEPT (it verified the original amount; only the raised
figure is in question). Lower the amount and re-upload the same file, and
the next verified scan clears the quarantine.
Foreign receipts convert automatically. A receipt printed in another currency (a fuel receipt in SEK or PLN, say) is converted into your claim currency at the ECB daily rate before the totals are compared, with the wider tolerance above absorbing card-issuer spread. You never need to convert on your side — declare what you actually claim, in your own billing currency.
Claiming less than the receipt shows is fine. A claim below the
receipt's (converted) total verifies normally and the receiptScan block
(and the expense.receipt.processed webhook) carry "partialClaim": true
so the difference stays visible — e.g. you claim the business portion of a
receipt that also has private items on it.
Other findings (an implausible date, hasNonBusinessItems, a subjective
"doesn't look like a business expense" judgment) are surfaced as
non-gating observations only — they never quarantine a line; only the
four money/identity-shaped problems above do.
Foreign VAT is an observation, never your deduction. rows[].vatTreatment
is the scan's OWN reading of each receipt line — a value from the same
VAT-treatment vocabulary GET /vat-treatments documents, OR the special
value foreign-vat when the scan's jurisdiction module resolved the
vendor to a foreign country (from its VAT-prefix, a printed tax id, its
address, or — only when the currency itself is foreign — a last-resort
currency fallback). vatCountry names that resolved country, and
observedVatPct/an accompanying observed amount are simply the rate/
amount PRINTED on the receipt, when legible — they are facts about what
the receipt says, never a deductible input Truster computes with. This is
also why observedVatPct's ABSENCE is meaningful and distinct from an
observed 0: no field at all means no rate was printed to observe;
0 means the receipt printed a genuine zero-rated line. Critically, this
scanned rows[].vatTreatment/vatCountry/observedVatPct data lives
ONLY inside receiptScan — your own declared items[].vat (see "Purchase
lines" above) is never overwritten or shadowed by what the scan read; the
two are independent, and your declared VAT stays the voice the invoice is
actually built from.
POST /receipts/scan is a completely separate, STATELESS sibling of the
real upload route above — same multipart envelope (one file field, 10 MiB
cap, pdf/jpeg/png/heic only), but it never touches a line, never stores
anything, and never decides anything. Use it to show a worker or an
integrator what Truster's extraction will read off a receipt BEFORE
committing to a claim — a preflight, not a step in the real flow.
POST /receipts/scan
Content-Type: multipart/form-data; boundary=...
--...
Content-Disposition: form-data; name="file"; filename="lunch.jpg"
Content-Type: image/jpeg
<file bytes>
--...--
{
"merchant": "Kahvila Aurinko",
"receiptDate": "2026-02-03",
"currency": "EUR",
"grossTotal": 24.9,
"vendorCountry": "FI",
"jurisdictionSource": "address",
"rows": [
{
"description": "Lunch",
"grossAmount": 24.9,
"vatTreatment": "reduced",
"vatCountry": ""
}
],
"extractionOnly": true
}
extractionOnly: true is on every response, always. This endpoint
never writes a Spanner row, never touches GCS, never enqueues a scan task,
never fires a webhook, and never gates or quarantines anything. The
merchant/receiptDate/currency/grossTotal/vendorCountry/
jurisdictionSource/rows fields are the exact same extraction shape
"Receipt scanning" above documents (including rows[].observedVatPct's
presence-preserving rule — absent means no rate was printed to observe, an
explicit 0 means a genuine zero-rated line) — this route just returns it
synchronously instead of stamping it onto a line. The BINDING verdict —
the one that can quarantine a line or clear one to invoice — only ever
happens at POST /lines/{externalRowId}/receipt; a clean preview here is
not a guarantee that the same file will verify once actually attached
(the real route additionally compares the extracted total against the
line's declared claim).
Optional: get an advisory verdict alongside the extraction. Send two
extra multipart TEXT fields — declaredGross, a decimal string with at
most 6 fractional digits (e.g. "50.00"), and currency, an uppercase
ISO-4217 code (e.g. "EUR") — the figure and currency you'd be claiming
if you went on to attach this same receipt to a real line. Unlike this
preview route, the real upload route never takes these as separate
fields: it compares against the line's OWN already-declared amount and
your company's currency instead, since by the time you upload a receipt
the line already carries that figure. Here there is no line yet, so you
state the comparison directly. Send BOTH declaredGross/currency
together or NEITHER; sending exactly one is a 400 (validation-failed)
naming the missing field, not a silent fallback to extraction-only. Order
relative to the file field doesn't matter. With both fields present, the
response carries a verdict object:
{
"merchant": "Kahvila Aurinko",
"receiptDate": "2026-02-03",
"currency": "EUR",
"grossTotal": 24.9,
"vendorCountry": "FI",
"jurisdictionSource": "address",
"rows": [
{
"description": "Lunch",
"grossAmount": 24.9,
"vatTreatment": "reduced",
"vatCountry": ""
}
],
"extractionOnly": true,
"verdict": {
"outcome": "verified",
"problems": [],
"comparableGross": "24.90",
"classificationSlug": "",
"advisory": true
}
}
outcome is one of three values: verified, review (would be held for
human review at upload — not rejected outright, but not a clean pass
either), or rejected. problems lists the gating/classification slugs
that fired and CAN be non-empty even on a verified outcome — e.g.
partial-claim when the declared amount is a genuine under-claim against
the receipt (see "Claiming less than the receipt shows is fine" above,
which applies identically here). classificationSlug is the shared
classifier's own slug, "" on a clean read, letting a UI distinguish a
money mismatch from "this doesn't look like a receipt at all".
advisory: true is stamped on every verdict this route ever returns, as a
reminder of the sentence that matters most: verdicts returned here are
indicative only — the binding verdict happens when the receipt is
uploaded to a work line (POST /lines/{externalRowId}/receipt), which
re-extracts and re-compares independently and can legitimately disagree
(model nondeterminism, an FX day-boundary rollover, or automation flags
changing between your preview call and the real upload).
alreadySubmitted: true flags a byte-for-byte duplicate. Independent
of the verdict above, every call also checks whether this exact file's
bytes already back a receipt on a real line in your company — the SAME
duplicate-hash policy the real upload route enforces (a stored match
whose whole batch ended terminally REJECTED does not count, since that
line can never bill and has no other recovery path). The key is omitted
entirely (never sent as false) when no match is found. This check runs
against your company's REAL stored fingerprints even when your key has
mock mode enabled — only the extraction itself is faked in mock mode, the
duplicate check is not.
Rate-limited separately from your account's general quota: 10 calls
per minute per API key, independent of the per-minute budget your other
calls share. A 429 here means the preview quota specifically, not your
account being throttled.
Mock mode returns one fixed, fictional extraction. Where your key has
mock mode enabled, every call — regardless of what file you send (as long
as it passes the type check) — returns the same canned response:
merchant: "Mock Market", a single 12.50 EUR row, extractionOnly: true
(never a verdict — mock mode has no real extraction to compare declared
money against). The real extraction service is never called;
alreadySubmitted, as noted above, is still real.
Errors: 400 (not-a-receipt-file) for a content type outside the
allowlist, 400 (validation-failed) for a missing file, a malformed
declaredGross (not a plain decimal string, at most 6 fractional
digits), a malformed currency (not 3 uppercase letters), or supplying
only one of declaredGross/currency, 413 over the 10 MiB cap, 429
past the preview quota, 502 (preview-scan-unavailable) when the
extraction service isn't configured for this deployment or is
temporarily unreachable, 504 (preview-scan-timeout) when it doesn't
answer in time.
Every route in this section only works on a batch still in DRAFT. The
moment a batch leaves DRAFT — submitted, auto-proceeded, parked for
review, or terminal — its rows are frozen. Fetch-one keeps working
forever; edit, delete, and bulk-move all 409 (batch-not-draft) once
the batch has moved on. This is the same DRAFT-only rule
POST /batches/{batchId}/lines (append) and
DELETE /batches/{batchId} (cancel) already follow — line-level
management extends it, it never relaxes it.
Every route below addresses the line by externalRowId — your own id,
the same handle the ack echoes and every line view carries. There is no
separate internal handle to reconcile: unlike a stored row's Truster-
internal id (never surfaced on the wire at all), externalRowId is the
ONE id you ever need for a line, on every surface — the ack, the line
views, and every mutation route below.
GET /lines/{externalRowId} returns the single-row view GET /lines
returns per item — this works at any point in a row's life, DRAFT batch
or not. An id that's unknown or belongs to a different company gets the
generic "not found or does not belong to your company" wording, revealing
nothing.
PUT /batches/{batchId}/lines/{externalRowId}A full replace: the body is the exact same line object
POST /batches accepts for that row's type — no patch dialect.
It's re-gated exactly like intake: money, dates, the SAME period-aware
registry VAT resolution (a row without its own vatTreatment still
resolves against the batch's stored default, by its NEW work period),
worker resolution and the hints cross-check, and the same batch-scoped
cross-line allowance conflicts. The externalRowId dedupe check excludes
the row being replaced, so putting a row back with its own unchanged
externalRowId never self-collides as duplicate-row — but it still
quarantines duplicate-row if that externalRowId is already claimed by
a genuinely DIFFERENT accepted row.
Two fields are immutable on an update:
externalRowId cannot change — 400 immutable-external-row-id.type cannot change — 400 immutable-line-type. Converting a row
from work to purchase (or between any two types, including
allowance <-> purchase) isn't an update; delete the
row and re-add it under the new type instead — the freed dedupe slot
(below) accepts the same externalRowId again.An allowance row's kind MAY change (mileage ↔ perDiem ↔
mealAllowance) — all three share the same read home,
GET /lines/{externalRowId}, so nothing about the id's URL goes stale.
kind can no longer change to or from purchase, since the taxonomy
split makes that a type change (immutable) rather than a kind change.
If the row already carried an uploaded receipt (type: "purchase") and
the edit keeps it purchase, the receipt survives untouched even though
the rest of the row's content is fully replaced; only a type change AWAY
from purchase drops it (the object is best-effort removed from storage
after the edit commits).
Like intake, a gate failure the original POST would have REJECTED
outright comes back as a 400 here, under the same slug (e.g.
invalid-money, mileage-exceeds-maximum, vehicle-type-invalid-for-market,
allowance-rate-exceeded, invalid-vat) — nothing is stored, the row is
untouched. A gate failure that would have QUARANTINED
instead is accepted: the row is written with its new quarantined
outcome and reason, exactly like a fresh submission — an update can flip
a quarantined row to accepted by fixing what was wrong, or the reverse.
The response is 200 with the row's current view — the same shape
GET /lines/{externalRowId} would show right after.
DELETE /batches/{batchId}/lines/{externalRowId}Hard-deletes the row: 204, nothing left behind — unlike
DELETE /batches/{batchId} (cancelling the WHOLE batch), which
only ever soft-withdraws its rows and keeps them for audit (see "Batch
lifecycle" above). Deleting a line frees its externalRowId's dedupe
slot: dedupe only ever considers a STORED accepted/invoiced row, so
once the row is gone, the same externalRowId can be resubmitted — in
this batch or a brand-new one — and land a fresh accepted outcome
instead of quarantining duplicate-row.
404 covers an unknown id, a foreign one, one that exists in a different
batch, AND a repeat delete of a row already gone: if a client-side
timeout makes you retry a DELETE that actually succeeded the first
time, the retry's 404 is confirmation the row is already gone, not a
new failure (unlike the batch-level cancel above, a line delete is NOT
idempotent — there's nothing left to safely no-op against once the row
itself no longer exists). A purchase row's uploaded receipt, if any, is
best-effort removed from storage after the delete commits.
POST /batches/{batchId}/lines/moveMoves 1–500 lines out of the path batch (the source) into another
DRAFT batch you name in the body (targetBatchId) — atomically: every
listed line moves, or none do.
{
"targetBatchId": "batch-2026-09-invoiced",
"externalRowIds": ["row-abc", "row-def"]
}
externalRowId must currently belong to the source batch — a
single missing one 404s, naming every missing id, and moves nothing.mileage line for the
same worker, overlapping perDiem periods, a mealAllowance landing on
a perDiem day, the daily meal-allowance cap) — AND against each other:
two moved mileage lines for the same worker conflict with EACH OTHER,
even into an empty target. Any conflict 409s line-conflict, naming
every conflicting externalRowId and the intake slug it tripped, and
moves nothing. Money, dates, VAT, worker resolution, and externalRowId
dedupe are NOT re-run — a move is reorganization, not re-pricing; a row
keeps its already-resolved VAT and its dedupe claim regardless of which
batch owns it.200 { moved, targetBatchId }), the moved rows vanish from
the source batch's own GET /batches/{batchId} ack replay
entirely (their stored row no longer belongs there) and appear in the
target's, at fresh indexes continuing after the target's own highest
existing index — so a re-GET of the target can show index values
beyond what you originally submitted to it. Every other field —
externalRowId, outcome, VAT — is untouched.Every mutation above — edit, delete, move — is DRAFT-only; the moment a
batch is submitted (or otherwise leaves DRAFT), its rows become
read-only. Fetch-one (GET /lines/{externalRowId}) keeps
working forever, same as the list endpoint; nothing else does.
There is no line selector on /submit — cutting an invoice for a SUBSET of
a batch's staged work means composing a fresh batch and submitting that
instead:
POST /batches with lines: [] (or
omitted) and draft: true — an empty DRAFT container:
{ "batchId": "sept-partial-01", "lines": [], "draft": true }.POST /batches/{originalBatchId}/lines/move with targetBatchId
set to the new batch's id and externalRowIds set to every id you
want invoiced now — leave everything else behind in the original batch
for later.POST /batches/sept-partial-01/submit
— only the moved lines are invoiced; everything left behind in the
original batch stays DRAFT, still fully editable, until its own
turn.Instead of polling GET /lines/{externalRowId} to find out when a
receipt-scan finishes, register an https:// endpoint and Truster pushes
you an event the moment your receipt's scan starts and again when it
finishes. This is a small, standalone surface — registering and
unregistering webhooks — independent of everything above.
| Method | Path | Purpose |
|---|---|---|
POST |
/webhooks |
Register a new webhook: { url, events }, where events is 1–3 of the event types below. 201s { id, url, events, signingSecret }. Requires webhooks:write. 400 (validation-failed) for a non-https:// URL, a URL resolving to a private/internal/loopback address, or an unknown event type. 409 (webhook-limit) past 10 registrations for your company — delete one first. |
GET |
/webhooks |
List your company's registered webhooks: { webhooks: [{ id, url, events, status, createdAt }] }. Requires webhooks:read. Never includes signingSecret — see below. |
DELETE |
/webhooks/{webhookId} |
Unregister a webhook. 204 on success. 404 (not-found) for an unknown id or one belonging to another company — the two cases are indistinguishable on purpose, so a DELETE can never be used to probe for another company's webhook ids. Requires webhooks:write. |
POST |
/webhooks/{webhookId}/ping |
Send a webhook.ping test event to this webhook right now. 202s { eventId }. 404 (not-found) for an unknown id or one belonging to another company, same non-leak convention as DELETE above. Requires webhooks:write. See "Ping" below. |
The signing secret is shown exactly once — in the POST /webhooks
response, at creation time. GET /webhooks never echoes it, for any
registration, ever. Store it immediately; if you lose it, delete the
registration and create a new one.
A URL must use https:// and must not resolve to a loopback, link-local,
or private address (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16,
169.254.0.0/16, 127.0.0.0/8, IPv6 ::1/fc00::/7), localhost, or an
internal-only hostname (metadata.google.internal, any *.internal
domain) — this is checked at registration time to keep Truster's own
delivery attempts from ever being pointed at an internal service.
| Event | Fires when… |
|---|---|
expense.receipt.processing |
A receipt has been queued for AI extraction — after an upload (POST /lines/{externalRowId}/receipt), or after an edit that triggered the foreign-currency re-scan fallback (see "Editing a line after its receipt verified"). |
expense.receipt.processed |
That extraction has finished — data.outcome is verified, rejected, duplicate, unreadable, or error; see below. |
worker.status_changed |
A worker's lifecycle status (see GET /workers) transitioned — see below for exactly which transitions fire today. |
Every delivery is one JSON envelope:
{
"id": "evt_1a2b3c4d5e6f7g8h",
"type": "expense.receipt.processing",
"createdAt": "2026-09-09T08:14:52.000Z",
"data": {
"expenseId": "exp_9f8e7d6c5b4a",
"batchId": "batch-2026-09-01",
"filename": "fuel-receipt.jpg"
}
}
{
"id": "evt_1a2b3c4d5e6f7g8h",
"type": "expense.receipt.processed",
"createdAt": "2026-09-09T08:15:00.000Z",
"data": {
"expenseId": "row-2026-fuel-01",
"batchId": "batch-2026-09-01",
"outcome": "verified",
"receipt": {
"merchant": "Statoil",
"receiptDate": "2026-09-08",
"currency": "EUR",
"grossTotal": 42.5,
"vendorCountry": "DE",
"rows": [
{
"description": "Fuel",
"grossAmount": 42.5,
"vatTreatment": "foreign-vat",
"vatCountry": "DE",
"observedVatPct": 19
}
]
}
}
}
A rejected outcome carries data.problems (a plain array of slug
strings — not-a-receipt/low-confidence/fx-unavailable/
gross-mismatch, see "Receipt scanning" above) ALONGSIDE data.receipt
(a Truster reviewer's rejection of a held receipt carries
data.problems: ["rejected-after-review"] and no data.receipt);
a duplicate outcome carries data.problems: ["duplicate-evidence"] plus
data.duplicateOfExternalRowId, and a verified outcome may carry
data.partialClaim: true (see "Receipt scanning" above) —
the same extract the receiptScan read-surface block now also carries on
a rejection (see "Receipt scanning" above): showing you what was actually
read off the receipt is the point of telling you why it was rejected, and
GET /lines stays the source of truth for it too, in case you ever miss
this delivery:
{
"id": "evt_2b3c4d5e6f7g8h9i",
"type": "expense.receipt.processed",
"createdAt": "2026-09-09T08:15:00.000Z",
"data": {
"expenseId": "row-2026-fuel-02",
"batchId": "batch-2026-09-01",
"outcome": "rejected",
"problems": ["gross-mismatch"],
"receipt": {
"merchant": "Statoil",
"receiptDate": "2026-09-08",
"currency": "EUR",
"grossTotal": 12.0,
"rows": [
{ "description": "Fuel", "grossAmount": 12.0, "vatTreatment": "standard" }
]
}
}
}
rows[].vatTreatment/vatCountry/observedVatPct are the SAME
observation-only treatment fields "Receipt scanning" above documents —
foreign-vat names a row the scan resolved to a foreign vendor;
observedVatPct is absent (not 0) when no rate was legible on the
receipt. An error outcome means the scan itself could not be completed
(an infrastructure failure, not a verdict on the receipt) and carries
neither problems nor receipt — just expenseId/batchId/outcome;
the expense line's own receiptScan.status still reflects it, so
GET /lines/{externalRowId} remains the source of truth if a delivery is
ever missed.
data.partnerMeta, when present, echoes the line's own partnerMeta (the
same free-form key/value map you may have sent at intake) — present on
every outcome, never just verified.
Note that a batch's own lineCounts (GET /invoices/batches/{batchId})
reflect intake-time decisions only — a receipt scan that quarantines a
line AFTER intake shows up on that line's own view and on this webhook,
never as a change to the batch's counts.
worker.status_changedA worker's lifecycle status (the same field GET /workers and
GET /workers/{workerId} return: PENDING | ENROLLED | COMPLETED)
moved forward. Two shapes, distinguished by whether data.contract is
present:
{
"id": "evt_2c3d4e5f6a7b8c9d",
"type": "worker.status_changed",
"createdAt": "2026-09-09T09:02:11.000Z",
"data": {
"workerId": "drv-1042",
"status": "ENROLLED"
}
}
{
"id": "evt_3d4e5f6a7b8c9d0e",
"type": "worker.status_changed",
"createdAt": "2026-09-09T09:15:47.000Z",
"data": {
"workerId": "drv-1042",
"status": "COMPLETED",
"contract": {
"type": "employmentContract",
"signedAt": "2026-09-09T09:15:40.000Z"
}
}
}
data.workerId is always YOUR OWN identifier for the worker (the
workerId you supplied at POST /workers), never a Truster-internal id.
data.contract is present only when the trigger is a signed employment
contract; an account-linking transition never carries it.
Transitions that fire today (owner ruling — this list will grow; a
worker's own status field is always the current-state source of truth,
whether or not you've seen every event):
PENDING
(unlinked) got linked to a Truster account — the worker signed up and
was auto-linked to their existing row, Truster support linked them, or
you re-POSTed their workerId with a share code they gave you after
the fact (the "upgrade case" above). This is Truster-side linking of a
PRE-EXISTING roster row, and only that. It is NOT the same as an
ssn/businessId establishment that matches an existing account, or a
share code supplied at establishment time: either of those creates the
row already linked from birth — it is never PENDING — and emits
nothing, because there is no transition to report. An ssn match alone
is not enough to link at establishment: the worker's name and email or
phone must also match their Truster account. Otherwise the worker is
established PENDING, and links later (with this event) once the match
is confirmed.contract block above.status field
(GET /workers/{workerId}) rather than waiting on this event; the
event lands in a future release.No ordering guarantee. An ENROLLED event and a COMPLETED event
for the same worker can arrive close together (e.g. a Swedish worker
links their account right around when they sign) — deliveries are
independent HTTP requests and may be received out of the order the
transitions actually occurred in. Always trust the worker's own status
field over "the last event I saw" if the two ever disagree. That field is
also computed for YOUR OWN market and is authoritative only for you — a
webhook you receive because of a transition attributed to a different
market context is not a guarantee that your own read of the worker's
status changed at all.
Dedupe key. Unlike the receipt-scan events above, a duplicate
worker.status_changed delivery is NOT guaranteed to carry the same
envelope id — some emissions hop through an internal retryable queue
that mints a fresh evt_ id on every retry. Dedupe on the semantic key
(type, workerId, status, contract.signedAt) instead (omit
contract.signedAt from the key for an ENROLLED event, which never has
one) — that tuple is stable across any duplicate delivery of the same
real-world transition. Keep that dedupe record only for a bounded
retention window (a handful of hours comfortably covers delivery-retry
latency), never forever: an unlink followed by a later re-link
legitimately re-fires the exact same tuple, and a permanent dedupe store
would silently swallow that second, real transition.
Every delivery carries an X-Truster-Signature header of the form:
t=<unix-seconds>,v1=<hex>
<hex> is HMAC-SHA256(signingSecret, "<t>." + rawBody), hex-encoded —
rawBody is the exact bytes of the request body as sent (compact JSON, no
added whitespace); re-serializing a parsed copy before hashing will not
match. Recompute it yourself and compare against v1 before trusting a
delivery; reject anything that doesn't match, and reject a t too far in
the past (a generous window — several minutes — comfortably covers normal
delivery and retry latency without being loose enough to make an old,
captured signature meaningfully replayable).
Worked example — fake secret, illustrative only:
signingSecret: whsec_testsecrett: 1757404800rawBody:
{"id":"evt_1a2b3c4d5e6f7g8h","type":"expense.receipt.processed","createdAt":"2026-09-09T08:15:00.000Z","data":{"expenseId":"row-2026-fuel-01","batchId":"batch-2026-09-01","outcome":"verified","receipt":{"merchant":"Statoil","receiptDate":"2026-09-08","currency":"EUR","grossTotal":42.5,"vendorCountry":"DE","rows":[{"description":"Fuel","grossAmount":42.5,"vatTreatment":"foreign-vat","vatCountry":"DE","observedVatPct":19}]}}}computes to:
X-Truster-Signature: t=1757404800,v1=9d83d8c49d7c85a41e71f3ce56701996ef0aca588318a8fef78735ed64a20eb2
Node.js reference implementation:
const crypto = require("node:crypto");
function verify(signatureHeader, secret, rawBody) {
const [tPart, v1Part] = signatureHeader.split(",");
const t = tPart.slice("t=".length);
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
return v1Part.slice("v1=".length) === expected;
}
POST /webhooks/{webhookId}/ping sends a synthetic webhook.ping event to
one of your registered webhooks on demand — useful for confirming your
endpoint is reachable and that your signature verification (above) is
implemented correctly, without waiting for a real receipt-scan or worker
event. It's delivered exactly like any other event — same
X-Truster-Signature header, same retry/at-least-once contract — except
webhook.ping is not a subscribable event type: it is not in the events
array of any registration (Event types above), and sending one has no
effect on what that webhook is otherwise subscribed to. Your endpoint must
2xx it like any other delivery.
{
"id": "evt_1a2b3c4d5e6f7g8h",
"type": "webhook.ping",
"createdAt": "2026-09-09T08:14:52.000Z",
"data": {
"webhookId": "wh_9f8e7d6c5b4a"
}
}
data.expenseId and the event's type if you make delivery-triggered side effects act only once, or simply key on the envelope's own id, which is stable across every retry of the same event.2xx status, as fast as you reasonably can.
Do the actual work (writing to your own database, etc.) after
acknowledging if it's slow — don't make Truster's delivery wait on it.2xx response, a connection failure, or a timeout is
retried with backoff for approximately 24 hours from the first attempt,
then given up on. GET /lines/{externalRowId} is always the fallback
source of truth for a scan's outcome if a delivery is ever ultimately
lost.2026-10-08 — Breaking: FI per-diem travel days are Helsinki dates
(#5348). An FI-market trip's travel days, and every trip.days[].date
you declare, are now the Helsinki (Europe/Helsinki) calendar dates the
24-hour blocks start on, not their UTC dates. Only a trip whose blocks
start between midnight and 02:00 (winter) or 03:00 (summer) Helsinki time
sees a different date: a trip leaving
2026-09-21T01:00:00+03:00 has its first travel day on 2026-09-21
(before: 2026-09-20). An old UTC date that is no longer a travel day
now rejects perdiem-day-invalid. An old UTC date that is STILL a travel
day is accepted silently and lands on the earlier block — check every
declaration on such trips. Example: a trip from
2026-09-21T01:00:00+03:00 to 2026-09-22T05:00:00+03:00 (one whole day,
then a 4-hour partial day) declaring {"date": "2026-09-21", "country": "DE"} used to price the partial day in Germany (54 € + 39 € = 93 €); it now
prices the whole day in Germany (78 € + 25 € = 103 €), +10 €. The rate year
follows the new date. Lines stored before this change keep their stored
days. SE travel days were already Stockholm-local and are unchanged.
2026-10-08 — Breaking: a mileage drive carries its own start and end
time (#5335). Each drives[] entry now requires startDateTime and
endDateTime (RFC3339 with Z or an offset) in place of date; a drive
that still sends date, or a time without an offset, rejects
invalid-line. A drive that ends at or before it starts rejects its line,
mileage-drive-window-invalid, and the rest of the batch goes on. GET /lines echoes each drive's window exactly as sent; a drive's days (for the
batch period) are the dates as written in its startDateTime and
endDateTime. Before this, every
drives line was accepted at intake and then stopped its whole batch at
processing. A bald km total is unchanged.
2026-10-07 — An allowance for a worker billed as a business is rejected
(#5187). On a Finnish-market company, a perDiem, mileage or
mealAllowance line for a worker whose agreement bills them as a sole
proprietor rejects allowance-for-business-worker at intake (and on a
line edit); the rest of the batch is unaffected — send it as a work line
with an amount. Such a line already accepted before the worker's agreement
changed is held at processing (quarantined, same slug) and the rest of the
batch invoices; resend it as a work line in a new batch under a new
externalRowId. Before this, the line stopped the whole batch.
expense-billed-as-work now only ever marks a purchase line.
2026-10-06 — Breaking: a batch can now produce more than one invoice, one
per billed entity; invoiceId is replaced by invoices[] on batch reads
and /submit; GET /invoices/{invoiceId} is removed, replaced by GET /batches/{batchId}/invoices (#5099). A batch's workers split by their own
Truster agreement into up to two invoices — one for workers billed as
individuals (light entrepreneurs), one for workers billed as businesses
(sole proprietors); the businessId/ssn you send never decides this. See
"A batch can produce more than one invoice" and "Invoices: one per entity"
above for the full behavior. Highlights:
invoiceId is gone from GET /batches, GET /batches/{batchId}, and
POST /batches/{batchId}/submit. All three now always carry
invoices: [{ invoiceId, entity, status }] — empty until the batch is
invoiced, one entry per billed entity once it is.GET /invoices/{invoiceId} is removed. A split batch can hold more
than one invoice, so there is no single id left to address it by. Use the
new GET /batches/{batchId}/invoices (scope invoices:read, same as
before) to read every invoice of a batch — number, dates, totals — in one
call./submit now creates them
as drafts and stops — the batch reaches COMPLETED with each invoice
awaiting_send; Truster sends them from its own dispatch, and each
invoice's status turns sent_to_customer once it goes out. You are not
expected to call anything further to send them.GET /lines now reports invoiceId on every line type, not just
allowance/purchase, and a new warnings array:
expense-billed-as-work marks an expense row that landed on a
sole-proprietor invoice and was billed as a plain work line at its own
amount, because a business invoices its own expenses.GET /workers's (and every other workers route's) entity is now the
worker's agreement-routed billed entity for an FI-market company —
individual, business, or unknown (no usable agreement yet, e.g. not
linked) — never derived from businessId/ssn. SE-market companies are
unaffected: entity stays the businessId-presence signal there.reviewPendingReason: "relationship_unresolved" —
a batch with a worker whose agreement doesn't resolve to either entity
parks for review instead of being invoiced incorrectly; retry with
/submit once the worker's Truster agreement is sorted out.reviewPendingReason: "invoice_needs_review" — the
batch is held for Truster to review an invoice already created for this
batch.No deprecation window: the only production v2 traffic to date is Bahn test runs, and Bahn (and Youpret, on dev) were told before this deployed.
2026-10-06 — Breaking: a receipt can no longer be uploaded to a finished
batch or a committed line. POST /lines/{externalRowId}/receipt now
accepts an upload only while the line's batch is DRAFT or
REVIEW_PENDING and the line is accepted or quarantined. A finished
batch (COMPLETED, REJECTED, DISPATCH_FAILED or CANCELLED) answers
409 with the new batch-closed error; an invoiced or withdrawn line
answers 409 line-committed. PROCESSING still answers
batch-processing. Previously an upload was accepted in every state except
PROCESSING, but a receipt on a finished batch's line could never bill. To
supply a receipt for a line whose batch has finished, send the line again
in a new batch and attach the receipt there — see "Receipts" above.
2026-09-22 — New: per-company line-type policy, and GET /settings
(new scope settings:read) to read your configuration. Each company is
now enabled for a specific set of line types; the default is work +
compensation, with the allowance kinds and purchase enabled per
company. A row of a type you are not enabled for is rejected on its own
ack as line-type-not-allowed (per row — the rest of the batch is
unaffected) and never stored. The new GET /settings endpoint (scope
settings:read, added to standard key grants) returns your effective
allowedLineTypes together with your market, mock-mode state, enforced
rate limit, and the calling key's own scopes — see "Which line types you
may send" under Getting started. If your integration already sends
allowances or purchases, confirm with Truster that your company's policy
was widened before this shipped.
2026-09-16 — Breaking: GET /lines (and the PUT replacement response)
now echo a perDiem line's money per-component, symmetric with the write
side — money.unitPrice no longer appears on a perDiem row at all
(issue #3553, ruling A). Previously (see the 2026-09-15 entry just below)
the read side collapsed a per-diem override onto the SAME money.unitPrice
key mileage/mealAllowance use, reporting exactly ONE representative
component — partialDay when the trip's own derived shape earns a
partial day, else wholeDay — and never surfacing night at all. That
was lossy in two ways: a mixed trip overriding BOTH wholeDay and
partialDay only ever showed one of them (the other was still applied at
invoicing, just invisible on read), and a wholeDay override stored on a
trip that happens to land entirely as partial days showed NOTHING (the
selection rule looked for partialUnitCost, found none, and never fell
back). GET /lines now instead echoes each STORED component verbatim:
money.wholeDay/money.partialDay/money.night, one key per component
actually stored — a component you never sent is simply absent, and
money carries none of the three (just currency) when no override was
sent at all. Echo is keyed on what's STORED, never on what the trip's own
derived shape earns: an override you sent on a day-type this particular
trip doesn't earn still echoes (it went unused at invoicing, same as
before this change — see "Per-diem money: component overrides" above for
the full breakdown). mileage/mealAllowance are entirely unaffected —
both still echo money.unitPrice exactly as before.
This is a breaking read-shape change for any partner reading
money.unitPrice off a perDiem row specifically. The break window is
deliberately short: the per-component WRITE shape
(money.wholeDay/partialDay/night) has only been live since
2026-09-15, and the OLD write shape (a perDiem line's own
money.unitPrice) has been rejected outright, invalid-line, since that
same date — so no accepted row anywhere pairs an old-style write with the
now-retired old-style read; there is no row for which this change alters
what money was actually billed, only how a per-diem override reads back.
2026-09-15 — perDiem money is now a per-COMPONENT override
(wholeDay/partialDay/night), replacing the single money.unitPrice
rate every other allowance kind still uses; SE perDiem accepts money
for the first time. Previously perDiem shared mileage/
mealAllowance's single-rate money.unitPrice shape (mapped onto
whichever day-type the trip's own derived shape earned); it now carries
its own strictObject money shape instead — see "Per-diem money:
component overrides" (under "Allowance lines" above) for the full
breakdown. Highlights:
money.unitPrice is no longer accepted on a perDiem
line — sending it fails schema validation and rejects the row,
invalid-line. Replace it with money.wholeDay and/or
money.partialDay (whichever component your old override priced).perDiem accepts money for the first time — the 2026-09-14
entry's blanket reject, se-perdiem-override-unsupported, is RETIRED.
An SE override applies to the trip's DOMESTIC components only
(wholeDay/partialDay/night, capped at that year's own Skatteverket
schablon — 300/150/150 SEK, 2026); a foreign day always prices at that
country's own normalbelopp regardless of money, so perdiem-rate- override-multi-country (below) never applies to an SE row.money.night is FI's one hard reject — Finland has no statutory
per-diem night rate at all, so sending night on an FI-market row
rejects outright, perdiem-night-not-supported-for-market, whatever its
size; wholeDay/partialDay are unaffected.wholeDay and partialDay
independently in the same line — previously a single unitPrice
applied to whichever ONE day-type the trip actually earned; each
component is checked against its own ceiling now, regardless of which
the other component resolves to.GET /lines still echoes the override under the money.unitPrice key
— unchanged, and not extended to per-component — reporting whichever
ONE of wholeDay/partialDay the trip's own derived shape earned;
money.night is never echoed. See "Per-diem money: component
overrides" above for the full read-back caveat. Superseded 2026-09-16
(see the entry just above): GET /lines now echoes each stored
per-diem component independently (money.wholeDay/partialDay/
night) — money.unitPrice no longer appears on a perDiem row at
all.se-perdiem-override-exceeds — a
supplied component exceeds that year's Skatteverket schablon rate for
it (registry drift between intake's own cap and processing's own
schablon snapshot); resubmit the corrected row in a new batch. The
existing se-perdiem-country-not-priced quarantine now ALSO fires for a
domestic-only per-diem row carrying an override, when the year's
schablon doc itself isn't seeded (not just for an unpriced foreign
country) — see the slug table in "perDiem on a Swedish-market company"
above.mileage/mealAllowance are unaffected by this entry — both still take a
single money.unitPrice override, as described in "Overriding the
allowance rate" above.
2026-09-14 — SE perDiem is now supported, on the identical wire shape
FI uses. Previously an SE-market perDiem line was rejected outright at
intake (perdiem-not-supported-for-market); that reject is gone — the
same trip{departureAt, returnAt, days?} / destination / optional
origin shape FI uses is now accepted and priced for Sweden too, per
Skatteverket's own rules. See "perDiem on a Swedish-market company"
(under "Allowance lines" above) for the full rules; three points worth
knowing before you push volume:
perdiem-not-eligible.money is never accepted on an SE perDiem line — a new reject,
se-perdiem-override-unsupported — Skatteverket's schablon/normalbelopp
are statutory only. Superseded 2026-09-15 (see the entry just
above): SE perDiem now accepts a per-component money override for
its DOMESTIC components, and se-perdiem-override-unsupported is
retired.origin, and never the address you may have
supplied via POST /workers) — the verdict arrives as one of eight new
per-line quarantine slugs (se-perdiem-under-50km,
se-perdiem-distance-unverifiable, se-perdiem-no-home-address,
se-perdiem-worker-unlinked, se-perdiem-measure-cap,
se-perdiem-country-not-priced, se-perdiem-empty,
se-perdiem-unsupported-shape) resubmitted only in a NEW batch, never
retried by re-driving the same one. GET /lines' derived field
reports domestic day/partial counts only for an SE row with foreign
trip.days[] — see the read-back note in "perDiem on a Swedish-market
company" above.mealAllowance is unaffected by this entry and still rejects outright at
intake for a Swedish-market company (not-valid-for-market) — see
"Allowance lines" above.
2026-09-14 — New scopes invoices:read/invoices:write: the money
trigger is now a separate permission from data staging. POST /batches/{batchId}/submit requires invoices:write (previously
lines:write), and GET /invoices/{invoiceId} requires invoices:read
(previously lines:read). A lines:write credential can create batches,
append/edit lines, and upload receipts, but can no longer trigger
invoicing — mint invoices:write only on the credential that should be
able to bill. Note on the names: these are NOT the pre-2026-09-10 scopes
of the same spelling coming back with their old meaning — the old strings
covered all line writing/reading and were renamed to lines:*; the new
invoices:* pair covers only invoice creation and the payment-snapshot
read. Keys minted between 2026-09-10 and now need invoices:* added to
keep submitting; there are no such partner keys in production.
2026-09-12 — purchase lines now require a stored receipt before they
can invoice (receipt-missing). Every purchase line must carry a
receipt as evidence — enforced at processing (never at the original ack),
one row at a time: a purchase row reaching processing with no receipt
attached quarantines receipt-missing, while every sibling row in the same
batch is unaffected and still invoices normally. Only purchase is
affected; work, compensation, and the three allowance kinds
(perDiem/mealAllowance/mileage) never had a receipt slot and are
unchanged. Applies on both markets. Attach the missing receipt (POST /lines/{externalRowId}/receipt) and resubmit or re-drive processing to
clear it — the row is re-checked for a stored receipt and flips back to
accepted the moment one is present; see "Purchase lines" and "Quarantine
remediation" above. Composes with receipt scanning (next entry): a
rejected scan deletes its stored file, so the row re-blocks as
receipt-missing until better evidence arrives — missing → quarantined;
uploaded → scanned; rejected → deleted → missing again; verified → clear.
2026-09-12 — Receipt scanning goes live: scanStatus, the receiptScan
read-surface block, a new quarantine reason, and the first live webhook
emissions. Where receipt scanning is configured for your environment
(see "Receipts" / "Receipt scanning" above):
POST /lines/{externalRowId}/receipt's 201 response now carries
scanStatus: "processing" once the upload has been accepted and queued
for asynchronous AI scanning.GET /lines/GET /lines/{externalRowId} gain a receiptScan block on
purchase rows (processing → verified/rejected/error) — see
"Receipt scanning" above for the full field-by-status breakdown,
including the gating criteria and the foreign-VAT treatment fields.quarantineReason:
"receipt-scan-failed", the same quarantine vocabulary "Quarantine
remediation" documents elsewhere in this guide — with the scan's own
gating slug(s) named in quarantineDetail.receiptFilename
clears off the row. Re-uploading is the fix path and re-runs the scan.expense.receipt.processing and expense.receipt.processed (see
"Webhooks" above) are now ACTUALLY EMITTED for the first time on this
lane, for any webhook registered for them.Correction to the previously published expense.receipt.processed
example (this guide's own "Webhooks" section, written ahead of this
event family's live implementation): data.receipt.grossTotal is a
number, not a string (the earlier example showed "42.50"); data. problems is a plain array of slug strings, ["gross-mismatch"] —
never [{slug, detail}] objects — sitting ALONGSIDE data.receipt (a
rejected outcome carries both, not one or the other). Nothing was ever
emitted under the old, incorrect shape — no partner integration needs to
change; this note exists only so anyone who copied the earlier example
literally isn't left building against a shape that was never live.
2026-09-12 — SE mileage and purchase work lines are now payable.
Previously, an SE-market mileage or purchase row was accepted and
stored at intake but silently quarantined before invoicing
(se-expenses-unsupported) — no wire or schema change, but the outcome
is different now: both kinds proceed to Truster's Swedish operator
review (Gate 1), same as every other Swedish row (see "Swedish-market
batches" above), and once accepted there, invoice through the Swedish
salary lane. se-expenses-unsupported now primarily applies to
perDiem/mealAllowance (plus a few defensive edge cases — see
"Allowance lines" above) — those two kinds are unchanged by this entry and
still reject outright at intake for a Swedish-market company (see
"Allowance lines" above for their current status).
Two precision gates worth knowing before you push SE mileage volume: an
SE km with more than one decimal place quarantines se-invalid-km (see
the mileage kind's own section above), and an SE mileage money. unitPrice rate override that doesn't convert to a whole number of öre per
km quarantines se-non-exact-minor (see "Overriding the allowance rate"
above). Both are processing-time quarantines, same as
se-expenses-unsupported — accepted at intake, resolved on re-read.
2026-09-11 — Breaking: perDiem is now submitted ONLY via a real trip
window, trip{departureAt, returnAt, days?}, plus a required, structured
destination and an optional origin — the old days+partial+
origin(string)+destination(string) quartet is GONE, and so is trip's
own former waypoints[]. Day counts, eligibility, and (for a foreign
trip) per-country pricing are always DERIVED from the trip window and
trip.days[] — never sender-asserted.
// Before (either form accepted)
{ "kind": "perDiem", "days": 3, "partial": false, "origin": "Helsinki", "destination": "Rovaniemi" }
// or
{ "kind": "perDiem", "trip": { "departureAt": "...", "returnAt": "...", "waypoints": [{ "address": "...", "postCode": "...", "city": "Helsinki" }, { "address": "...", "postCode": "...", "city": "Rovaniemi" }] } }
// After (the only form)
{
"kind": "perDiem",
"description": "Client workshop, Rovaniemi office",
"trip": { "departureAt": "2026-09-01T07:00:00+03:00", "returnAt": "2026-09-04T12:00:00+03:00" },
"origin": { "address": "Mannerheimintie 12", "postCode": "00100", "city": "Helsinki" },
"destination": { "address": "Hallituskatu 7", "postCode": "96100", "city": "Rovaniemi" }
}
origin is now optional: omit it and Truster falls back to the worker's
own home address on record, purely for eligibility/distance purposes —
never stored as though the partner had sent it (see "Allowance lines"
above). destination stays required.trip.days[]
({date, country} pairs, ISO-3166-1 alpha-2) — a day not listed is
domestic. Pricing follows per day, per country. date is the travel
day's start date on a Helsinki clock (blocks are measured from
departureAt, not from midnight) — see the 2026-10-08 entry above.perDiem: perdiem-not-eligible (the
trip window earns no per-diem day at all), perdiem-day-invalid (a
trip.days[] date isn't one of the trip's own derived travel days —
or is listed twice),
perdiem-distance-ineligible (straight-line distance
too short to plausibly reach the statutory threshold), perdiem-origin- required (no origin sent AND no worker home address on record to
fall back to), and perdiem-not-supported-for-market (a Swedish-market
company — per-diem stays Finland-only). See "Allowance lines" above for
the full detail text and remedy for each.advisories array can appear on any line. Three values
ship in this slice: origin-fallback-worker-home, signalling that
origin was resolved from the worker's record rather than sent by the
partner (and, in that case, origin itself is deliberately ABSENT from
the echo — see "Allowance lines" above for why); distance-unverified,
on a trip whose straight-line origin-to-destination distance falls
between the 5 km floor and the 15 km statutory threshold; and
address-unverifiable, on a trip Truster could not geocode. Treat the
array as open-ended — parse values you know and ignore the rest, because
later slices will add more.money.unitPrice override on a per-diem line whose trip SPANS more
than one country is now rejected outright,
perdiem-rate-override-multi-country — no single rate is correct for a
trip spanning different statutory rates. Omitting money already prices
each day correctly, per its own country. Countries are counted over the
trip's derived travel days, so a day absent from trip.days[] counts as
the domestic (FI) day it is: a trip with one declared foreign day and
any unlisted day is a two-country trip. A trip spent entirely in one
country — all-domestic, or every derived day declared as the same
foreign country — is unaffected, and there the override's ceiling is
that country's own statutory rate, which may be LOWER than FI's.money.unitPrice override to a destination with no rate in this
year's table is rejected, perdiem-rate-override-unverifiable — with no
published rate there is no ceiling to check the override against, and an
unchecked one would fail at invoice generation instead. Rare in
practice. The trip itself is unaffected: omit money and the same line
is accepted and priced at the statutory rate.mealAllowance is confirmed Finland/Åland-only: a Swedish-market
company's mealAllowance line now rejects not-valid-for-market
(Sweden has no meal-allowance instrument — meal cost is priced into the
SE schablon rate itself).perDiem row now reports
derived: { wholeDays, partialDays } (replacing the old flat
days/partial), origin/destination as full waypoint objects
(replacing the old bare locality strings), and trip: { departureAt, returnAt, days } (trip.waypoints[] is gone — the destination address
now lives on the row's own top-level destination instead). See "The
line shapes — write vs. read" and "Allowance lines" above for the full
read examples.2026-09-11 — Breaking: a mileage allowance line's vehicle{} is now
REQUIRED. Every type: "allowance", kind: "mileage" line must now carry
vehicle: { base, variant? } — a stable base (car/motorcycle/other)
shared across markets, plus an optional market-specific variant
(electric for SE's car, light for FI's motorcycle). Omitting variant
is not "unspecified": for base:"car" it means the combustion form
specifically — there is no third, un-priced bucket, and other has no
variant at all. vehicle also carries three optional extras —
registration (any vehicle) and trailer/tools/passengers (priced for
FI's plain car only; any other resolved vehicle plus a nonzero extra
rejects vehicle-extras-not-priced). A base your market doesn't offer at
all rejects vehicle-type-invalid-for-market; a base that's right but an
unpriced variant rejects vehicle-variant-invalid. See "Allowance
lines" above for the full rules and the per-market (base, variant)
table, and GET /allowance-rates for the always-current, discoverable
version.
GET /lines now echoes the submitted vehicle{} back on every mileage
row (see "The line shapes — write vs. read" above), and GET /allowance-rates's mileage rows carry the same vehicle{base, variant?}
shape to submit back as their ONLY vehicle identity (internal registry
tokens are never exposed) — including, for the first time, Sweden's real
Skatteverket mileage rates. Sweden's vehicle types are now submittable and
discoverable, but a submitted SE mileage claim still does not yet reach an
invoice — see "Allowance rates" above for the exact caveat.
Separately, a mileage drive's own waypoint (drives[].waypoints[]) may
now carry optional lat/lng (real range-validated coordinates; send
both together or neither). They round-trip only there today — the same
waypoint shape used by a per-diem trip's own stops, or by a
mealAllowance line's location, accepts lat/lng on the wire but
drops them at intake.
2026-09-11 — Breaking: a purchase line's money field is replaced by
items[] — a receipt claim is now 1..50 independently-priced,
independently-VAT-resolved line items, not one flat amount. money is no
longer accepted at all on a type: "purchase" line (a strict schema, same
as every other foreign key on this contract — sending it now fails
invalid-line, the same generic slug any other schema violation gets).
In its place, items is now REQUIRED: an array of 1-50 objects, each
{ amount, vatTreatment?, description? }.
// Before
{ "type": "purchase", "description": "Hotel, Helsinki", "money": { "amount": 100 } }
// After
{ "type": "purchase", "description": "Hotel, Helsinki", "items": [{ "amount": 100 }] }
items[].amount is that ONE item's own RECEIPT GROSS (what was actually
paid for that item, including VAT) — same gross-not-net convention the
retired money.amount used, just per item now. Same precision rule too:
up to 6 fractional digits.items[].vatTreatment overrides that ONE item's VAT treatment only —
every item is resolved independently (falling back to the batch/market
default when omitted), so a single receipt can genuinely mix rates (a
standard-rate meal and a VAT-exempt toll on the same hotel bill, say).items[].description is an optional PER-ITEM label ("Taxi", "Hotel —
night 1") — distinct from the row's own description, which stays
required and is still the text that prints on the invoice line.receipt-item-invalid
— replacing the shared money-gate slugs (invalid-money/
zero-amount-row/arithmetic-mismatch) for anything item-specific on a
purchase row. The detail names which item failed, e.g. items[1].amount must be greater than zero.GET /lines, GET /lines/{externalRowId}), the same items[]
array echoes back, each entry { amount, vat?, description? } — vat is
that ONE item's resolved { treatment, rate } (absent for a legacy item
stored before per-item VAT tracking, same honest-degrade rule every other
VAT echo on this API follows). money on a purchase row now carries
ONLY currency — the money.amount/money.vat this type used to
carry are gone; there is no row-level total or VAT to read off money
for this type anymore, only per item, via items[]. See "Purchase lines"
and "The line shapes — write vs. read" above for the full read/write
shapes.Everything else about a purchase line is unchanged: the shared four
fields, the required row-level description, the receipt-upload route,
and dedupe/quarantine semantics.
2026-09-11 — Fix: an allowance money.unitPrice override above the
statutory registry rate is now rejected at submit, not accepted then
failed at invoicing. Corrects the previous "There's no cap" behavior
documented under "Overriding the allowance rate" above: the internal
pricing engine has always enforced a ceiling at invoice generation time
(it never actually let an above-registry rate bill), so the old
no-cap wording described a state that let a partner get a 2xx at submit
followed by a silent processing failure later. perDiem/mealAllowance/
mileage now reject invalid-line-style at submit —
allowance-rate-exceeded — the moment money.unitPrice exceeds the
statutory registry rate for that line's market and date (GET /allowance-rates); a rate AT or BELOW the registry rate is unaffected
(still accepted, exactly as before), and omitting money entirely is
unaffected either way. Applies to POST /batches, the append route, and
the PUT /batches/{batchId}/lines/{externalRowId} replacement route
alike.
2026-09-10 — Model-surface cleanup: the Models list prunes from 18 components down to 10, and the two line-read models unify into one. Naming/grouping only — every wire shape a partner sends or receives is unchanged.
WorkLineInput/
CompensationLineInput/AllowanceLineInput/PurchaseLineInput
components) now render inline inside LineInput's own "One of" selector,
each still labeled by name ("Work line" / "Compensation line" /
"Allowance line" / "Purchase line") — same fields, just no longer a
separate Models-list entry each.GET /lines, GET /lines/{externalRowId}, and the PUT /batches/{batchId}/lines/{externalRowId} replacement response all now
share ONE read model, Line — a "One of" union of "Work / compensation
line" and "Allowance / purchase line" — replacing the former separately-
registered WorkLine/ExpenseLine split (which mapped onto an internal
storage detail, not anything a partner reasoned about).BatchAppendInput and the bulk-move request/response
(MoveLinesInput/MoveLinesResult) are no longer separate Models-list
entries — each was a small, single-use body that now renders inline at
its one route instead.ExpenseReceiptUpload →
ReceiptUpload.autoDispatched — an internal
ops-only signal partners had no use for.2026-09-10 — Renamed API scopes invoices:read/invoices:write →
lines:read/lines:write. Same gates, same routes — only the scope
strings a key must carry changed. workers:read/workers:write are
unaffected.
2026-09-10 — Naming consolidation: the batch family's OpenAPI schema ids
drop their stale Invoice-prefixed names. The /batches surface's
request/response components are now Batch (response), BatchInput
(create body), and BatchSubmitInput (submit body) — the last also folds
in the already-agreed Send→Submit rename. BatchAppendInput predates the
model-surface cleanup above, which un-registered it in turn — the append
body renders inline at its own route now. Field shapes and route paths are
unchanged throughout — this is a Models-list/schema-id rename only.
2026-09-10 — Breaking (v2 read shape): line money is now nested under
money, and every row carries money.currency. GET /lines and
GET /lines/{externalRowId} used to echo a line's money as flat
top-level fields (amount/quantity/unitPrice/vat); the response
now nests them under money — matching the shape the write side already
used — and adds a new field, money.currency (EUR, or SEK for an
SE-market company), present on EVERY row. The write side is unchanged
for work/compensation (money was already nested there). purchase's
own write (and read) shape changed again the very next day — see the
items[] entry at the top of this changelog — so money no longer
applies to purchase at all today.
// Before
{ "amount": 180, "quantity": 4.5, "unitPrice": 40, "vat": { "treatment": "standard", "rate": 25.5 } }
// After
{ "money": { "amount": 180, "quantity": 4.5, "unitPrice": 40, "vat": { "treatment": "standard", "rate": 25.5 }, "currency": "EUR" } }
Same change, non-breaking, on write: allowance lines
(perDiem/mealAllowance/mileage) gained an optional
money.unitPrice — a per-unit rate override for that line, replacing
Truster's statutory registry rate; omitting it is byte-identical to
before this change. See "Overriding the allowance rate" above.