> ## Documentation Index
> Fetch the complete documentation index at: https://docs.watchdog.no/llms.txt
> Use this file to discover all available pages before exploring further.

# Invoice API quickstart

> Create an invoice, upload source files, submit an import, and retrieve its result.

This walkthrough uses `curl` and `jq` with the [API preview](/api-preview/introduction).
Set `API_URL` to your development environment; the examples use a local Worker at port 3500.
These endpoints are under development and are separate from the current API at `api.watchdog.no`.

## Connect and check access

In the development app, open **Settings → Personal → API keys → Create API key**.
Choose **Write** for this walkthrough and select the organization you will use. **Read** is enough
for reporting, selectors, filters, related documents, downloads, and workflow status. **Admin** also
includes Write and supports organization-admin operations.

Copy the token when it is shown. Open your organization in the app and use the `org_…` segment from
its URL as the organization ID.

```bash theme={null}
export API_URL=http://localhost:3500
export API_KEY='<your personal API key>'
export ORGANIZATION_ID='<org_… ID from the organization URL>'
curl --fail-with-body "$API_URL/v1/organizations/$ORGANIZATION_ID" -H "Authorization: Bearer $API_KEY" -H "X-Organization-Id: $ORGANIZATION_ID"
```

The response identifies the organization and reports configured and effective levels under `access`.
Use `GET /v1/me` without an organization header to inspect caller identity. Compare it with the saved key details after reloading settings.
An Admin key is limited to Write where you are a member. Every organization-scoped personal-key request requires
**X-Organization-Id**; a supplied header for a Clerk session must match its active organization.
Migrated organization keys can omit the header only to use their original organization.

All-organization access follows your current and future memberships. Selected access remains limited
to the chosen organizations. Membership and role changes apply after Clerk webhook synchronization. Key revocation is immediate. A missing permission or
unauthorized organization returns `403`; a revoked key returns `401`. See
[authentication and API keys](/api-reference/authentication) for role limits and account requirements.

This walkthrough covers invoices, documents, supplier/recipient and team selectors, and workflow runs.
Use the [agreement walkthrough](/api-preview/agreements) to manage agreements, prices, and source links
with the same Write key. Alert and compliance-check execution endpoints remain deferred.

## List existing suppliers and recipients

Use [List suppliers](/api-preview/endpoints/parties/list-suppliers) and
[List recipients](/api-preview/endpoints/parties/list-recipients) to find existing parties:

```bash theme={null}
curl --fail-with-body "$API_URL/v1/suppliers?limit=50" -H "Authorization: Bearer $API_KEY" -H "X-Organization-Id: $ORGANIZATION_ID"
curl --fail-with-body "$API_URL/v1/recipients?limit=50" -H "Authorization: Bearer $API_KEY" -H "X-Organization-Id: $ORGANIZATION_ID"
```

Both require Read access. Add `search=Acme` to filter names case-insensitively. Results are
ordered by name and ID, with 50 records per page by default and a maximum of 100. Each response
contains compact party summaries in `data` and a `next_cursor`; pass that value as `cursor` with
the same search to continue. A null cursor means the final page. Retrieve a party by ID for its
full master record.

## Create a structured invoice

For an organization with no parties, create a supplier and recipient first. Each create returns
`201` and a `Location` header. No idempotency key is required.

```bash theme={null}
curl --fail-with-body "$API_URL/v1/suppliers" \
  -H "Authorization: Bearer $API_KEY" -H "X-Organization-Id: $ORGANIZATION_ID" -H 'Content-Type: application/json' \
  --data '{"name":"Acme AS","organization_number":"123456785","org_country":"NO","email":"billing@acme.example","address":{"city":"Oslo","country_code":"NO"}}' > supplier.json
curl --fail-with-body "$API_URL/v1/recipients" \
  -H "Authorization: Bearer $API_KEY" -H "X-Organization-Id: $ORGANIZATION_ID" -H 'Content-Type: application/json' \
  --data '{"name":"Ecma AS","organization_number":"987654325","org_country":"NO"}' > recipient.json
export SUPPLIER_ID=$(jq -r '.id' supplier.json)
export RECIPIENT_ID=$(jq -r '.id' recipient.json)

curl --fail-with-body "$API_URL/v1/suppliers?search=Acme" -H "Authorization: Bearer $API_KEY" -H "X-Organization-Id: $ORGANIZATION_ID"
curl --fail-with-body "$API_URL/v1/recipients?search=Ecma" -H "Authorization: Bearer $API_KEY" -H "X-Organization-Id: $ORGANIZATION_ID"


jq -n --arg supplier "$SUPPLIER_ID" --arg recipient "$RECIPIENT_ID" '{
  supplier_id: $supplier,
  recipient_id: $recipient,
  invoice_number: "API-DEMO-001",
  category: "invoice",
  issued_date: "2026-09-07",
  currency_code: "NOK",
  total_amount_excluding_vat: "100.00",
  total_amount_including_vat: "125.00",
  line_items: [{
    line_number: 1,
    description: "Consulting",
    quantity: "1",
    unit_price: "100.00",
    vat_rate: "0.25",
    total_excluding_vat: "100.00"
  }]
}' > invoice-input.json

curl --fail-with-body "$API_URL/v1/invoices" \
  -H "Authorization: Bearer $API_KEY" -H "X-Organization-Id: $ORGANIZATION_ID" \
  -H 'Content-Type: application/json' \
  --data-binary @invoice-input.json > invoice.json
export INVOICE_ID=$(jq -r '.id' invoice.json)
jq '{id, financially_valid, financial_errors, relationships}' invoice.json
```

