# Sending invoices

Outbound flow: check the customer's e-invoice capability → create the invoice → send it on the right channel. Two channels work today: `E_INVOICE` (the Estonian operator network) and `EMAIL` (PDF attached; see [email-invoices](https://sandbox.bilnex.io/docs/email-invoices.md)). `PEPPOL` is not available yet and answers 501 (see [peppol](https://sandbox.bilnex.io/docs/peppol.md)).

## Lifecycle and categories

```
create → SAVED → send → SENT → e-mail opened → SEEN
                  │
                  └─ e-invoice refused → DRAFT
```

- `POST /partner/v1/invoices` creates the invoice in status `SAVED`, ready to send. The Partner API has no draft-editing step.
- An accepted send moves it to `SENT`.
- A refused e-invoice send moves it to `DRAFT`. It was not sent; see section 6.
- `SEEN` means the recipient opened the e-mail: the tracking pixel in production, the `invoice_seen` trigger in the sandbox. In production only e-mailed invoices reach `SEEN`.
- **For `E_INVOICE`, `SENT` is final.** There is no `DELIVERED` status and no delivery receipt. Never wait or poll for one.

`category` records the format: `PDF` from creation, `E_INVOICE` from the first e-invoice send attempt — a refused attempt too. E-mailing does not change it.

**Send each invoice once.** Bilnex does not stop a second send: sending an invoice that was already sent sends it again — a second e-invoice at the recipient, or a second e-mail. Record that you sent it, and never retry a send that answered 200. After a timeout, retry with the same `Idempotency-Key` (section 2); that returns the first response instead of sending again.

The Partner API cannot issue a credit note: `quantity` must be greater than 0 and `price` 0 or more. A sent invoice is corrected outside the Partner API.

## 1. Capability check

`GET /partner/v1/customers/{regCode}/e-invoice-capability` answers "can this Estonian company receive e-invoices?" Always call it before choosing a channel; never assume capability. The example checks the sandbox's receiver company (its `regCode` differs per sandbox):

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

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

| Field | Meaning |
|---|---|
| `eInvoiceCapable` | `true` = the recipient can receive e-invoices on the Estonian operator network |
| `operator` | The code of the e-invoice operator the recipient is registered with when `eInvoiceCapable` is `true` (the sandbox answers `BILNEX`), `null` otherwise |
| `peppolCapable` / `peppolId` | Peppol registration; `peppolId` is `0191:<regCode>`. The API cannot send over Peppol yet |
| `recommendedChannel` | `E_INVOICE`, `EMAIL` or `PEPPOL`. Peppol sending is not available yet, so treat `PEPPOL` as `E_INVOICE` when `eInvoiceCapable` is `true`, otherwise as `EMAIL` |
| `checkedAt` | When the lookup ran |

Branching rule: `eInvoiceCapable: true` → send with `E_INVOICE`; `false` → send with `EMAIL`.

Verification: a check that returns `eInvoiceCapable: true` passes `CAPABILITY_CHECKED_CAPABLE`; one that returns `false` passes `CAPABILITY_CHECKED_NOT_CAPABLE`. `NOT_CAPABLE_FALLBACK` passes when an invoice to the not-capable fixture `95999902` is sent with channel `EMAIL`.

## 2. Create — `POST /partner/v1/invoices`

```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": "95000041",
      "email": "arved@receiver.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
      }
    ]
  }'
```

Request fields:

