# Bilnex Partner API — agents.md

Bilnex is an e-invoicing platform for Baltic/Nordic SMEs. The Partner API (`/partner/v1`) lets an ERP send sales invoices as Estonian operator-channel e-invoices or as PDF by email, receive purchase invoices by polling, and confirm each import back to Bilnex. Each customer company connects with its own key. Not available through the API yet: sending over Peppol, listing incoming Peppol documents, and digitization.

This file is the entry point for coding agents building a Bilnex integration. Follow it top to bottom.

## You don't need an account to get started

1. `POST https://sandbox.bilnex.io/partner/v1/sandbox` with your email and ERP name (no signup, no login). You get an API key, two pre-provisioned Estonian test companies (a sender and a receiver), a per-company key for each (`companyKeys` — the kind of key production uses), and three fixture counterparties.
2. Build the integration against `https://sandbox.bilnex.io` using that key. Sandbox email is intercepted (never delivered) and e-invoice delivery is simulated, so every flow is testable end to end.
3. Poll `GET /partner/v1/sandbox/verification` until `readyForProduction` is `true` — that is the definition of done for your code. Production then needs only a different base URL and key, but switching it on is not self-serve yet: Bilnex opens the Partner API per integration, on request. See **Production today** below.

If you are a human handing this to a coding agent, paste this prompt:

```text
Build a Bilnex e-invoicing integration into this codebase.
1. Read https://sandbox.bilnex.io/agents.md and follow it exactly.
2. Provision your own sandbox key: POST https://sandbox.bilnex.io/partner/v1/sandbox with {"email":"<my email>","erpName":"<this ERP's name>","ref":"agent"} — no account needed. Store the key in server-side config only, never in frontend code or the repo.
3. Implement outbound invoicing (capability check, e-invoice send, EMAIL/PDF fallback) and inbound purchase invoices (poll with `since`, then mark-exported), wired into this codebase's existing models.
4. Test every flow against the sandbox, not with mocks.
Do not report the integration complete until GET /partner/v1/sandbox/verification returns "readyForProduction": true. If a required check is pending, read its fixHint and docsUrl, fix, and re-run.
Production is then opened on request, not self-serve: when you report completion, include the verification response and the steps in the "Production today" section of agents.md.
```

## Machine-readable resources

- `https://sandbox.bilnex.io/llms.txt` — llms.txt index of every docs page with descriptions.
- `https://sandbox.bilnex.io/partner/v1/openapi.json` — OpenAPI 3.1 spec for `/partner/v1`.
- `https://sandbox.bilnex.io/.well-known/agent-skills/index.json` — agent-skills discovery index. Install the integration skill with `npx skills add https://sandbox.bilnex.io`.
- `https://sandbox.bilnex.io/skills/bilnex-erp-integration/SKILL.md` — the skill itself (build procedure, routing table, references).
- Every page under `/docs/` is also plain markdown: append `.md` to any docs URL to get the raw source.

## Environments and keys

| | Sandbox | Production |
|---|---|---|
| Base URL | `https://sandbox.bilnex.io` | `https://api.app.bilnex.io` |
| Key prefix | `blx_sbx_` | `blx_live_` |
| How issued | Self-serve `POST /partner/v1/sandbox`: one key for both sandbox companies, plus one company key per company | Generated by each customer company in Bilnex (Settings → ERP integration), one company per key, once Bilnex has switched the Partner API on for that company (see **Production today**) |
| Key lifetime | 2 days; `POST /partner/v1/sandbox/claim` extends it once, to 30 days from the claim | No expiry; ends when the customer generates a new key or disconnects the ERP |
| Limits | 60 requests/minute, 5,000/day, 200 sends/day per key | The same |
| Email | Intercepted, readable at `/partner/v1/sandbox/emails`, never delivered | Delivered |
| E-invoice delivery | Simulated: an e-invoice to your receiver company arrives within seconds | Real operator network |
| Peppol | Sending answers 501; incoming Peppol documents are not returned by the API | The same |
| `/partner/v1/sandbox/*` endpoints | Available | 404 |

Keys are host-bound: a `blx_sbx_` key on `api.app.bilnex.io` (or `blx_live_` on `sandbox.bilnex.io`) returns 401 with a hint naming the correct host. Paths and payloads are identical on both hosts. API version: `2026-08-01`.