A new invoice returns `201` and its detail. An invoice with the same business identity returns
`409` without creating another invoice. Validation failures return `400` without committing
anything. To recover the original response after a timeout, optionally include an
[Idempotency-Key](/api-preview/conventions#safely-retrying-requests).

Amounts, quantities, and rates are decimal **strings**. Rates are fractions: `"0.25"` means 25%.
Dates are `YYYY-MM-DD`. Unknown values are `null`. Do not send computed `financially_valid`,
`financial_errors`, `origin`, or original source values in create/edit inputs. Structured invoices
must pass the documented financial checks, with a tolerance of 1 currency unit.

## Maintain a canonical party

```bash theme={null}
curl --fail-with-body -X PATCH "$API_URL/v1/suppliers/$SUPPLIER_ID" \
  -H "Authorization: Bearer $API_KEY" -H "X-Organization-Id: $ORGANIZATION_ID" -H 'Content-Type: application/json' \
  --data '{"name":"Acme Consulting AS","address":{"city":"Bergen"}}'
curl --fail-with-body "$API_URL/v1/suppliers/$SUPPLIER_ID" -H "Authorization: Bearer $API_KEY" -H "X-Organization-Id: $ORGANIZATION_ID"
curl --fail-with-body -X PATCH "$API_URL/v1/recipients/$RECIPIENT_ID" \
  -H "Authorization: Bearer $API_KEY" -H "X-Organization-Id: $ORGANIZATION_ID" -H 'Content-Type: application/json' \
  --data '{"name":"Ecma Updated AS","address":{"city":"Trondheim"}}'
curl --fail-with-body "$API_URL/v1/invoices/$INVOICE_ID" -H "Authorization: Bearer $API_KEY" -H "X-Organization-Id: $ORGANIZATION_ID" | jq '.supplier'
```

The master record now says Acme Consulting AS in Bergen; the invoice still says Acme AS in Oslo.
The recipient master record has also changed; the invoice retains its original recipient snapshot.
New invoices created with these IDs use the updated master records.
PATCH preserves omitted fields and address members;
null clears optional values, and `address: null` clears the whole address. Unchanged normalized
values preserve timestamps. Names must contain a letter or digit and be at most 500 characters,
emails valid, websites absolute HTTP(S) URLs,
and country codes two letters. Unknown optional values are null.

Organization numbers remain optional. Recognized country-specific formats and checksums are validated;
valid identifiers are stored canonically, preserving leading zeros and identifier families.
For example, `NO123456785MVA` becomes `123456785` / `NO`, `FI19675438` becomes `19675438` / `FI`,
and `PL5851101301` becomes `5851101301` / `PL`.

Set `org_country` explicitly when known. An existing or explicitly supplied registration country is
authoritative; a conflicting recognized prefix is rejected. For a missing country, the API validates
a recognized prefix first. A failed prefix stops inference. Only without a recognized prefix does it
try the address country. Country is inferred only when that country's checksum passes.
Unsupported or format-only identifiers receive basic input checks without country inference.
Validation does not verify registry existence.

Known registration countries do not change on address edits. A missing country may be filled when
the number or address country changes and validation succeeds. Explicit `org_country: null` clears it
and suppresses inference for that write; later validated ingestion may refill it. Contact-only edits
preserve unchanged legacy identifiers. Clearing the number does not clear registration country.

Duplicate normalized organization numbers, or duplicate normalized names among unnumbered parties, return `409 conflict`, with
`error.existing_party.type` and `.id` when a conflicting record is available to you. Retrieve that
record before deciding which ID to use; the API never merges parties. Validation returns `400`.
Canonical edits do not rewrite invoice evidence, invalidate snapshot-based assessments, or start
matching/compliance work.

## Find invoices

Use canonical party IDs to filter invoices, and `search` to match an invoice number or title.
For example, list this supplier's September invoices, newest issue date first:

```bash theme={null}
curl --fail-with-body --get "$API_URL/v1/invoices" \
  -H "Authorization: Bearer $API_KEY" -H "X-Organization-Id: $ORGANIZATION_ID" \
  --data-urlencode "supplier_ids=$SUPPLIER_ID" \
  --data-urlencode 'issued_from=2026-09-01' \
  --data-urlencode 'issued_through=2026-09-30' \
  --data-urlencode 'sort=issued_date' \
  --data-urlencode 'direction=desc' \
  --data-urlencode 'limit=20' > invoices.json
jq '.data' invoices.json
```

Both dates are inclusive. Omit either date for an open-ended range. If `next_cursor` is not null,
repeat the request with the same filters and sort, adding `--data-urlencode "cursor=$NEXT_CURSOR"`,
where `NEXT_CURSOR` is the returned value. Stop when it is null.

`GET /v1/invoices/metrics` accepts the same filters and returns counts and totals by currency;
omit pagination and sort parameters. To filter by team, first list `/v1/teams`, then pass its ID
as `team_ids`. Read access includes both party and team filters.

## Read and edit

```bash theme={null}
curl --fail-with-body "$API_URL/v1/invoices/$INVOICE_ID" -H "Authorization: Bearer $API_KEY" -H "X-Organization-Id: $ORGANIZATION_ID"
curl --fail-with-body "$API_URL/v1/invoices/$INVOICE_ID/line-items" -H "Authorization: Bearer $API_KEY" -H "X-Organization-Id: $ORGANIZATION_ID"
curl --fail-with-body "$API_URL/v1/invoices/$INVOICE_ID/documents" -H "Authorization: Bearer $API_KEY" -H "X-Organization-Id: $ORGANIZATION_ID"
curl --fail-with-body -X PATCH "$API_URL/v1/invoices/$INVOICE_ID" \
  -H "Authorization: Bearer $API_KEY" -H "X-Organization-Id: $ORGANIZATION_ID" -H 'Content-Type: application/json' \
  --data '{"title":"September consulting"}'
```

Detail contains relationship counts; lines and documents are separate paginated collections.
Party fields are invoice snapshots. Their IDs identify canonical parties, while their names and
addresses describe the invoice as captured.

PATCH sends only changed invoice fields. If you supply `line_items`, send the **complete resulting
collection**, retaining IDs of existing lines. Omitted lines are removed; lines without IDs are
added. Financial changes must leave the complete invoice financially valid. A failed edit rolls
back every supplied change. Metadata-only edits can leave an imported financial inconsistency in
place. Editing does not refresh compliance analysis or imply approval.

DELETE `/v1/invoices/{id}` soft-deletes an invoice. POST `/v1/invoices/{id}/restore` restores it.
Both are repeatable and return the invoice detail.

## Upload a file and import it

Use a PDF or XML invoice file on your computer. The example below uses XML:

```bash theme={null}
export SOURCE_FILE=./invoice.xml
jq -n --arg name invoice.xml --argjson size "$(wc -c < "$SOURCE_FILE")" \
  '{file_name: $name, mime_type: "application/xml", file_size: $size}' > upload-input.json
curl --fail-with-body "$API_URL/v1/documents/upload" \
  -H "Authorization: Bearer $API_KEY" -H "X-Organization-Id: $ORGANIZATION_ID" -H 'Content-Type: application/json' \
  --data-binary @upload-input.json > upload.json

jq -r '.headers | to_entries[] | "\(.key): \(.value)"' upload.json > upload.headers
curl --fail-with-body --upload-file "$SOURCE_FILE" \
  --header @upload.headers "$(jq -r '.upload_url' upload.json)"

export DOCUMENT_ID=$(jq -r '.document_id' upload.json)
jq -n --arg id "$DOCUMENT_ID" '{primary_document_id: $id}' > import-input.json
curl --fail-with-body "$API_URL/v1/invoices/imports" \
  -H "Authorization: Bearer $API_KEY" -H "X-Organization-Id: $ORGANIZATION_ID" \
  -H 'Content-Type: application/json' \
  --data-binary @import-input.json > admission.json
cat admission.json
```

The upload authorization creates a Document placeholder, **not** an import. Send bytes to the
returned signed URL using every returned header. Do not send your API bearer token to storage.
An upload replay can return `412` because the object already exists; admission verifies its bytes.
There is no upload completion call. For a PDF, use its filename and `application/pdf`; actual
content is inspected by the workflow. Add already-uploaded, distinct Document IDs in
`attachment_document_ids`, in the order you want retained. Each file must be at least 100 bytes;
primaries are at most 50 MiB and each attachment at most 25 MiB, with no attachment-count or combined-size cap.

PDF-primary extraction still has model input limits. If the combined content exceeds those limits,
the import fails with `validation_error` and stops automatic retries. All uploaded Documents remain
available; no attachments are silently omitted. Submit a smaller source set as a new import.
To attach an uploaded Document to an existing invoice, use the [Document commands](/api-preview/invoices#managing-invoice-documents).

Handle both admission responses:

* `202 { "outcome": "accepted", "import_id": "…", "workflow_run_id": "…", "workflow_run_item_id": "…" }`: processing is
  admitted. The `Location` header names the import. `200` with this shape is an idempotent replay.
* `200 { "outcome": "duplicate", "invoice_id": "…" }`: the primary file already belongs to a
  published invoice. Read that invoice; no import or workflow was created.

For an accepted response, poll its run every few seconds using the returned `workflow_run_id`:

```bash theme={null}
export IMPORT_ID=$(jq -r '.import_id' admission.json)
export RUN_ID=$(jq -r '.workflow_run_id' admission.json)
curl --fail-with-body "$API_URL/v1/workflow-runs/$RUN_ID" -H "Authorization: Bearer $API_KEY" -H "X-Organization-Id: $ORGANIZATION_ID"
```

`queued` and `running` are in progress. After `completed`, GET
`/v1/workflow-runs/$RUN_ID/items` for item outcomes and invoice references. A single import has
one item; admission also returns `workflow_run_item_id` for its direct item URL. Read
`/v1/invoices/imports/$IMPORT_ID` for current import details, sources, and evidence. Terminal import
results are:

| Status      | Outcome       | Next step                                                        |
| ----------- | ------------- | ---------------------------------------------------------------- |
| `completed` | `imported`    | Read `invoice_id`, including its financial result.               |
| `completed` | `duplicate`   | Read the existing `invoice_id`.                                  |
| `completed` | `not_invoice` | No invoice was created.                                          |
| `failed`    | `null`        | Read `failure.code`, `failure.message`, and retained `evidence`. |
| `cancelled` | `null`        | No invoice was published by this execution.                      |

An import can complete successfully with `financially_valid: false` on its invoice. This retains
the source's inconsistent amounts and concrete financial errors for correction. It is different
from unusable extraction, which fails the import. Detailed extraction confidence remains in import
evidence.

POST `/v1/invoices/imports/{id}/cancel` cancels queued/running work; repeating cancellation
succeeds. POST `/v1/invoices/imports/{id}/retry` starts a new run for a
failed/cancelled import. Retrying unchanged malformed files will fail again. A missing or changed
source requires a new Document and import. Replaying an earlier admission refers to its original
execution; always read the import for its current run and status.

GET `/v1/workflow-runs?status=queued,running` discovers active work. Poll known IDs together using
`GET /v1/workflow-runs?ids=<id>,<id>&limit=100` without a status filter to observe completion.
See the [workflow-run guide](/api-preview/workflow-runs) for history, cancellation, results, and polling rules.
GET `/v1/invoices/imports` lists imports. Read `/v1/invoices/{id}/documents` for source Document IDs,
then authorize a download with `GET /v1/documents/{id}/download`. Fetch its short-lived `download_url`
without your API bearer token. The same Read access covers invoice and agreement source downloads.

## Lists, metrics, and errors

```bash theme={null}
curl --fail-with-body --get "$API_URL/v1/invoices" \
  -H "Authorization: Bearer $API_KEY" -H "X-Organization-Id: $ORGANIZATION_ID" \
  --data-urlencode 'currency_codes=NOK,EUR' \
  --data-urlencode 'financially_valid=false' \
  --data-urlencode 'sort=issued_date' --data-urlencode 'direction=desc' --data-urlencode 'limit=20'
```

Lists return `{ data, next_cursor }`. Send `next_cursor` as `cursor` with the same filters and sort;
stop when it is `null`. Defaults are 50 records, `sort=created_at`, and `direction=desc`; the maximum
limit is 100. Null sort values come last. Active invoices are the default; `deleted=true` selects
only deleted invoices. Comma-separated filters and all five sorts are listed in OpenAPI.

Use GET `/v1/invoices/metrics` with the same filters, omitting pagination and sort. Monetary totals
are grouped by currency; never add amounts in different currencies together.

Errors include `{ error: { code, message, request_id } }`. Validation failures also contain bounded
`details` when available. Keep `request_id` when reporting a problem. Check `error.code` as well as
HTTP status: `409 source_unavailable` requires checking files, while `409 conflict` can describe
idempotency, capacity, or incompatible state. For `429`, wait for `Retry-After`. A `503` import
admission can include `error.import_id`; poll that retained import before deciding to retry.