- `customer.name` and `customer.regCode` are required. `customer.email` and `customer.address` are optional.
- Bilnex matches the customer by `regCode` among the sending company's customers. An existing customer is used as stored. A new one takes its details from the business registry, and your `name` and `email` fill what the registry does not have.
- `customer.address` is accepted but not used yet: the address on the invoice comes from the business registry, or from the customer the company already has. Do not rely on it being printed.
- `date` and `dueDate` are `YYYY-MM-DD`. Send both.
- `currency` is an ISO 4217 code: `EUR`, `USD`, `GBP`, `SEK`, `NOK`, `DKK`, `PLN`, `CZK`, `ISK` or `TRY`. A missing or unknown code becomes `EUR` without an error.
- `language` is `EN`, `ET`, `LV` or `LT` — the language of the PDF and the e-mail. A missing or unknown value becomes `EN`.
- Optional: `number` (your own invoice number, used as given; without it Bilnex numbers the company's invoices `2026-0001`, `2026-0002`, …), `reference` (payment reference number) and `note` (free text). Both `reference` and `note` are printed on the invoice.
- Each item: `description`, `quantity` (greater than 0), `unit`, `price` (net unit price, 0 or more) and `vatRate` (percent; `24` is the Estonian standard rate).
- `unit` is one of `HR`, `PCS`, `ML`, `DL`, `L`, `G`, `KG`, `T`, `CM`, `M`, `KM`, in any case. A missing or unknown unit becomes `PCS` without an error — `h` is not `HR`.
- Send `quantity` and `price` with two decimals (`10.00`, `95.00`). VAT is computed per line and rounded at the precision of the numbers you send, so whole numbers round the VAT to whole euros.

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

Flips `INVOICE_CREATED`.

**Totals.** The server computes the line totals, the VAT and `totalAmount`, the gross total (here 10 × 95.00 plus 24 % VAT = 1178.0). The response carries no lines, net amount, VAT breakdown or currency. Your ERP keeps its own lines and VAT breakdown — it sent them — and stores Bilnex's `id`, `number` and `totalAmount`. `totalAmount` is the total on the issued document.

**Idempotency.** Send an `Idempotency-Key` header (a UUID) on every create and every send:

- A repeated key returns the first response, even if the body differs. No error is raised, and nothing is created or sent again.
- While the first request with that key is still running, a repeat answers 409 `idempotency_conflict`. Wait, then retry with the same key — never with a new one, which can create or send twice.
- A key is kept for 24 hours, per API key and per operation — for a send, per invoice — so a key reused on a create and a send, or on two invoices, cannot replay the wrong response. Still use a new key for every new request.
- To retry — after a timeout, a network error, 409 `idempotency_conflict`, 429 `rate_limited` or 500 `internal_error` — send the same request with the same key. Use a new key only for a request you changed, for example after a 400 `validation_failed`.

## 3. Read back — list and get

`GET /partner/v1/invoices?status=&customerRegCode=&page=&pageSize=`:

```bash
curl -s "https://sandbox.bilnex.io/partner/v1/invoices?status=SENT&pageSize=5" \
  -H "Authorization: Bearer <YOUR_API_KEY>"
```

```json
{
  "items": [
    {
      "id": "9e0a9088-a4b3-4d80-9d17-4917667a95f7",
      "number": "2026-0001",
      "status": "SENT",
      "category": "E_INVOICE",
      "customerName": "AwesomeERP Sandbox Receiver OÜ",
      "customerRegCode": "95000041",
      "date": "2026-10-12",
      "dueDate": "2026-10-26",
      "totalAmount": 1178.0
    }
  ],
  "page": 1,
  "pageSize": 5,
  "total": 1,
  "hasMore": false,
  "nextSince": null
}
```

Query parameters:

- `status`: `DRAFT`, `SAVED`, `SENT` or `SEEN`. An unknown value is ignored, and the list is not filtered.
- `customerRegCode`: filters within each page. `total` and `hasMore` ignore it, so a page can hold fewer matches than `pageSize`, or none. Keep paging while `hasMore` is `true`.
- `page` is 1-based. `pageSize` defaults to 50, at most 100.
- There is no `since` parameter for sales invoices, and `nextSince` is always `null` here. The order is not guaranteed; to find one invoice, read it by `id`.

`GET /partner/v1/invoices/{id}` returns the same invoice object as create.

## 4. Fetch the PDF — `GET /partner/v1/invoices/{id}/pdf`

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

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

`pdfUrl` is a presigned link, valid for 15 minutes (`expiresAt`). Download it immediately; store the bytes, not the URL. Available in every status. Flips `PDF_FETCHED`.

## 5. Send — `POST /partner/v1/invoices/{id}/send`

Body: `{"channel": "E_INVOICE" | "EMAIL" | "PEPPOL", "to": "...", "message": "..."}`. `to` (one address) and `message` are for `EMAIL` only.

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

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

Flips `EINVOICE_SENT` (channel `E_INVOICE`) or `EMAIL_SENT` (channel `EMAIL`). `PEPPOL_SENT` cannot pass today.

Channel rules:

| Channel | Requires | Result |
|---|---|---|
| `E_INVOICE` | Recipient `eInvoiceCapable: true`. In production the sending company also needs e-invoice sending activated and a bank account. | Delivered over the Estonian operator network. `SENT` is final. |
| `EMAIL` | A recipient: `to`, or the customer's e-mail | PDF attached to an e-mail. Can reach `SEEN`. E-mail limits apply — read [email-invoices](https://sandbox.bilnex.io/docs/email-invoices.md) before sending in bulk. |
| `PEPPOL` | — | Not available: 501 `peppol_send_unavailable`, or 503 `feature_disabled` if Peppol is off for the company. |

Every send that reaches a channel counts toward the key's 200 sends a day (`sendsPerDay`), a refused one too. A replayed response (same `Idempotency-Key`) does not count.

## 6. Refused sends

| Status | Code | When | What to do |
|---|---|---|---|
| 422 | `customer_not_einvoice_capable` | Sandbox: an e-invoice to fixture `95999902` | Send it with `EMAIL`. Check capability before sending. |
| 422 | `einvoice_rejected` | Sandbox: an e-invoice to fixture `95999903` | Treat it as a real rejection: send it with `EMAIL`, or flag it for a person. |
| 400 | `send_failed` | Production: the operator network refused the e-invoice. Sandbox: an e-invoice to a registry code outside the sandbox. | As for a rejection. |
| 501 | `peppol_send_unavailable` | Channel `PEPPOL` | Send with `E_INVOICE` or `EMAIL`. |
| 503 | `feature_disabled` | Channel `PEPPOL` while Peppol is off for the company, or the Partner API is off for the company | Not retryable. |
| 429 | `email_rate_limited` | Channel `EMAIL`: an e-mail limit was reached | Do not retry automatically; see [email-invoices](https://sandbox.bilnex.io/docs/email-invoices.md). |
| 402 | `upgrade_required` | The customer's Bilnex plan does not cover the document, e.g. its monthly document quota is used up | Not retryable by your integration; the customer upgrades in Bilnex. |
| 404 | `invoice_not_found` | Wrong `id`, or `X-Company-Id` names another company | Check both. |

A refused e-invoice puts the invoice in `DRAFT` with category `E_INVOICE`. It was not sent: send it with `EMAIL`, or leave it to a person. Sending it again on `E_INVOICE` without fixing the cause is refused again.

What skipping the capability check looks like (an e-invoice to fixture `95999902`):

```json
{
  "error": {
    "code": "customer_not_einvoice_capable",
    "message": "Recipient 95999902 is not registered to receive e-invoices.",
    "hint": "Call the capability endpoint before sending and use channel EMAIL when eInvoiceCapable is false.",
    "docsUrl": "https://sandbox.bilnex.io/docs/sending-invoices.md"
  }
}
```

## Error drill: the rejector fixture

`95999903` (Sandbox Rejector OÜ) reports `eInvoiceCapable: true`, but its operator refuses every e-invoice — use it to prove your error handling. Create an invoice with `customer.regCode` `95999903` (here `b5cf76ae-3b42-49ab-bf1b-a767a60e9818`, number `2026-0003`), then send it:

```bash
curl -s -X POST https://sandbox.bilnex.io/partner/v1/invoices/b5cf76ae-3b42-49ab-bf1b-a767a60e9818/send \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 7d2c4e91-5b3a-4f08-9e6d-1a8b0c3f5e27" \
  -d '{"channel":"E_INVOICE"}'
```

Response `422 Unprocessable Entity`:

```json
{
  "error": {
    "code": "einvoice_rejected",
    "message": "The recipient's operator refused the document.",
    "hint": "This is the rejector fixture, and refusing is what it is for. Treat it as a real rejection: the invoice stays unsent and needs a human or a fallback channel.",
    "docsUrl": "https://sandbox.bilnex.io/docs/sending-invoices.md"
  }
}
```

The invoice is now back in `DRAFT`:

```bash
curl -s https://sandbox.bilnex.io/partner/v1/invoices/b5cf76ae-3b42-49ab-bf1b-a767a60e9818 \
  -H "Authorization: Bearer <YOUR_API_KEY>"
```

```json
{
  "id": "b5cf76ae-3b42-49ab-bf1b-a767a60e9818",
  "number": "2026-0003",
  "status": "DRAFT",
  "category": "E_INVOICE",
  "customerName": "Sandbox Rejector OÜ",
  "customerRegCode": "95999903",
  "date": "2026-10-12",
  "dueDate": "2026-10-26",
  "totalAmount": 1178.0
}
```

It was never delivered, so sending it with `EMAIL` is a fallback, not a duplicate.

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