# Receiving purchase invoices

Inbound flow: poll the purchase-invoice list with a `since` cursor → import each new document into the ERP, with its original PDF → `mark-exported`. Receiving is **pull-based**: there are no webhooks anywhere in this API. Poll on a schedule (every 1–15 minutes is typical for an ERP; never faster than every 2 seconds).

## Statuses and sources

```
NEW → SEEN → EXPORTED          (DIGITIZED_PENDING for digitized documents, see below)
```

| Status | Meaning |
|---|---|
| `NEW` | Received; the original has not been opened yet |
| `SEEN` | The original was opened — `GET /partner/v1/purchase-invoices/{id}/original` was called, or a user opened the document in the Bilnex app. Reading the list or the detail does not change the status. |
| `EXPORTED` | Confirmed imported into the ERP via `mark-exported` — the end state |
| `DIGITIZED_PENDING` | Digitized in the Bilnex app and not yet reviewed there ([digitization](https://sandbox.bilnex.io/docs/digitization.md)) |

| `source` | Meaning |
|---|---|
| `E_INVOICE` | Arrived over the Estonian operator network |
| `PDF` | Added in the Bilnex app from a PDF or a scan, e.g. digitized there. There is no `DIGITIZED` source: digitized documents are `PDF`. Uploading for digitization is not possible through the Partner API — see [digitization](https://sandbox.bilnex.io/docs/digitization.md) |
| `PEPPOL` | Reserved for Peppol documents, which the list does not return yet (below) |

**Incoming Peppol documents are not returned yet.** Bilnex stores them separately, and `GET /partner/v1/purchase-invoices` does not read them. Do not build a flow that depends on receiving Peppol documents through this API.

## 1. Poll — `GET /partner/v1/purchase-invoices?since=&status=&page=`

```bash
curl -s "https://sandbox.bilnex.io/partner/v1/purchase-invoices?since=2026-10-01T00:00:00Z" \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -H "X-Company-Id: <RECEIVER_COMPANY_ID>"
```

```json
{
  "items": [
    {
      "id": "aa360bdf-c873-4a6c-ac80-0a758c3411f6",
      "status": "NEW",
      "source": "E_INVOICE",
      "senderName": "AwesomeERP Sandbox Sender OÜ",
      "invoiceNumber": "2026-0001",
      "date": "2026-10-12T00:00:00",
      "dueDate": "2026-10-26T00:00:00",
      "currency": "EUR",
      "totalAmount": 1178.0,
      "receivedAt": "2026-10-12T10:58:46.701667"
    }
  ],
  "page": 1,
  "pageSize": 50,
  "total": 1,
  "hasMore": false,
  "nextSince": "2026-10-12T10:58:46.701667"
}
```

These are all the fields an incoming document has: `senderName` is the supplier's name, `invoiceNumber` the supplier's number, `totalAmount` the gross total including VAT, and `receivedAt` the moment it arrived at Bilnex. There are no line items, no sender registry code, no IBAN and no net/VAT split; for the full content, fetch the original PDF (section 3).

Passes `PURCHASE_LISTED`. `EINVOICE_RECEIVED_AT_RECEIVER` passes on its own when an e-invoice your sender sent is delivered — listing it is not required.

**Cursor pattern.** `since` (ISO-8601, e.g. `2026-10-01T00:00:00Z`) returns documents received strictly after that instant; a `+` in a UTC offset must be URL-encoded as `%2B`, or use `Z`. An invalid value answers 400 `validation_failed`. The list is newest first. `nextSince` is the newest `receivedAt` on the page — or your own `since` back when the page is empty. Persist it and pass it back as `since` on the next poll. On the first ever poll, use a date safely in the past (e.g. `2026-01-01T00:00:00Z`).

**Pagination.** While `hasMore` is `true`, request the next `page` (1-based) with the SAME `since`. `pageSize` defaults to 50, up to 100. Because the list is newest first, the first page's `nextSince` is the newest of the whole poll: keep the largest `nextSince` you saw, and store it after the last page.

**Dedupe by `id`.** The cursor is time-based and pages are offsets, so a document can appear twice — on two pages when new documents arrive during a poll, or across polls. Keep a processed-ids record (or a unique constraint on the Bilnex id in your ERP) and skip duplicates silently.

`status=` filters by status (e.g. `status=NEW`); an unknown value is ignored rather than refused, so check the spelling. Filtering by `NEW` alone misses documents a user already opened in Bilnex (`SEEN`) and `DIGITIZED_PENDING` documents; cursor-poll everything and branch on status instead. A `DIGITIZED_PENDING` document keeps its `receivedAt` when it is reviewed, so the cursor does not return it again: import it as it is, or keep its id and re-read it with `GET /partner/v1/purchase-invoices/{id}` until its status changes.

## 2. Detail — `GET /partner/v1/purchase-invoices/{id}`

```bash
curl -s https://sandbox.bilnex.io/partner/v1/purchase-invoices/aa360bdf-c873-4a6c-ac80-0a758c3411f6 \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -H "X-Company-Id: <RECEIVER_COMPANY_ID>"
```

```json
{
  "id": "aa360bdf-c873-4a6c-ac80-0a758c3411f6",
  "status": "NEW",
  "source": "E_INVOICE",
  "senderName": "AwesomeERP Sandbox Sender OÜ",
  "invoiceNumber": "2026-0001",
  "date": "2026-10-12T00:00:00",
  "dueDate": "2026-10-26T00:00:00",
  "currency": "EUR",
  "totalAmount": 1178.0,
  "receivedAt": "2026-10-12T10:58:46.701667"
}
```

The same fields as a list item, nothing more. Reading the detail does not change the status. Use it to re-read one document, e.g. to check its status.

## 3. Original file — `GET /partner/v1/purchase-invoices/{id}/original`

Returns a link to the received document as a PDF, valid for 15 minutes. Fetching it moves a `NEW` document to `SEEN`.

```bash
curl -s https://sandbox.bilnex.io/partner/v1/purchase-invoices/aa360bdf-c873-4a6c-ac80-0a758c3411f6/original \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -H "X-Company-Id: <RECEIVER_COMPANY_ID>"
```

```json
{
  "expiresAt": "2026-10-12T11:13:56.39728849",
  "url": "https://s3.eu-north-1.amazonaws.com/…"
}
```

Download immediately; store bytes, not the URL. Attach the original to the ERP document for audit.

## 4. Confirm the import — `POST /partner/v1/purchase-invoices/{id}/mark-exported`

MANDATORY after every successful import into the ERP. This is how Bilnex (and the customer's Bilnex UI) knows the document reached accounting — skipping it leaves documents looking unprocessed forever.

```bash
curl -s -X POST https://sandbox.bilnex.io/partner/v1/purchase-invoices/aa360bdf-c873-4a6c-ac80-0a758c3411f6/mark-exported \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -H "X-Company-Id: <RECEIVER_COMPANY_ID>"
```

Response `200`: the document, now `EXPORTED`:

```json
{
  "id": "aa360bdf-c873-4a6c-ac80-0a758c3411f6",
  "status": "EXPORTED",
  "source": "E_INVOICE",
  "senderName": "AwesomeERP Sandbox Sender OÜ",
  "invoiceNumber": "2026-0001",
  "date": "2026-10-12T00:00:00",
  "dueDate": "2026-10-26T00:00:00",
  "currency": "EUR",
  "totalAmount": 1178.0,
  "receivedAt": "2026-10-12T10:58:46.701667"
}
```

Idempotent: repeating the call answers 200 with the document, still `EXPORTED`. Order your importer so `mark-exported` runs only after the ERP write is durable — if the ERP write fails, do not mark; the document stays visible for the next attempt. Passes `MARKED_EXPORTED`.

## Testing inbound without a full send

Two ways to get documents into the receiver's list in the sandbox:

1. **Real path** — send an `E_INVOICE` from your sender company to your receiver company's regCode; it arrives a moment later.
2. **Trigger** — `POST /partner/v1/sandbox/trigger` with `{"event":"inbound_einvoice"}` delivers an e-invoice from the fixture Sandbox Capable OÜ to your receiver company; inbound triggers always deliver there, whatever `X-Company-Id` says. `{"event":"inbound_peppol"}` delivers a Peppol document there too, but the list does not return it (see above). See [sandbox](https://sandbox.bilnex.io/docs/sandbox.md).

## Reference importer loop

```
cursor = load_cursor() or "2026-01-01T00:00:00Z"
newest = cursor
page = 1
loop:
  resp = GET /purchase-invoices?since={cursor}&page={page}
  newest = max(newest, resp.nextSince)     # newest first: page 1 holds the newest
  for doc in resp.items:
    if already_imported(doc.id): continue
    original = GET /purchase-invoices/{doc.id}/original    # PDF link, valid 15 minutes
    erp_write(doc, download(original.url))   # durable ERP insert first
    POST /purchase-invoices/{doc.id}/mark-exported
    record_imported(doc.id)
  if resp.hasMore: page += 1; continue
  cursor = newest; save_cursor(cursor); page = 1
  sleep(poll_interval)                     # >= 2 seconds; minutes in production
```

API version 2026-08-01. /partner/v1 changes are additive-only; see [versioning-policy](https://sandbox.bilnex.io/docs/versioning-policy.md).

---
**Building with a coding agent?** Start from [agents.md](https://sandbox.bilnex.io/agents.md) or install the skill: `npx skills add https://sandbox.bilnex.io`. Machine-readable index: [llms.txt](https://sandbox.bilnex.io/llms.txt). Every page on this site is also plain markdown — append `.md`.