Auth on every authenticated request: `Authorization: Bearer <YOUR_API_KEY>` (the header `X-API-Key: <YOUR_API_KEY>` is also accepted). The sandbox key from provisioning acts as the sender company by default; add `X-Company-Id: <uuid>` to act as the other company it owns (a company it does not own returns 403 `company_not_owned`; a value that is not a UUID returns 400 `invalid_company_id`). A company key — every production key, and the sandbox's `companyKeys` — acts for its one company and needs no `X-Company-Id`.

## Limits, retries and idempotency

- **Errors** come back as `{"error": {"code", "message", "hint", "docsUrl"}}`. Branch on `code`; show `hint` to the person who has to act. The full table is in [errors](https://sandbox.bilnex.io/docs/errors.md).
- **Per key**, sandbox and production alike: 60 requests a minute, 5,000 a day and 200 sends a day (`sendsPerDay`). Day windows are fixed 24-hour windows counted from the first request, not midnight UTC. Going over answers 429 `rate_limited` with `Retry-After` in seconds — wait that long. Poll no faster than every 2 seconds.
- **Headers.** Authenticated responses carry `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` — the number of seconds until the window resets, not a timestamp. They are absent on 401, 403 and the per-key 429, which carries `Retry-After` instead. `limits.maxInvoices` (500) and `limits.maxEmails` (200) are reported but not enforced.
- **E-mail limits**, per company, in production and in the sandbox: at most 10 recipients a minute, 100 an hour, 20 different recipient domains an hour, and the same invoice to the same address at most 3 times a day. Reaching one answers 429 `email_rate_limited`. Do not retry it automatically: a company that keeps reaching the e-mail limits within an hour is suspended, and then every call for it answers 403 `account_suspended`.
- **`Idempotency-Key`** on create and on send: send a new UUID with every request. Keys are kept per operation, and for a send per invoice; reusing one for the same operation within 24 hours returns the first response, even if the body differs, and creates or sends nothing. 409 `idempotency_conflict` means the first request with that key is still running. If a request timed out, retry it with the same key.
- **Sending twice sends twice.** Bilnex does not stop a second send of the same invoice. Send each invoice once and record that you did; never retry a send that answered 200.
- **Retry** 500 `internal_error` with backoff, and 429 `rate_limited` after `Retry-After`. Never retry automatically: 429 `email_rate_limited`, 503 `feature_disabled` (switched off for that company — not retryable), 4xx validation or ownership errors, or a send that answered 200. 402 `upgrade_required` means the customer's Bilnex plan does not cover the action, for example its monthly document quota; surface it to the customer.

## Quickstart — the whole happy path, in order

Run these 13 steps in order. Each step names the [verification check(s)](https://sandbox.bilnex.io/docs/verification.md) it flips to `pass`. Checks are sticky — once passed they stay passed until `POST /partner/v1/sandbox/reset`. The responses are from a real sandbox run; ids, names and timestamps will differ in yours.

### 1. Provision a sandbox key (no auth)

```bash
curl -s -X POST https://sandbox.bilnex.io/partner/v1/sandbox \
  -H "Content-Type: application/json" \
  -d '{"email":"dev@erp.example","erpName":"AwesomeERP","ref":"agent"}'
```

Response `201 Created`:

```json
{
  "apiKey": "blx_sbx_<secret, shown once>",
  "keyId": "cd9e4507-92ab-421e-b8d3-c746b0ecb352",
  "keyPrefix": "blx_sbx_",
  "expiresAt": "2026-10-14T10:58:31.230669582",
  "companies": {
    "sender": {
      "companyId": "f2fe5e72-0792-41f4-88b7-a9ce5fda82be",
      "name": "AwesomeERP Sandbox Sender OÜ",
      "regCode": "95000040",
      "role": "sender",
      "peppolId": "0191:95000040"
    },
    "receiver": {
      "companyId": "2cc86222-3717-4f26-b9f9-378dc89e9384",
      "name": "AwesomeERP Sandbox Receiver OÜ",
      "regCode": "95000041",
      "role": "receiver",
      "peppolId": "0191:95000041"
    }
  },
  "fixtures": [
    {
      "name": "Sandbox Capable OÜ",
      "regCode": "95999901",
      "behavior": "accepts_einvoice",
      "description": "Accepts operator-channel e-invoices and Peppol documents."
    },
    {
      "name": "Sandbox Paper OÜ",
      "regCode": "95999902",
      "behavior": "not_einvoice_capable",
      "description": "Is not registered to receive e-invoices — send this one a PDF by email instead."
    },
    {
      "name": "Sandbox Rejector OÜ",
      "regCode": "95999903",
      "behavior": "rejects_einvoice",
      "description": "Always refuses e-invoices at the operator, so you can exercise the failure path."
    }
  ],
  "limits": {
    "requestsPerMinute": 60,
    "requestsPerDay": 5000,
    "sendsPerDay": 200,
    "maxInvoices": 500,
    "maxEmails": 200
  },
  "urls": {
    "docs": "https://sandbox.bilnex.io/agents.md",
    "verification": "https://sandbox.bilnex.io/partner/v1/sandbox/verification",
    "openapi": "https://sandbox.bilnex.io/partner/v1/openapi.json",
    "claim": "https://sandbox.bilnex.io/partner/v1/sandbox/claim"
  },
  "companyKeys": {
    "sender": {
      "companyId": "f2fe5e72-0792-41f4-88b7-a9ce5fda82be",
      "apiKey": "blx_sbx_<secret, shown once>",
      "keyId": "5ea3816d-45b0-4a11-ab80-1fe246edd789",
      "keyPrefix": "blx_sbx_",
      "last4": "C9Sv"
    },
    "receiver": {
      "companyId": "2cc86222-3717-4f26-b9f9-378dc89e9384",
      "apiKey": "blx_sbx_<secret, shown once>",
      "keyId": "1f06a99c-1ff3-41fc-a862-b92148a8453f",
      "keyPrefix": "blx_sbx_",
      "last4": "4y4z"
    }
  }
}
```

`apiKey` and the two `companyKeys` secrets are shown exactly once — store them in server-side config now (environment variable or secret store). Never write them into the repo. Timestamps have no UTC offset. In every step below, replace `<YOUR_API_KEY>` with `apiKey`, `<RECEIVER_COMPANY_ID>` with `companies.receiver.companyId` and `<RECEIVER_REG_CODE>` with `companies.receiver.regCode`. Each company key works the same way for its own company, as production keys do.

Satisfies: nothing yet (provisioning is the prerequisite).

### 2. Verify auth works

```bash
curl -s https://sandbox.bilnex.io/partner/v1/me \
  -H "Authorization: Bearer <YOUR_API_KEY>"
```

Response `200 OK`:

```json
{
  "environment": "sandbox",
  "keyId": "cd9e4507-92ab-421e-b8d3-c746b0ecb352",
  "keyExpiresAt": "2026-10-14T10:58:31.23067",
  "actingCompany": {
    "companyId": "f2fe5e72-0792-41f4-88b7-a9ce5fda82be",
    "name": "AwesomeERP Sandbox Sender OÜ",
    "regCode": "95000040",
    "role": "sender",
    "peppolId": "0191:95000040"
  },
  "companies": [
    {
      "companyId": "2cc86222-3717-4f26-b9f9-378dc89e9384",
      "name": "AwesomeERP Sandbox Receiver OÜ",
      "regCode": "95000041",
      "role": "receiver",
      "peppolId": "0191:95000041"
    },
    {
      "companyId": "f2fe5e72-0792-41f4-88b7-a9ce5fda82be",
      "name": "AwesomeERP Sandbox Sender OÜ",
      "regCode": "95000040",
      "role": "sender",
      "peppolId": "0191:95000040"
    }
  ],
  "limits": {
    "requestsPerMinute": 60,
    "requestsPerDay": 5000,
    "sendsPerDay": 200,
    "maxInvoices": 500,
    "maxEmails": 200
  },
  "usage": {
    "requestsToday": 1,
    "sendsToday": 0,
    "emailsCaptured": 0
  }
}
```

In production `environment` is `production` and a company key's `role` is `company`.

Satisfies: `AUTH_OK`.

### 3. Capability check — a capable receiver

```bash
curl -s https://sandbox.bilnex.io/partner/v1/customers/<RECEIVER_REG_CODE>/e-invoice-capability \
  -H "Authorization: Bearer <YOUR_API_KEY>"
```

Response `200 OK`:

```json
{
  "regCode": "95000041",
  "name": "AwesomeERP Sandbox Receiver OÜ",
  "eInvoiceCapable": true,
  "operator": "BILNEX",
  "peppolCapable": true,
  "peppolId": "0191:95000041",
  "recommendedChannel": "E_INVOICE",
  "checkedAt": "2026-10-12T10:58:32.43101703"
}
```

(`95999901` — Sandbox Capable OÜ — also returns `eInvoiceCapable: true` and works for this check.)

Satisfies: `CAPABILITY_CHECKED_CAPABLE`.

### 4. Capability check — a NOT capable receiver

```bash
curl -s https://sandbox.bilnex.io/partner/v1/customers/95999902/e-invoice-capability \
  -H "Authorization: Bearer <YOUR_API_KEY>"
```

Response `200 OK`:

```json
{
  "regCode": "95999902",
  "name": "Sandbox Paper OÜ",
  "eInvoiceCapable": false,
  "operator": null,
  "peppolCapable": false,
  "peppolId": null,
  "recommendedChannel": "EMAIL",
  "checkedAt": "2026-10-12T10:58:32.637562205"
}
```

Your integration must branch on `eInvoiceCapable`: `false` means fall back to `EMAIL`. Never assume capability without this call.

`recommendedChannel` is `E_INVOICE`, `EMAIL` or `PEPPOL`. Sending over Peppol is not available yet — channel `PEPPOL` answers 501 `peppol_send_unavailable` — so treat `PEPPOL` as `E_INVOICE` when `eInvoiceCapable` is `true`, otherwise as `EMAIL`.

Satisfies: `CAPABILITY_CHECKED_NOT_CAPABLE`.

### 5. Create an invoice (addressed to the receiver company)

```bash
curl -s -X POST https://sandbox.bilnex.io/partner/v1/invoices \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 19dffedd-56dd-442a-b0e7-6420afe40544" \
  -d '{
    "customer": {
      "name": "AwesomeERP Sandbox Receiver OÜ",
      "regCode": "<RECEIVER_REG_CODE>",
      "email": "arved@receiver.example",
      "address": "Sinilille tee 4, 10115 Tallinn, Estonia"
    },
    "date": "2026-10-12",
    "dueDate": "2026-10-26",
    "currency": "EUR",
    "language": "ET",
    "items": [
      {
        "description": "Consulting services, September 2026",
        "quantity": 10.00,
        "unit": "HR",
        "price": 95.00,
        "vatRate": 24
      }
    ]
  }'
```

Response `201 Created`:

```json
{
  "id": "9e0a9088-a4b3-4d80-9d17-4917667a95f7",
  "number": "2026-0001",
  "status": "SAVED",
  "category": "PDF",
  "customerName": "AwesomeERP Sandbox Receiver OÜ",
  "customerRegCode": "95000041",
  "date": "2026-10-12",
  "dueDate": "2026-10-26",
  "totalAmount": 1178.0
}
```

That is the whole invoice object: create, get and list return exactly these fields. The server computes all totals; `totalAmount` is the gross total (10 × 95.00 + 24 % VAT = 1178.00). There are no line items, net amount or VAT breakdown in the response — your ERP keeps its own lines and VAT (it sent them) and stores Bilnex's `id`, `number` and `totalAmount`. `customer.email` is optional; it is the default recipient when the invoice is e-mailed. `category` stays `PDF` until the invoice is sent as an e-invoice.

Reusing the `Idempotency-Key` returns this same response, even with a different body — use a new UUID for each new invoice.

Satisfies: `INVOICE_CREATED`.

### 6. Fetch the invoice PDF

```bash
curl -s https://sandbox.bilnex.io/partner/v1/invoices/9e0a9088-a4b3-4d80-9d17-4917667a95f7/pdf \
  -H "Authorization: Bearer <YOUR_API_KEY>"
```

Response `200 OK`:

```json
{
  "expiresAt": "2026-10-12T11:13:44.9724759",
  "pdfUrl": "https://s3.eu-north-1.amazonaws.com/<presigned, 15 minutes>"
}
```

The URL is presigned and valid for 15 minutes — download immediately, do not persist the URL.

Satisfies: `PDF_FETCHED`.

### 7. Send it as an e-invoice

```bash
curl -s -X POST https://sandbox.bilnex.io/partner/v1/invoices/9e0a9088-a4b3-4d80-9d17-4917667a95f7/send \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 0b3c8e5d-2f7a-4c19-8a6b-d4e1f9a0c752" \
  -d '{"channel":"E_INVOICE"}'
```

Response `200 OK`:

```json
{
  "id": "9e0a9088-a4b3-4d80-9d17-4917667a95f7",
  "status": "SENT",
  "category": "E_INVOICE",
  "channel": "E_INVOICE",
  "sentAt": "2026-10-12T10:58:46.100115571"
}
```

`SENT` is TERMINAL for `E_INVOICE` — there is no `DELIVERED` status and no read receipt. Do not wait or poll for anything further on this invoice.

Send each invoice once and record that you did. Calling send again for this invoice would send it again — Bilnex does not stop a second send. Never retry a send that answered 200; if a send timed out, retry it with the same `Idempotency-Key`, which returns the first response instead of sending again.

If the e-invoice is refused, the invoice goes back to `DRAFT` (with `category` `E_INVOICE`) and needs a fallback channel (`EMAIL`) or a person. In the sandbox, fixture `95999903` refuses every e-invoice with 422 `einvoice_rejected`, and `95999902` answers 422 `customer_not_einvoice_capable`; in production a refused e-invoice answers 400 `send_failed`.

Satisfies: `EINVOICE_SENT`, and — when the document arrives at your receiver company, within seconds — `EINVOICE_RECEIVED_AT_RECEIVER`. That second check needs the invoice addressed to your own receiver company.

### 8. Switch to the receiver company and list its purchase invoices

The e-invoice from step 7 arrives at your receiver company within seconds in the sandbox. Act as the receiver with `X-Company-Id`:

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

Response `200 OK`:

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

Persist `nextSince` and pass it back unchanged as `since` on the next poll; only documents received after it are returned. The list is newest first: when `hasMore` is `true`, read the next pages (`page=2`, …) with the same `since` before moving the cursor. Receiving is pull-based — there are no webhooks. Dedupe by `id`. A `since` that is not ISO-8601 answers 400 `validation_failed`.

These fields are all the structured data the API returns for an incoming document: there are no line items, sender registry code, IBAN or net/VAT split. `GET /partner/v1/purchase-invoices/{id}` returns the same fields and does not change the status. `source` is `E_INVOICE`, `PEPPOL` or `PDF`. Incoming Peppol documents are not yet returned by this list.

Satisfies: `PURCHASE_LISTED`.

### 9. Mark the purchase invoice exported

To import the document, fetch the file as it was received:

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

Response `200 OK`:

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

`url` is a presigned link to the received document's PDF, valid for 15 minutes. Fetching it moves the document from `NEW` to `SEEN`.

After importing the document into the ERP, always confirm the import:

```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 OK`:

```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 — calling it again returns the same document. This step is MANDATORY after every ERP import. Statuses run `NEW` → `SEEN` → `EXPORTED`.

Satisfies: `MARKED_EXPORTED`.

### 10. Create and send an EMAIL invoice to the non-capable customer

Step 4 proved `95999902` cannot receive e-invoices, so the correct channel is `EMAIL`. Create (back as the sender — drop `X-Company-Id`):

```bash
curl -s -X POST https://sandbox.bilnex.io/partner/v1/invoices \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: b62ed1da-9e8a-496e-b357-40ea8e832b2d" \
  -d '{
    "customer": {
      "name": "Sandbox Paper OÜ",
      "regCode": "95999902",
      "email": "billing@sandbox-paper.example"
    },
    "date": "2026-10-12",
    "dueDate": "2026-10-26",
    "currency": "EUR",
    "language": "ET",
    "items": [
      {
        "description": "Consulting services, September 2026",
        "quantity": 10.00,
        "unit": "HR",
        "price": 95.00,
        "vatRate": 24
      }
    ]
  }'
```

Response `201 Created`:

```json
{
  "id": "1d6c2176-2fc2-462f-a76b-c10f1eeb26c6",
  "number": "2026-0002",
  "status": "SAVED",
  "category": "PDF",
  "customerName": "Sandbox Paper OÜ",
  "customerRegCode": "95999902",
  "date": "2026-10-12",
  "dueDate": "2026-10-26",
  "totalAmount": 1178.0
}
```

Then send via EMAIL:

```bash
curl -s -X POST https://sandbox.bilnex.io/partner/v1/invoices/1d6c2176-2fc2-462f-a76b-c10f1eeb26c6/send \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: c5a9b0d3-7e21-4f6f-8e6d-1b4a2c4d0e97" \
  -d '{"channel":"EMAIL","to":"billing@sandbox-paper.example","message":"Invoice attached."}'
```

Response `200 OK`:

```json
{
  "id": "1d6c2176-2fc2-462f-a76b-c10f1eeb26c6",
  "status": "SENT",
  "category": "PDF",
  "channel": "EMAIL",
  "sentAt": "2026-10-12T10:58:58.585549903"
}
```

`to` and `message` are optional and apply to `EMAIL` only; without `to` the e-mail goes to `customer.email`. The e-mail limits apply per company (see **Limits, retries and idempotency**): never retry a 429 `email_rate_limited` automatically. Sandbox email is NEVER delivered — it is intercepted (step 11).

Satisfies: `EMAIL_SENT`, and `NOT_CAPABLE_FALLBACK` — an `EMAIL` send to `95999902`, the recipient step 4 found not capable.

### 11. Read the intercepted email

```bash
curl -s https://sandbox.bilnex.io/partner/v1/sandbox/emails \
  -H "Authorization: Bearer <YOUR_API_KEY>"
```

Response `200 OK` — a bare JSON array, newest first:

```json
[
  {
    "id": "49acbee7-9fd7-45fc-ab13-9e9d4d18d946",
    "to": "billing@sandbox-paper.example",
    "cc": "",
    "from": null,
    "subject": "Arve #2026-0002 AwesomeERP Sandbox Sender OÜ",
    "body": "<!DOCTYPE html>\n<html>\n<head>…",
    "attachmentName": "Arve#20260002.pdf",
    "sentAt": "2026-10-12T10:58:59.873864"
  }
]
```

`body` is the e-mail's full HTML; `attachmentName` is the invoice PDF it carried.

Satisfies: `EMAIL_FETCHED_FROM_INTERCEPT` (it needs at least one captured e-mail).

### 12. Simulate the customer opening the email, and handle SEEN

In production only an e-mailed invoice reaches `SEEN`, when the recipient opens the e-mail (a tracking pixel). In the sandbox this trigger marks the invoice you name `SEEN` without checking its state or channel — use it on the e-mailed invoice:

```bash
curl -s -X POST https://sandbox.bilnex.io/partner/v1/sandbox/trigger \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{"event":"invoice_seen","invoiceId":"1d6c2176-2fc2-462f-a76b-c10f1eeb26c6"}'
```

Response `200 OK`:

```json
{
  "event": "invoice_seen",
  "applied": true,
  "invoiceId": "1d6c2176-2fc2-462f-a76b-c10f1eeb26c6",
  "hint": "The invoice moves to SEEN shortly; re-read it with GET /partner/v1/invoices/1d6c2176-2fc2-462f-a76b-c10f1eeb26c6."
}
```

Then observe the new status from your integration:

```bash
curl -s https://sandbox.bilnex.io/partner/v1/invoices/1d6c2176-2fc2-462f-a76b-c10f1eeb26c6 \
  -H "Authorization: Bearer <YOUR_API_KEY>"
```

Response `200 OK`:

```json
{
  "id": "1d6c2176-2fc2-462f-a76b-c10f1eeb26c6",
  "number": "2026-0002",
  "status": "SEEN",
  "category": "PDF",
  "customerName": "Sandbox Paper OÜ",
  "customerRegCode": "95999902",
  "date": "2026-10-12",
  "dueDate": "2026-10-26",
  "totalAmount": 1178.0
}
```

The same endpoint takes `"event":"inbound_einvoice"` (a supplier's e-invoice arrives at your receiver company) and `"event":"inbound_peppol"`; both always deliver to the receiver company, whatever `X-Company-Id` says. A Peppol document delivered this way passes `PEPPOL_RECEIVED`, but it does not appear in `GET /partner/v1/purchase-invoices` — do not promise Peppol receiving through the API yet.

Satisfies: `INVOICE_SEEN_HANDLED` (optional check; it passes when the trigger fires).

### 13. Run verification — the stopping condition

```bash
curl -s https://sandbox.bilnex.io/partner/v1/sandbox/verification \
  -H "Authorization: Bearer <YOUR_API_KEY>"
```

Response `200 OK` (see [verification](https://sandbox.bilnex.io/docs/verification.md) for what flips each of the 15 checks):

```json
{
  "checks": [
    { "id": "AUTH_OK", "required": true, "status": "pass", "detail": "GET /partner/v1/me succeeded with this key.", "fixHint": null, "docsUrl": "https://sandbox.bilnex.io/docs/authentication.md" },
    { "id": "CAPABILITY_CHECKED_CAPABLE", "required": true, "status": "pass", "detail": "Capability checked for 95000041 (capable).", "fixHint": null, "docsUrl": "https://sandbox.bilnex.io/docs/sending-invoices.md" },
    { "id": "CAPABILITY_CHECKED_NOT_CAPABLE", "required": true, "status": "pass", "detail": "Capability checked for 95999902 (not capable).", "fixHint": null, "docsUrl": "https://sandbox.bilnex.io/docs/sending-invoices.md" },
    { "id": "INVOICE_CREATED", "required": true, "status": "pass", "detail": "Invoice 2026-0001 created.", "fixHint": null, "docsUrl": "https://sandbox.bilnex.io/docs/sending-invoices.md" },
    { "id": "PDF_FETCHED", "required": true, "status": "pass", "detail": "PDF fetched for invoice 9e0a9088-a4b3-4d80-9d17-4917667a95f7.", "fixHint": null, "docsUrl": "https://sandbox.bilnex.io/docs/sending-invoices.md" },
    { "id": "EINVOICE_SENT", "required": true, "status": "pass", "detail": "Invoice 2026-0001 sent via E_INVOICE.", "fixHint": null, "docsUrl": "https://sandbox.bilnex.io/docs/sending-invoices.md" },
    { "id": "EINVOICE_RECEIVED_AT_RECEIVER", "required": true, "status": "pass", "detail": "E-invoice 9e0a9088-a4b3-4d80-9 was delivered to 95000041.", "fixHint": null, "docsUrl": "https://sandbox.bilnex.io/docs/receiving-invoices.md" },
    { "id": "PURCHASE_LISTED", "required": true, "status": "pass", "detail": "Listed incoming documents (1 total).", "fixHint": null, "docsUrl": "https://sandbox.bilnex.io/docs/receiving-invoices.md" },
    { "id": "MARKED_EXPORTED", "required": true, "status": "pass", "detail": "Document aa360bdf-c873-4a6c-ac80-0a758c3411f6 confirmed as imported.", "fixHint": null, "docsUrl": "https://sandbox.bilnex.io/docs/receiving-invoices.md" },
    { "id": "EMAIL_SENT", "required": true, "status": "pass", "detail": "Invoice 2026-0002 emailed.", "fixHint": null, "docsUrl": "https://sandbox.bilnex.io/docs/email-invoices.md" },
    { "id": "EMAIL_FETCHED_FROM_INTERCEPT", "required": true, "status": "pass", "detail": "Read 1 intercepted email(s).", "fixHint": null, "docsUrl": "https://sandbox.bilnex.io/docs/email-invoices.md" },
    { "id": "NOT_CAPABLE_FALLBACK", "required": true, "status": "pass", "detail": "Fell back to email for 95999902, which cannot receive e-invoices.", "fixHint": null, "docsUrl": "https://sandbox.bilnex.io/docs/sending-invoices.md" },
    { "id": "PEPPOL_SENT", "required": false, "status": "pending", "detail": null, "fixHint": "Optional, and not available yet: sending with channel PEPPOL answers 501 peppol_send_unavailable. Nothing to do.", "docsUrl": "https://sandbox.bilnex.io/docs/peppol.md" },
    { "id": "PEPPOL_RECEIVED", "required": false, "status": "pending", "detail": null, "fixHint": "Optional. Call POST /partner/v1/sandbox/trigger with event inbound_peppol. The check passes when the document arrives; Peppol documents are not yet listed by /partner/v1/purchase-invoices.", "docsUrl": "https://sandbox.bilnex.io/docs/peppol.md" },
    { "id": "INVOICE_SEEN_HANDLED", "required": false, "status": "pass", "detail": "Invoice 2026-0002 was marked as opened by the recipient.", "fixHint": null, "docsUrl": "https://sandbox.bilnex.io/docs/email-invoices.md" }
  ],
  "readyForProduction": true,
  "nextSteps": []
}
```

There are 15 checks: 12 required and 3 optional (`PEPPOL_SENT`, `PEPPOL_RECEIVED`, `INVOICE_SEEN_HANDLED`). Each is `pass` or `pending` — there is no failed state. `readyForProduction` is `true` when all 12 required checks pass; the optional ones do not count, and `PEPPOL_SENT` cannot pass today. While a required check is `pending`, `nextSteps` lists up to three of them with their fix hints. Read the check's `fixHint` and `docsUrl`, fix your integration (not the curl — the point is that YOUR code performs these calls), and re-run this endpoint.

Do not report the integration complete until this endpoint returns `"readyForProduction": true`. Then follow **Production today**.

## When readyForProduction is true

1. Report completion, including the verification JSON output as evidence.
2. Production is opened on request, not self-serve: follow **Production today** below. Nothing in your code changes between the hosts — the settings do (`https://sandbox.bilnex.io/partner/v1` + `blx_sbx_...` → `https://api.app.bilnex.io/partner/v1` + `blx_live_...`). All paths, payloads and semantics are identical.
3. Give your software two customer-editable settings — Bilnex URL and Bilnex API key — and a "test connection" action on `GET /partner/v1/me`. Show the `hint` of a 401 to the user: a key answers 401 when it was entered before the customer clicked Connect in Bilnex, after the customer generated a new key or disconnected the ERP, or when it was mistyped. A company key owns one company, so drop `X-Company-Id` for it. Gate every `/partner/v1/sandbox/*` call behind an environment flag — those endpoints return 404 in production. The real-world channel activation steps that the sandbox simulates (registry verification, receiving-operator election, bank account) are the customer's, inside Bilnex.

## Production today

Your integration code does not change for production: point it at `https://api.app.bilnex.io/partner/v1` and use the key each customer generates. What is not self-serve yet is switching it on — Bilnex opens the Partner API per integration. When `GET /partner/v1/sandbox/verification` returns `readyForProduction: true`:

1. Email partners@bilnex.io the verification response and your ERP's name.
2. Open a Bilnex account for your own company at https://app.bilnex.io, activate e-invoice sending and add a bank account.
3. Bilnex switches the Partner API on for that company. You generate its key under Settings → ERP integration and run the production smoke test in [go-live](https://sandbox.bilnex.io/docs/go-live.md).
4. When you confirm it works, Bilnex switches it on for your customers. Each of them generates its own key under Settings → ERP integration and enters it in your ERP together with the URL.

Until the Partner API is switched on for a company, the Bilnex API option does not appear in its ERP list, key generation is refused, and every production call for it answers 503 `feature_disabled` — not retryable.

- Production keys (`blx_live_...`) are generated by each customer company, one company per key. Bilnex does not issue keys to vendors. The limits are the same as for sandbox keys.
- Listing your software by name in the ERP list ships in a Bilnex release (the name in four languages, optionally a logo) — it is not a configuration change. Until it ships, customers choose "Other accounting software (Bilnex API)", which works identically. Ask for it at partners@bilnex.io.
- Nothing is e-mailed to vendors automatically — not on readiness, not before the sandbox expires. The `email` you gave at provisioning is kept so Bilnex can follow up by hand; it is not verified and nothing is sent to it.

## Deep dives

- [Quickstart](https://sandbox.bilnex.io/docs/quickstart.md) — this happy path as a standalone page.
- [Authentication](https://sandbox.bilnex.io/docs/authentication.md) — keys, headers, `X-Company-Id`, key storage.
- [Sandbox](https://sandbox.bilnex.io/docs/sandbox.md) — provisioning, fixtures, state, reset, claim, triggers, intercepted email.
- [Sending invoices](https://sandbox.bilnex.io/docs/sending-invoices.md) — capability check, create, channels, statuses, idempotency, refusals.
- [Receiving invoices](https://sandbox.bilnex.io/docs/receiving-invoices.md) — polling with `since`, statuses, `/original`, mark-exported.
- [Peppol](https://sandbox.bilnex.io/docs/peppol.md) — participant IDs; sending answers 501 and incoming Peppol documents are not yet returned by the API.
- [Digitization](https://sandbox.bilnex.io/docs/digitization.md) — not available through the Partner API: there is no upload endpoint.
- [Email invoices](https://sandbox.bilnex.io/docs/email-invoices.md) — EMAIL channel, SEEN, sandbox interception.
- [Errors](https://sandbox.bilnex.io/docs/errors.md) — error envelope and code table.
- [Rate limits](https://sandbox.bilnex.io/docs/rate-limits.md) — per-key limits, headers, 429, Retry-After, the e-mail limits.
- [Verification](https://sandbox.bilnex.io/docs/verification.md) — all 15 checks and exactly what flips each one.
- [Go-live](https://sandbox.bilnex.io/docs/go-live.md) — production today (opened per integration on request), customer-generated keys, the production smoke test, activation, sandbox-vs-production differences, key replacement.
- [API reference](https://sandbox.bilnex.io/docs/api-reference.md) — every endpoint on one page.
- [Changelog](https://sandbox.bilnex.io/docs/changelog.md) · [Versioning policy](https://sandbox.bilnex.io/docs/versioning-policy.md)

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