# Truster B2B API — Integration Guide

This guide walks you through integrating with the Truster B2B API. By the end, you will be able to manage your workers, submit batch invoices, and download invoice PDFs.

**Base URL:** `https://api.truster.com/api/v1`
**Interactive API Docs (Swagger):** [https://api.truster.com/api/v1/docs](https://api.truster.com/api/v1/docs)

---

## Getting Started

### Authentication

All API requests require your API key in the `Authorization` header:

```
Authorization: Bearer YOUR_API_KEY
```

To verify that your key is valid and see which company it's connected to:

```bash
curl -X GET https://api.truster.com/api/v1/auth/verify \
  -H "Authorization: Bearer YOUR_API_KEY"
```

```json
{
  "success": true,
  "message": "Token is valid",
  "companyId": "abc123"
}
```

If you receive a `401` response, your API key is invalid or expired. Contact Truster to get a new one.

### 🧪 Mock Mode (Sandbox Testing)
We provide a safe Sandbox testing environment called **Mock Mode**. When Mock Mode is enabled for your company, the API completely bypasses actual database writes and backend integrations. Instead, it intercepts requests at the route level and returns standardized, successful JSON responses filled with preset dummy data. 

**Key Characteristics of Mock Mode:**
- **Validation First:** All JSON schema validation rules and data formats are strictly enforced.
- **Security Active:** You must still provide a valid Bearer Token in the `Authorization` header.
- **No Side Effects:** No data is written to Production, no files are uploaded to GCS, and no real invoices are generated.
- **Instant Response:** Responses are returned immediately with high-fidelity mock objects.

**To enable Mock Mode for your API Key, please contact your Truster account manager.**

### 🛡️ Safe Retries with Idempotency

All write operations (`POST`, `PUT`) support the `X-Idempotency-Key` header. If your request fails due to a network issue, you can safely retry with the same key — the API will return the original response instead of creating duplicates.

```
X-Idempotency-Key: your-unique-id-here
```

Keys are cached for 24 hours. We recommend using UUIDs or your own internal transaction IDs.

---

## Step 1: Add Your Workers

Before you can create invoices, you need to add workers to your Truster account. There are three ways to do this depending on what information you have.

### Option A: Create by personal details

Use this when you're onboarding a new worker who doesn't have a Truster account yet.

```bash
curl -X POST https://api.truster.com/api/v1/workers \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "firstName": "Matti",
    "lastName": "Meikäläinen",
    "ssn": "010199-1234",
    "email": "matti@example.com",
    "phone": "+358401234567",
    "externalKey": "ERP-001"
  }'
```

### Option B: Link by share code

If the worker already has a Truster account, they can share a 6-character code with you from their app.

```bash
curl -X POST https://api.truster.com/api/v1/workers/shareCode \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "shareCode": "ABC123"
  }'
```

### Option C: Link by business ID

If the worker is a sole proprietor (toiminimi), you can link them using their Y-tunnus.

```bash
curl -X POST https://api.truster.com/api/v1/workers \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "businessId": "1234567-8",
    "externalKey": "ERP-001"
  }'
```

### What is `externalKey`?

The `externalKey` field lets you store your own internal identifier (e.g. employee number, ERP ID) on the worker. This makes it easy to map Truster workers back to your own system. It is optional but highly recommended.

### Response

All three options return the same worker object:

```json
{
  "id": "worker_abc123",
  "firstName": "Matti",
  "lastName": "Meikäläinen",
  "ssn": "010199-****",
  "email": "matti@example.com",
  "phone": "+358401234567",
  "externalKey": "ERP-001",
  "verificationStatus": "verified",
  "userId": "abc123FirebaseUid",
  "isLinked": true,
  "subscriptionType": "pro"
}
```

> **Data Privacy (SSN Masking):** For security and data minimization, Truster automatically masks the final 4 characters of a worker's SSN in all API responses. You will only receive the full SSN if you are specifically generating a direct tax or ledger artifact internally via Truster.

> **Linked vs unlinked (`isLinked`):** When a worker row exists but has not yet been connected to a Truster user account (e.g. you created them from SSN+email but the person hasn't registered with Truster yet), `userId` is `""` and `isLinked` is `false`. Invoices for unlinked workers will be parked in `REVIEW_PENDING` and a `invoice_batch.worker_link_needed` webhook is fired; the batch auto-dispatches as soon as the worker connects (either by SSN match when the user registers, or by manual link from the Truster admin). Auto-process logic should skip rows where `isLinked` is `false`.

Save the `id` — you will need it as the `workerIdentifier` when creating invoices.

---

## Step 2: Update Worker Details

If a worker's contact information changes, you can update it. Only include the fields you want to change.

```bash
curl -X PUT https://api.truster.com/api/v1/workers/worker_abc123 \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "matti.new@example.com",
    "phone": "+358409999999"
  }'
```

---

## Step 3: List Your Workers

To see all workers linked to your company:

```bash
curl -X GET "https://api.truster.com/api/v1/workers?limit=50" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

The response is paginated. If `meta.hasMore` is `true`, pass the returned `meta.cursor` to fetch the next page:

```bash
curl -X GET "https://api.truster.com/api/v1/workers?limit=50&cursor=CURSOR_VALUE" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

---

## Step 4: Get a Specific Worker

If you already have the worker's ID (the `id` string returned by `/workers`), you can fetch their latest profile details directly without paginating through the list.

```bash
curl -X GET https://api.truster.com/api/v1/workers/worker_abc123 \
  -H "Authorization: Bearer YOUR_API_KEY"
```

## Step 5: Verify Live Tax Card (OmaVero)

Once a worker is successfully linked to your company and their SSN is on file, you can instruct Truster to perform a real-time lookup against the Finnish Tax Administration (OmaVero) to retrieve their exact, up-to-the-minute tax limits and percentages.

```bash
curl -X GET https://api.truster.com/api/v1/workers/worker_abc123/tax-card \
  -H "Authorization: Bearer YOUR_API_KEY"
```

```json
{
  "data": {
    "ssn": "010199-1234",
    "basePercentage": 10.5,
    "additionalPercentage": 38.0,
    "annualIncomeLimit": 35000.0
  }
}
```

> **Note:** The SSN returned here is unmasked as it constitutes an official tax record response. If the worker is not associated with your company, the API will reject this lookup.

---

## Step 6a: Create a Single Invoice

If you only have one worker's lines to invoice (or you're testing
the API), use the single-invoice endpoint:

```bash
curl -X POST https://api.truster.com/api/v1/invoices \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "workerId": "worker_abc123",
    "invoiceDate": "2026-05-15",
    "dueDate": "2026-05-29",
    "reference": "PO-2026-1234",
    "orderReference": "PRJ-9988",
    "vatId": "DomesticVat255",
    "lines": [{
      "description": "Consulting Services",
      "quantity": 10,
      "unitPrice": 100,
      "vatPercent": 25.5,
      "workStartDate": "2026-05-01",
      "workEndDate": "2026-05-15"
    }]
  }'
```

The response carries an `invoiceId` and one of:

| Status | Meaning |
|---|---|
| `REVIEW_PENDING` | Default for companies without auto-process. A Truster ops admin will review and dispatch to the collections partner when satisfied. |
| `COMPLETED` | Auto-process is on; invoice was successfully dispatched to the collections partner inline. |
| `DISPATCH_FAILED` | Auto-process tried to dispatch but the collections partner rejected. The invoice persists for manual retry; see the `invoice_batch.dispatch_failed` webhook payload. |
| `DRAFT` | You set `draft: true` — see **Step 6d: Draft Invoices** below. Returned as `201`, not `202`. |
| `CANCELLED` | (Draft invoices only, reached later.) The draft was abandoned via `DELETE /api/v1/invoices/{id}`. Terminal. |
| `IMPORTED_TO_DRAFT` | (Draft invoices only, reached later.) An operator merged this container's rows into a scheduled-draft mass invoice. Terminal; see **Scheduled-draft holds** below. |

This same status set applies to `POST /api/v1/invoices/batch` — see **Step 6d** for the two statuses unique to drafts.

The `invoiceId` is actually a Truster batch id (a single-invoice
POST creates a batch of 1 internally). You can use it with:

- `GET /api/v1/invoices/batch/{invoiceId}` — batch metadata
- `GET /api/v1/invoices/batch/{invoiceId}/pdf` — the dispatched PDF
- `GET /api/v1/invoices/{invoiceId}` — full payment-facing details
  once the underlying invoice exists (after dispatch)

### Auto-process behaviour

Both `POST /api/v1/invoices` and `POST /api/v1/invoices/batch`
honor the **`autoProcessInvoices`** flag on your DataHub company
(ask your Truster account manager to flip it on). When the flag
is on, both endpoints dispatch to the collections partner inline
and skip the back-office review queue. When the flag is off, both
endpoints leave the submission in `REVIEW_PENDING` for an ops
admin to review.

---

## Step 6: Create a Batch Invoice

This is the main operation. A batch invoice lets you submit invoice items for multiple workers in a single request. Truster will group the items by worker and create one invoice per worker.

Each item in the batch must include a `workerIdentifier` to link it to a worker. You can use any of the following:

- **Worker ID** — the Truster `id` returned when you added the worker (e.g. `"worker_abc123"`)
- **External Key** — the `externalKey` you assigned to the worker (e.g. `"ERP-001"`)
- **SSN** — the worker's social security number (e.g. `"010199-1234"`)
- **Email** — the worker's email address
- **Business ID** — the worker's `businessId` (company/business workers)
- **User ID** — the linked Truster `userId` (also returned by `GET /workers`)

All of these are returned on the worker object by `GET /workers`, so you can use whichever identifier you already store. A single worker can have multiple items in the same batch — they will be grouped into one invoice.

### Example: Two workers, one with multiple items

```bash
curl -X POST https://api.truster.com/api/v1/invoices/batch \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "X-Idempotency-Key: batch-2026-03-31" \
  -d '{
    "clientBatchId": "PAYROLL-2026-03",
    "invoiceDate": "2026-03-31",
    "dueDate": "2026-04-14",
    "items": [
      {
        "workerIdentifier": "worker_abc123",
        "description": "Web development — March 2026",
        "quantity": 120,
        "unitPrice": 55.00,
        "vatPercent": 25.5,
        "workStartDate": "2026-03-01",
        "workEndDate": "2026-03-31"
      },
      {
        "workerIdentifier": "worker_abc123",
        "description": "Travel expenses — Helsinki client visit",
        "quantity": 1,
        "unitPrice": 150.00,
        "vatPercent": 25.5,
        "workStartDate": "2026-03-15",
        "workEndDate": "2026-03-15"
      },
      {
        "workerIdentifier": "worker_def456",
        "description": "Graphic design — Logo project",
        "quantity": 40,
        "unitPrice": 70.00,
        "vatPercent": 25.5,
        "workStartDate": "2026-03-01",
        "workEndDate": "2026-03-20"
      }
    ]
  }'
```

In this example:
- **worker_abc123** gets one invoice with 2 line items (development work + travel expenses)
- **worker_def456** gets one invoice with 1 line item (design work)

### Response

The batch is processed asynchronously. You receive a `batchId` to track its progress:

```json
{
  "success": true,
  "batchId": "batch_xyz789",
  "status": "processing",
  "message": "Batch accepted for processing"
}
```

### Batch-level defaults

The fields `invoiceDate`, `dueDate`, `orderReference`, `vatId`, and `vatClassId` can be set at the batch level and apply to all items. This saves you from repeating them on every item.

### 🧾 VAT & Taxation Guide

Our API strictly enforces Finnish taxation rules. You must provide a valid `vatId` and, in the case of 0% VAT, a specific `vatClassId` for every invoice.

#### 1. Standard VAT Rates
- `DomesticVat255` (Default): Standard 25.5% VAT for most services and goods.
- `DomesticVat10`: 10% rate for books, pharmaceutical products, passenger transport, etc.
- `DomesticVat135`: 14% rate for food, feed, and restaurant services.

#### 2. Zero-Rated Sales (0% VAT)
When using a 0% rate, you **MUST** provide one of the following `vatClassId` options to specify the legal reason for the exemption:
- `Artist0`: Performing artist fee (VAT Act 45 §).
- `ConstructionReverseVat`: Construction services subject to reverse charge mechanism (VAT Act 8 c §).
- `DomesticVatFree`: Standard tax-exempt sale within Finland (VAT Act 34 §).
- `EuServiceSales`: Intra-community supply of services within the EU (VAT Act 65 §).
- `Healthcare0`: Tax-exempt healthcare and medical services (VAT Act 34 §).
- `MarginalTaxScheme`: Margin scheme for used goods, works of art, or collectors' items (VAT Act 163 §).
- `OutsideEuSales`: Export of goods or services to customers outside the European Union (VAT Act 71 §).
- `SocialCare0`: Tax-exempt social care and social welfare services (VAT Act 37 §).
- `UsedGoods0`: Simplified margin scheme for used goods (VAT Act 163 §).
- `VesselSales0`: Tax-exempt sale, rental, or servicing of vessels (VAT Act 58 §).

#### 3. Special Cases
- `ForeignEUReverseVat`: General category for sales to foreign EU customers (Reverse charge).
- `ForeignNoEUReverseVat`: General category for sales to foreign Non-EU customers.
- `DomesticNoVat`: Sale without any VAT applied (0%).

---

## Step 6c: Expense Collections & Reimbursements (travel, per-diem, meals, receipts)

Beyond billable work, an item can carry a tax-exempt **reimbursement** — mileage, per-diems, meal allowances, and receipt-backed expense claims. Allowances are **net** (no unit price / VAT): Truster prices them against the Finnish statutory rate registry and clamps anything above the legal maximum.

There are two ways to submit reimbursements:

1. **Attach to a work item** — add an `expenses` object to a normal `BillableWork` item.
2. **Reimbursement-only item** — send `type: "ExpenseCollection"` with just `workerIdentifier` + `expenses` (no `description`/`unitPrice`/`vatPercent`).

Both accept the exact same `expenses` object, and it works identically on `POST /api/v1/invoices/batch`, `POST /api/v1/invoices` (single), and the dry-run validator below.

### Validate first (recommended)

`POST /api/v1/expense-collections/validate` is a **dry run** — no invoice is created. It returns blocking `errors`, non-blocking `warnings` (e.g. a per-diem overclaim that will be clamped, or a drive under the 15 km minimum), and a `computed` breakdown (per travel-day per-diem countries + the measured `tripDistanceKm`). Call it before submitting so you can surface problems to your user.

Per-diem addresses are geocoded server-side, which surfaces:

| Signal | Severity | Meaning |
| ------ | -------- | ------- |
| `expense-perdiem-address-unverifiable` | warning | An origin/destination address didn't geocode; its measured distance is left off. Non-blocking. |
| `expense-perdiem-country-mismatch` | warning | The address geocodes to a different country than the `country` you supplied (the rate uses the **supplied** country — confirm it). |
| `expense-perdiem-country-invalid` | **error** | The day's country can't be priced (bogus/unseeded code). **Blocks** — this same guard also aborts the batch at generation. |

Supplying an accurate `postCode` on each per-diem `start`/`waypoints` entry improves geocoding and reduces the two warnings above.

```bash
curl -X POST https://api.truster.com/api/v1/expense-collections/validate \
  -H "Authorization: Bearer YOUR_API_KEY" -H "Content-Type: application/json" \
  -d '{ "mileage": { ... }, "perDiem": { ... }, "meals": { ... } }'
```

You can also preview the resolved rates for a country + date with `GET /api/v1/expenses/rates?country=FI&date=2026-03-01` (per-diem whole/partial, meal, and mileage per vehicle type). Omit any unit cost you're happy to default to these.

### The `expenses` object

```jsonc
{
  // Billability is decided PER ITEM: each mileage trip, per-diem trip, meal item
  // and expense claim carries its own `billable`, and it DEFAULTS TO FALSE —
  // set `billable: true` explicitly to bill the client. There is NO
  // collection-level billable flag.
  "mileage": {
    "km": 156,                       // total claimed distance
    "vehicleType": "CAR",            // CAR | MOTORCYCLE | LIGHT_MOTORCYCLE | OTHER | NONE
    "vehicleId": "ABC-123",
    "carOptions": { "carWithPassengers": 2, "carWithTools": false, "carWithTrailer": false },
    "trips": [{
      "description": "Oulu client visit",
      "billable": true,              // does the client pay for THIS drive? DEFAULTS TO FALSE
      "startDateTime": "2026-03-01T06:00:00Z",
      "endDateTime":   "2026-03-01T15:00:00Z",
      // Mileage waypoints are an ORDERED ARRAY — legs are measured pairwise server-side.
      "waypoints": [
        { "address": "Kvartsitie 2, Oulu, Suomi", "postCode": "90630", "country": "FI", "description": "Office" },
        { "address": "Äimäkuja 2, Oulu, Suomi",   "postCode": "90400", "country": "FI", "description": "Client" }
      ]
    }]
  },
  "perDiem": {
    "trips": [{
      "description": "Tampere project",
      "billable": true,              // does the client pay for THIS trip's per diems? DEFAULTS TO FALSE
      "startDateTime": "2026-03-01T08:00:00Z",
      "endDateTime":   "2026-03-04T11:00:00Z",
      "international": false,         // true → each foreign day takes its country (rate) from that day's waypoint
      "wholeDays": 3,
      "partialDays": 1,
      // Trip origin — the distance (etäisyys) is measured from here to the destination.
      "start": { "address": "Mannerheimintie 1, Helsinki, Suomi", "postCode": "00100", "country": "FI" },
      // Per-diem waypoints are a DATE-KEYED MAP (YYYY-MM-DD → destination locality).
      // Every derived travel-day start date must have an entry.
      "waypoints": {
        "2026-03-01": { "address": "Hämeenkatu 1, Tampere, Suomi", "postCode": "33100", "country": "FI" },
        "2026-03-02": { "address": "Hämeenkatu 1, Tampere, Suomi", "postCode": "33100", "country": "FI" },
        "2026-03-03": { "address": "Hämeenkatu 1, Tampere, Suomi", "postCode": "33100", "country": "FI" },
        "2026-03-04": { "address": "Hämeenkatu 1, Tampere, Suomi", "postCode": "33100", "country": "FI" }
      }
    }]
  },
  "meals": {
    // One entry per meal-allowance date with its per-day quantity (1 or 2; at most 2/day)
    // and its own billable flag (DEFAULTS TO FALSE).
    "items": [
      { "date": "2026-03-06", "quantity": 2, "billable": true },
      { "date": "2026-03-07", "quantity": 1, "billable": false }
    ]
    // A meal date may NOT fall on a per-diem travel day (the per diem already covers meals).
  },
  "expenseClaims": [{
    "id": "clm_hotel_1",             // optional; auto-generated (clm_<uuid>) if omitted — read it back off the invoice
    "description": "Hotel Tampere",
    "date": "2026-03-01",
    "billable": false,
    "receiptFilePath": "dataHub/<companyId>/attachments/...",  // from POST /api/v1/attachments (see below)
    "receiptLineItems": [{ "grossAmount": 145.50, "vatPct": 25.5 }]  // vatPct is a PERCENT
  }]
}
```

> ⚠️ **Waypoints differ by type.** Mileage waypoints are an **ordered array** (`{address, postCode?, country?, description?}`); per-diem waypoints are a **date-keyed map** whose values are `{address, postCode?, country?}` plus a separate trip `start` origin. They are not interchangeable.

> 💰 **`billable` defaults to `false`** on every item — set `billable: true` explicitly on each item the client should pay for.

> ⚠️ **Non-billable allowances need a funding source.** For workers on the *light entrepreneur* payroll model any item may be non-billable. For other payroll models only expense-claim **receipts** may be non-billable — a non-billable (or omitted-`billable`) **allowance** (per-diem trip, mileage trip or meal item) is rejected with `expense-nonbillable-not-allowed`.

### Receipt files

To attach a receipt image/PDF to an expense claim:

1. Upload the file with `POST /api/v1/attachments` (multipart). It returns a `path` under `dataHub/<yourCompanyId>/…`.
2. Put that path in the claim's `receiptFilePath`. The path **must** be under your own company prefix.

You can also attach (or replace) a receipt **after** the invoice exists, targeting the claim by its `id`:

```bash
curl -X PUT https://api.truster.com/api/v1/invoices/INVOICE_ID/expenses/claims/CLAIM_ID/receipt \
  -H "Authorization: Bearer YOUR_API_KEY" -H "Content-Type: application/json" \
  -d '{ "receiptFilePath": "dataHub/<companyId>/attachments/receipt_8.pdf" }'
```

When the batch is committed into invoices, Truster copies each receipt image **and** the generated travel-diary PDF into the worker's own storage so the worker can open them — you don't need to do anything extra.

### Example: a reimbursement-only item in a batch

```bash
curl -X POST https://api.truster.com/api/v1/invoices/batch \
  -H "Authorization: Bearer YOUR_API_KEY" -H "Content-Type: application/json" \
  -H "X-Idempotency-Key: batch-2026-03-reimb" \
  -d '{
    "invoiceDate": "2026-03-31",
    "items": [{
      "type": "ExpenseCollection",
      "workerIdentifier": "worker_abc123",
      "expenses": {
        "meals": { "items": [{ "date": "2026-03-06", "quantity": 2, "billable": false }] },
        "perDiem": {
          "trips": [{
            "description": "Tampere project",
            "billable": false,
            "startDateTime": "2026-03-01T08:00:00Z",
            "endDateTime": "2026-03-04T11:00:00Z",
            "wholeDays": 3, "partialDays": 1,
            "start": { "address": "Mannerheimintie 1, Helsinki, Suomi", "postCode": "00100", "country": "FI" },
            "waypoints": { "2026-03-01": { "address": "Hämeenkatu 1, Tampere, Suomi", "postCode": "33100", "country": "FI" } }
          }]
        }
      }
    }]
  }'
```

---

## Step 6d: Draft Invoices (assemble now, send later)

A **draft** is a batch (or single invoice) container that Truster holds open for editing: you create it, add lines to it over hours, days, or weeks, then explicitly send it — or cancel it — when you're ready. Nothing is validated against workers, held for review, or dispatched to the collections partner until you call `send`; draft creation and every append are plain, immediate writes.

Use a draft when you assemble one invoice/batch incrementally — e.g. a payroll run where billable lines trickle in over the pay period — instead of gathering everything up front for a single `POST /invoices(/batch)` call.

### Create the draft

Set `draft: true` on either create endpoint. `POST /api/v1/invoices/batch` is shown here because a draft's items always use the batch `workerIdentifier`-keyed item shape — the same shape `POST .../lines` (below) appends — even for a draft that started as a single invoice via `POST /api/v1/invoices`.

```bash
curl -X POST https://api.truster.com/api/v1/invoices/batch \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "X-Idempotency-Key: draft-2026-07-payroll" \
  -d '{
    "clientBatchId": "PAYROLL-2026-07-DRAFT",
    "invoiceDate": "2026-07-31",
    "dueDate": "2026-08-14",
    "draft": true,
    "items": [{
      "workerIdentifier": "worker_abc123",
      "description": "Consulting Services — week 1",
      "quantity": 40,
      "unitPrice": 55.00,
      "vatPercent": 25.5,
      "workStartDate": "2026-07-01",
      "workEndDate": "2026-07-05"
    }]
  }'
```

```json
{
  "success": true,
  "batchId": "batch_xyz789",
  "status": "DRAFT",
  "message": "Draft created. Add lines with POST /api/v1/invoices/{id}/lines; send with POST /api/v1/invoices/{id}/send."
}
```

`draft: true` still runs item/VAT validation — it only skips worker resolution, the scheduled-draft hold check, auto-dispatch, and webhooks. Item/VAT-validation failures still return `400`/`422` and nothing is persisted, exactly like a non-draft submission.

### Grow it

Call this as many times as you like. Each call is additive; the draft's `status` never changes until you `send` or cancel it.

```bash
curl -X POST https://api.truster.com/api/v1/invoices/batch_xyz789/lines \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [{
      "workerIdentifier": "worker_abc123",
      "description": "Consulting Services — week 2",
      "quantity": 40,
      "unitPrice": 55.00,
      "vatPercent": 25.5,
      "workStartDate": "2026-07-08",
      "workEndDate": "2026-07-12"
    }]
  }'
```

```json
{ "invoiceId": "batch_xyz789", "status": "DRAFT", "itemCount": 2 }
```

`items` accepts the exact same shapes as `POST /api/v1/invoices/batch` — plain `BillableWork` rows, `expenses` blocks attached to a row, or standalone `type: "ExpenseCollection"` rows (see **Step 6c** above). Each row is validated the same way as a non-draft submission — a bad row (missing/invalid field, out-of-range VAT percent, etc.) is rejected `422 invalid-items` with the per-item error(s) and nothing is appended. At most one row per worker may carry an `expenses` block across the *whole* draft (existing + appended) — appending a second one for the same worker is rejected `422 duplicate-expenses-row` and nothing is appended.

### Send it

```bash
curl -X POST https://api.truster.com/api/v1/invoices/batch_xyz789/send \
  -H "Authorization: Bearer YOUR_API_KEY"
```

`send` runs the exact resolution → worker-link check → scheduled-draft-hold → auto-process decision your non-draft `POST /invoices(/batch)` would have run inline:

| Response | Meaning |
|---|---|
| `200` `COMPLETED` / `DISPATCH_FAILED` / `REJECTED` | Ran to a terminal outcome — `{"invoiceId": "batch_xyz789", "status": "COMPLETED", "massInvoiceId": "mass_abc987", "errors": []}`. |
| `202` `REVIEW_PENDING` | Parked — a matched worker still needs linking, your company holds for scheduled-draft import, or auto-process is off. See **Scheduled-draft holds** below. |
| `409 empty-draft` | No lines yet — add at least one with `POST .../lines` first. |
| `409 not-a-draft` | Already sent, or already cancelled. |
| `409 retry-send` | Your items changed while `send` was processing (a concurrent `POST .../lines` append landed mid-request). Nothing was sent — retry `POST .../send`. |
| `422 unresolved-workers` | One or more `workerIdentifier` rows didn't match any worker on your DataHub company. The draft is left untouched — fix the rows with another `POST .../lines` call and retry `send`. |

### …or cancel it

Changed your mind before sending? Cancel it instead of sending — `CANCELLED` is terminal: a cancelled draft can never be revived, appended to, or sent, and nothing is dispatched to the collections partner.

```bash
curl -X DELETE https://api.truster.com/api/v1/invoices/batch_xyz789 \
  -H "Authorization: Bearer YOUR_API_KEY"
```

```json
{ "invoiceId": "batch_xyz789", "status": "CANCELLED" }
```

`DELETE` only succeeds while the container is still `DRAFT` — once `send` has moved it on (even just into `REVIEW_PENDING`), cancelling returns `409 not-a-draft`.

### List your drafts

```bash
curl -X GET "https://api.truster.com/api/v1/invoices/batches?status=DRAFT" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

```json
{
  "batches": [{
    "batchId": "batch_xyz789",
    "status": "DRAFT",
    "source": "api",
    "clientBatchId": "PAYROLL-2026-07-DRAFT",
    "massInvoiceId": "",
    "rejectionReason": "",
    "reviewPendingReason": "",
    "autoDispatched": false,
    "itemCount": 2,
    "createdAt": "2026-07-01T09:00:00.000Z",
    "updatedAt": "2026-07-08T09:00:00.000Z"
  }],
  "nextCursor": null
}
```

`status` accepts any lifecycle value — `DRAFT`, `CANCELLED`, and `IMPORTED_TO_DRAFT` included — the same way it already does for `REVIEW_PENDING`/`COMPLETED`/etc. `GET /api/v1/invoices/batch/{batchId}` returns one draft with its full `items` array; nothing about that endpoint changes for a draft.

### Scheduled-draft holds

Some companies run recurring "scheduled draft" mass invoices internally and want every partner submission merged into one of those instead of dispatched straight to the collections partner (ask your Truster account manager if you want this enabled for your company). While **scheduled draft invoicing** is enabled for your company:

- `POST /api/v1/invoices/batch` (non-draft) **always** parks in `REVIEW_PENDING` with `reviewPendingReason: "awaiting_draft_import"` when it would otherwise auto-dispatch — there is no per-company override for the batch endpoint.
- Sending a draft (`POST /invoices/{id}/send`) is treated the same as a non-draft `POST /invoices` (single) explicit submission: if your company additionally has `allowDirectApiDispatch: true`, `send` dispatches immediately even while the hold is enabled; without it, `send` parks exactly like a held batch.
- `reviewPendingReason` tells you *why* a batch is parked: `"worker_link_needed"` — a matched worker hasn't connected their Truster account yet (send them a share code, or wait for auto-retry); `"awaiting_draft_import"` — every worker is fine, an operator just needs to import your rows into an open scheduled draft. A `worker_link_needed` park converges to `awaiting_draft_import` automatically once the worker links, if the hold is still active by then.
- Once an operator imports your batch's rows, it reaches the terminal status `IMPORTED_TO_DRAFT` and an `invoice_batch.imported_to_draft` webhook fires. `massInvoiceId` on `GET /api/v1/invoices/batch/{batchId}` tells you where your rows landed — poll `GET /api/v1/invoices/{massInvoiceId}` for the consolidated invoice once it's eventually dispatched.

---

## Step 7: Set Up Webhooks

Webhooks let Truster push real-time notifications to your system when events happen — for example, when a batch is processed or an invoice status changes. This is the recommended way to stay in sync without polling.

### Register a webhook

Provide an HTTPS endpoint URL and the events you want to subscribe to:

```bash
curl -X POST https://api.truster.com/api/v1/webhooks \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-system.com/webhooks/truster",
    "events": ["batch.processed", "invoice.created", "invoice.status_changed"]
  }'
