Truster Partner API v2 — integration guide

Getting started

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.

The mental model

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.

Authentication

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.

Base URL

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.

Finding endpoints in the OpenAPI document

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.

Your first batch, end to end

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:

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.

The four line types

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.

Which line types you may send

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.

Where to go next

Finding endpoints in the OpenAPI document

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.

Your first batch, end to end

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:

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.

The four line types

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.

Which line types you may send

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.

Where to go next

Error responses

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.

Batches, lines, and invoices

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.

Endpoints

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.

Line types

Every line is one of four types:

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.

Per-row outcome model

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:

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

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:

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.

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.

Allowance rates

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.

Money: normalization policy (contractual)

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

Retries and idempotency

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.

Dedupe semantics

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.

Quarantine remediation

A quarantined row is not a dead end. The typical loop:

  1. 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).
  2. Establish the worker via POST /workers (or fix the mismatched hints on the partner's side, for an identity-conflict quarantine).
  3. Resubmit a line with the SAME 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.

Batch lifecycle: draft, append, submit, cancel

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:

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 vocabulary

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.

Processing availability: 503 vs 502

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

Batch period

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.

Invoices: one per entity

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:

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.

Swedish-market batches

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.

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.

Filtering batches by state

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.

Reading your rows

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:

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:

  1. the accepted/invoiced row for that externalRowId, if one exists (at most one — the dedupe guarantee), else
  2. the outstanding quarantined row.

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

The line shapes — write vs. read

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:

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

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):

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 — 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" } }

Overriding the allowance rate

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.

Per-diem money: component overrides

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.

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.

Purchase lines

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:

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.

Diary-grade mileage drives

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.

Receipts

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:

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.

Receipt scanning

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.

Preview scan

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.

Managing lines in a DRAFT batch

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.

Fetch one line

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.

Edit a line — 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:

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 a line — 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.

Bulk move — POST /batches/{batchId}/lines/move

Moves 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"]
}

Submitted batches are locked

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.

Recipe: partial invoicing (compose-then-submit)

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:

  1. Create the cut batch. POST /batches with lines: [] (or omitted) and draft: true — an empty DRAFT container: { "batchId": "sept-partial-01", "lines": [], "draft": true }.
  2. Move the ready lines into it. 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.
  3. Submit the cut batch. 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.

Webhooks

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.

Endpoints

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 types

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_changed

A 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):

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.

Verifying a delivery's signature

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:

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;
}

Ping

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"
  }
}

Delivery contract

Changelog

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:

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:

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:

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):

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" }
}

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 }] }

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.

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.