```

```json
{
  "id": "wh_abc123",
  "url": "https://your-system.com/webhooks/truster",
  "events": ["batch.processed", "invoice.created", "invoice.status_changed"],
  "status": "active",
  "signingSecret": "whsec_7f3a...b2e1",
  "createdAt": "2026-03-31T12:00:00Z"
}
```

Save the `signingSecret` — it is only returned once, at registration time. You will need it to verify incoming webhook signatures.

### Webhook Payloads

When an event occurs, Truster will dispatch an HTTP `POST` request to your registered URL. The payload will have the following structure:

```json
{
  "event": "invoice.status_changed",
  "timestamp": "1711894523000",
  "data": {
    "invoiceId": "inv_123abc",
    "externalKey": "ERP-INV-001",
    "status": "PAID",
    "updatedAt": "2026-03-31T14:15:23Z"
  }
}
```

### Available Events
- `batch.processed`: Fired when a mass invoice batch extraction and staff review is complete.
- `invoice.created`: Fired the moment a new invoice document is generated.
- `invoice.status_changed`: Fired when an invoice is marked as `PAID`, `OVERDUE`, `REJECTED`, or `CREDITED`.
- `worker.created` / `worker.updated`: Fired when a worker profile linked to your company is modified.
- `worker.subscription_changed`: Fired when a worker upgrades or changes their Truster subscription.

### Verifying webhook signatures (Security)

Every webhook request from Truster includes an `X-Truster-Signature` (and `Truster-Signature`) header. This is a HMAC-SHA256 hash of the JSON payload, generated using the `signingSecret` provided to you during webhook registration. You **MUST** verify this signature to ensure the request originated from Truster.

Every webhook request from Truster includes two security headers:
*   `Truster-Timestamp`: The UNIX timestamp (in milliseconds) when the request was dispatched.
*   `Truster-Signature`: An HMAC-SHA256 hash of the payload, formatted as `v1=<hex_hash>`.

To verify the signature, you must calculate the HMAC of the exact string `${timestamp}.${payload}` using your `signingSecret` and compare the resulting hex value to the one provided in the header.

**Example Signature Verification (Node.js)**
```javascript
const crypto = require('crypto');

const timestamp = req.headers['truster-timestamp'];
const signatureHeader = req.headers['truster-signature'] || req.headers['x-truster-signature'];
const signature = signatureHeader.split('=')[1];
const rawBodyString = JSON.stringify(req.body); 

const hmac = crypto.createHmac('sha256', YOUR_SIGNING_SECRET);
hmac.update(`${timestamp}.${rawBodyString}`);
const expectedSignature = hmac.digest('hex');

if (expectedSignature !== signature) {
    throw new Error('Invalid Webhook Signature!');
}
```

---

## Step 6: Download Invoice PDF After Webhook

After receiving a webhook notification (e.g. `invoice.created`), you can use the invoice ID to download the PDF.

```
1. You submit a batch invoice         POST /invoices/batch
2. Truster processes the batch        (async)
3. Truster sends webhook              → invoice.created (contains invoiceId)
4. You download the PDF               GET  /invoices/{invoiceId}/pdf
```

```bash
curl -X GET https://api.truster.com/api/v1/invoices/INVOICE_ID/pdf \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -o invoice.pdf
```

## Step 7: Fetch Invoice Details After Dispatch

Once an invoice has been sent, you can fetch the canonical
payment-facing view — IBAN, BIC, payment reference number, due
date, current status, customer payment history, and (for
HetiPalkka Immediate invoices) the verification state.

```bash
curl -X GET https://api.truster.com/api/v1/invoices/INVOICE_ID \
  -H "Authorization: Bearer YOUR_API_KEY"
```

The same endpoint accepts a **standalone invoice id**, a
**mass-invoice child id**, or a **mass-invoice parent id** — all
three resolve to the consolidated payment details. On a parent
id, the `lines` array aggregates every child's lines (each line
carries its `worker` sub-object) and the top-level `worker`
field is empty.

### Response (abridged)

```jsonc
{
  "id": "inv_abc123",
  "invoiceNumber": "20261001",
  "externalKey": "partner-side-key",
  "clientBatchId": "your-batch-ref",
  "massInvoiceId": "mass_xyz789",  // empty on standalone invoices
  "type": "ChildInvoice",          // Normal | ChildInvoice | MassInvoice | CreditNote
  "status": "Pending",             // Draft | Sent | Pending | Paid | Overdue | Credited | Deleted
  "paymentType": "After",          // After | Immediate

  // HetiPalkka verification state for Immediate invoices:
  //   not_applicable | pending | accepted | disputed
  "verificationStatus": "not_applicable",

  "createdAt": "2026-05-15T10:00:00Z",
  "updatedAt": "2026-05-30T12:00:00Z",
  "invoiceDate": "2026-05-15",
  "dueDate": "2026-05-29",
  "sentAt": "2026-05-15T10:05:00Z",
  "paidAt": "",                    // ISO timestamp when customer paid; empty until then

  "reference": "PO-2026-1234",
  "orderReference": "PRJ-9988",
  "paymentReferenceNumber": "12345678901234567890",

  // Customer-facing payment instructions. For all B2B invoices
  // today, the IBAN + BIC always route to Truster's collection
  // account; the payment reference number disambiguates which
  // invoice was paid.
  "paymentDetails": {
    "iban": "FI5457895420126885",
    "swift": "OKOYFIHH",
    "currency": "EUR",
    "billNumber": "RP-9876"        // Collections-partner bill number for reconciliation
    // `ropoBillnum` (deprecated alias for `billNumber`) is also emitted with the same value
    // for backward compatibility. Prefer `billNumber` going forward.
  },

  "vatId": "DomesticVat255",
  "vatClassId": "EU_SERVICE_SALES",

  "totals": {
    "netAmount": 100.00,
    "taxAmount": 25.50,
    "grossAmount": 125.50,
    "currency": "EUR",
    "breakdown": [
      {
        "category": { "id": "DomesticVat255", "category": 255 },
        "netAmount": 100.00, "taxAmount": 25.50, "grossAmount": 125.50
      }
    ]
  },

  // Customer payment history from the collections partner (empty
  // until first event; partial / installment payments produce
  // multiple entries). For paid/unpaid status use the
  // invoice-level `status` field above; `rowType` + `rowTypeText`
  // describe what kind of row this is (payment, credit note,
  // adjustment, …).
  "payments": [
    {
      "amount": 125.50,
      "paymentDate": "2026-05-28",
      "description": "Customer payment",
      "rowType": 1,
      "rowTypeText": "PAYMENT"
    }
  ],

  "customer": {
    "id": "cust_abc", "name": "Acme Oy",
    "businessId": "1234567-8", "email": "billing@acme.fi"
  },
  "worker": {
    "id": "worker_xyz", "name": "Matti Meikäläinen"
  },

  "lines": [ /* same shape as /invoices list */ ]
}
```

### Polling pattern

If you don't subscribe to webhooks, poll this endpoint to detect
payment:

```
1. Submit batch         POST /invoices/batch
2. Wait for COMPLETED   GET  /invoices/batch/:batchId  (until status == COMPLETED)
3. Poll for payment     GET  /invoices/:id              (every ~30 min until status == Paid)
4. Reconcile            payments[] + paidAt + status
```

For HetiPalkka Immediate invoices, the same endpoint tells you
whether the customer accepted the verification email — watch
`verificationStatus` move `pending → accepted` before the
salary is auto-released.

---

### Batch (mass invoice) PDFs

A batch is dispatched to the collections partner as a **single
job** with multiple payment rows, so the customer receives **one
consolidated PDF**
that covers every worker line in the batch — not one PDF per
worker child.

You can fetch this consolidated PDF in three equivalent ways:

```bash
# (a) By batch id — most natural after POST /invoices/batch:
curl -X GET https://api.truster.com/api/v1/invoices/batch/BATCH_ID/pdf \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -o batch.pdf

# (b) By the mass invoice id (returned in GET /invoices/batch/:batchId):
curl -X GET https://api.truster.com/api/v1/invoices/MASS_INVOICE_ID/pdf \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -o batch.pdf

# (c) By any child invoice id (e.g. from an invoice.created webhook):
curl -X GET https://api.truster.com/api/v1/invoices/CHILD_INVOICE_ID/pdf \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -o batch.pdf
```

All three return the same bytes. The PDF is archived the moment
the batch is dispatched to the collections partner (typically
within seconds of submission). Before that, the `/pdf` endpoint
returns `404` with
detail "PDF file is not currently available for this invoice."
— poll `GET /invoices/batch/{batchId}` and wait for
`status: "COMPLETED"` before requesting the PDF.

---

## Quick Reference

| What you want to do | Method | Endpoint |
|---------------------|--------|----------|
| Verify API key | `GET` | `/api/v1/auth/verify` |
| List workers | `GET` | `/api/v1/workers` |
| Get a specific worker | `GET` | `/api/v1/workers/{id}` |
| Get OmaVero tax card | `GET` | `/api/v1/workers/{id}/tax-card` |
| Add a worker | `POST` | `/api/v1/workers` |
| Link via Share Code | `POST` | `/api/v1/workers/shareCode` |
| Update a worker | `PUT` | `/api/v1/workers/{id}` |
| Create single invoice (or draft, `draft: true`) | `POST` | `/api/v1/invoices` |
| Create batch invoice (or draft, `draft: true`) | `POST` | `/api/v1/invoices/batch` |
| Add lines to a draft | `POST` | `/api/v1/invoices/{id}/lines` |
| Send a draft | `POST` | `/api/v1/invoices/{id}/send` |
| Cancel a draft | `DELETE` | `/api/v1/invoices/{id}` |
| Get batch metadata | `GET` | `/api/v1/invoices/batch/{batchId}` |
| List batches (filter `?status=DRAFT`, etc.) | `GET` | `/api/v1/invoices/batches` |
| Download batch PDF | `GET` | `/api/v1/invoices/batch/{batchId}/pdf` |
| List invoices | `GET` | `/api/v1/invoices` |
| Get invoice details (payment + status) | `GET` | `/api/v1/invoices/{id}` |
| Download invoice PDF | `GET` | `/api/v1/invoices/{id}/pdf` |
| Register webhook | `POST` | `/api/v1/webhooks` |
| List webhooks | `GET` | `/api/v1/webhooks` |
| Delete webhook | `DELETE` | `/api/v1/webhooks/{id}` |

---

## Error Handling

The API uses standard HTTP status codes:

| Code | Meaning |
|------|---------|
| `200` | Success |
| `201` | Created successfully |
| `202` | Accepted for async processing |
| `400` | Bad request — check your payload |
| `401` | Unauthorized — check your API key |
| `404` | Resource not found |
| `500` | Server error — contact Truster support |

All error responses include a `message` field explaining what went wrong.

---

## Typical Integration Flow

```
1. Verify your API key             GET    /auth/verify
2. Add workers to your account     POST   /workers (once per worker)
3. Register webhooks               POST   /webhooks (once per endpoint)
4. Submit batch invoice            POST   /invoices/batch (e.g. monthly)
5. Receive webhook notification    ← invoice.created event
6. Download PDF automatically      GET    /invoices/{id}/pdf
```

---

*Truster B2B API Integration Guide — 2026-03-31*

## Advanced Configuration

### 1. Dynamic Schema (Custom Fields)
Your DataHub company configuration allows you to define a `CustomFieldDef` schema. When creating or updating a worker, you can pass an arbitrary JSON object in the `customData` field. The Truster API will dynamically validate the payload against your configured schema (e.g., enforcing required fields, string limits, enum values).

```json
{
  "firstName": "Matti",
  "lastName": "Meikäläinen",
  "email": "matti@example.com",
  "customData": {
    "hygiene_passport": true,
    "driver_license_class": "B"
  }
}
```

### 2. Compliance Engine & Webhooks
Truster DataHub includes a json-rules-engine compliance system. When you configure risk factors (e.g., `"iban_mismatch"`, `"missing_tax_card"`), Truster will automatically evaluate workers and fire `WorkerAlert` instances. You can subscribe to these alerts via Eventarc Webhooks to automatically pause dispatching in your own ERP.

### 3. DataHub MCP Server (AI Agents)
We expose a Model Context Protocol ([MCP](https://modelcontextprotocol.io/)) server endpoint exclusively for our partners at `POST /api/v1/mcp`. Connect your own LangChain, Claude SDK, or Anthropic-API client to execute tools against your isolated DataHub directly from the agent loop.

**Transport.** The endpoint speaks the [MCP Streamable HTTP](https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/transports/#streamable-http) transport in stateless mode — every JSON-RPC message is a single, independent POST. There is no SSE handshake and no session id to manage. The previous `GET /api/v1/mcp/sse` + `POST /api/v1/mcp/message` handshake pair has been removed.

**Auth.** Send your B2B API key as `Authorization: Bearer sk_...`, same as every other `/api/v1/*` endpoint. Tenant scope is enforced from the API key — there is no per-tool company id parameter.

**Headers.** Required on every request:
- `Content-Type: application/json`
- `Accept: application/json, text/event-stream` — the MCP Streamable HTTP spec mandates BOTH values. Sending only one yields HTTP 406 with `{error: {message: "Not Acceptable: Client must accept both application/json and text/event-stream"}}`. Synchronous tool calls return as `application/json`; long-running tool calls may stream back as `text/event-stream` (rare today).

**Available tools.** Run `tools/list` over the endpoint to discover the live catalog programmatically. As of 2026-05-25:
- **Discovery:** `get_schema` — return the custom worker-field definitions configured for your DataHub.
- **Workers:** `search_workers`, `get_worker`, `link_worker_by_sharecode`, `get_tax_card`, `list_garnishments`.
- **Invoices:** `list_invoices`, `list_invoice_batches`, `get_invoice_batch`, `get_invoice_pdf_url`.
- **Webhooks:** `list_webhooks`.

Worker payloads are masked the same way as the REST endpoints (last-4 SSN visible). The only mutation tool exposed is `link_worker_by_sharecode`; create/update operations require the REST surface.

**Mock-mode partners.** This endpoint refuses requests for API keys whose company has `apiSettings.mockMode=true` (HTTP 503 `mock-mode-mcp-unavailable`). Use the REST endpoints under `/api/v1/*` for sandbox integration testing — they emit canned responses.

### 4. Accounting & Ledger Mappings
You can customize how Truster data maps to your General Ledger (GL) and the Finnish Incomes Register (Tulorekisteri). These configurations are managed at the company level and ensure that generated salary artifacts (PDFs, XMLs, and Ledger JSONs) align perfectly with your internal accounting standards.

#### General Ledger (GL) Overrides
Define custom account numbers for different salary components (e.g., base salary, travel compensation, etc.).
- **Account Number:** The target GL account in your ERP (e.g., `3000`).
- **Name (FI/EN):** Display names used in generated reports.
- **Active:** Toggle specific mappings on or off.

#### Tulorekisteri (Incomes Register) Mappings
Control which income types are reported and under which specific transaction codes.
- **Transaction Code:** The official Tulorekisteri code (e.g., `201` for Wage).
- **Reported:** Boolean flag to enable/disable automated reporting for this row type.

These settings are automatically applied during the `POST /api/v1/salaries` flow to produce balanced, ERP-ready accounting entries.
