# ePodatelna24 Outbox API — server-side implementation brief

**Audience:** an engineer or AI agent designing or changing the **server** side
of the ePodatelna24 Outbox Ingestion API and its delivery webhook.

**Provenance:** written by the team that built and runs the reference client
(the ERP simulator at `/`). Everything here is either (a) published contract,
(b) observed behaviour of the live sandbox and production APIs, or (c) a
decision recorded in a dated memo between the two teams. Claims of type (b) say
so, because they are the ones that can rot.

**How to use it:** read the whole thing before changing a status code, a header
or an event. Each rule below has a cost attached — the paragraph explains what
breaks on the client side if the rule is dropped. If you are adding something
new, the last section lists what the reference client asserts, so you can see
immediately whether your change is breaking.

**Verify before you trust:** this file ships with the client, not with the
server. If it disagrees with `…/api-docs/openapi.yaml`, the spec wins for
anything published there — but check the date, because the spec has been up to
a week behind the running code at least twice.

---

## 1. The model, and why it constrains everything else

One invoice per request. The API answers synchronously with the *only*
feedback an ERP gets in v1 apart from the webhook:

- `202` — custody taken. ePodatelna24 is now responsible for delivering it.
- `200` — idempotent replay of a key already accepted.
- `4xx` — rejected, nothing stored, nothing charged.

Because the response is the whole conversation, **everything checkable must be
checked before `202`**, in this order:

1. XML safety (no DOCTYPE/entities/XXE),
2. sender resolution and Peppol activation,
3. Peppol BIS 3.0 / EN 16931 schematron,
4. receiver reachability (SMP),
5. late-issue guard (§5),
6. wallet balance for the send fee.

Changing this order changes which error an ERP sees for a document that is
wrong in two ways at once. That is a contract change even though no status code
moved: integrators branch on the first failure, and their test fixtures encode
the order. If you must reorder, say so in a memo with the reason.

---

## 2. Endpoints

```
POST /api/v1/outbox/documents           — submit, takes custody
POST /api/v1/outbox/documents/validate  — dry run, no custody, no charge
```

### Request headers

| Header | Required | Rule |
|---|---|---|
| `Authorization` | yes | `Token <token>` — the scheme is `Token`, not `Bearer` |
| `Content-Type` | yes | `application/xml`, UTF-8 |
| `Idempotency-Key` | yes (submit) | **must be a UUID**; any other shape is `400 missing_idempotency_key` |
| `X-Requested-Delivery` | no | only `peppol`; `email` → `400 email_delivery_unsupported`; anything else → `400 bad_requested_delivery` |
| `X-Buyer-Email` | no | fallback address a human can use from the web app. Never triggers a send. An unusable value is dropped, not an error |
| `X-Ep24-Simulate` | no | **sandbox only**, see §9 |

`validate` ignores `Idempotency-Key` entirely. Keep it that way: a dry run that
consumed a key would make "validate then submit" unsafe, which is the workflow
every integration guide recommends.

### Response codes that clients branch on

| Code | `code` | Notes |
|---|---|---|
| `202` | — | `CustodyReceipt` |
| `200` | — | `CustodyReceipt` of the original submission |
| `400` | `invalid_xml` | malformed or unsafe XML, or empty body |
| `400` | `missing_idempotency_key` | missing or not a UUID |
| `400` | `email_delivery_unsupported` | `X-Requested-Delivery: email` |
| `400` | `bad_requested_delivery` | other `X-Requested-Delivery` value |
| `401` | `unauthorized` | |
| `402` | `insufficient_funds` | key NOT consumed — see §4 |
| `403` | `sender_not_found` / `sender_not_active` | |
| `409` | `idempotency_conflict` | same key, byte-different body |
| `413` | `too_large` | over 4 MB, **after** authentication |
| `422` | `validation_failed` | with `errors[]`, one entry per broken rule |
| `422` | `receiver_unreachable` | no SMP registration |
| `422` | `custody_refused` | with `reason`, today only `late_issue_guard` |
| `429` | `rate_limited` | with `Retry-After` |
| `503` | `upstream_unavailable` | transient; also covers a deferred custody |

**The `code` field is the contract, not the HTTP status and not the prose.**
Clients switch on it. Two different failures must never share a code, and a
code must never change meaning — add a new one instead.

---

## 3. The error body

RFC 7807, always, including for errors produced before any business logic:

```jsonc
{
  "type": "https://www.epodatelna24.sk/errors/<code>",
  "title": "Human readable, English",
  "status": 422,
  "code": "custody_refused",     // the stable machine field
  "detail": "…",                  // may name specifics; never a stack trace
  "reason": "late_issue_guard"    // extra fields are allowed and useful
}
```

One exception exists in practice and must be documented rather than hidden: a
body over roughly 4.5 MB is refused **by the hosting platform before any
application code runs**, and answers `text/plain` with
`FUNCTION_PAYLOAD_TOO_LARGE`. Clients therefore have to check `Content-Type`
before parsing a `413` as JSON. (Observed, verified independently by the client
team.)

---

## 4. Idempotency — the strict rules clients depend on

- A key is **bound only by a successful `202`**. Every `4xx` leaves it free, so
  an ERP can fix the document and resubmit under the same key. This includes
  `402`: after a top-up, the same key must produce a fresh `202`, not a replay.
- **Strict conflict.** The same key with a byte-different body is
  `409 idempotency_conflict` — nothing stored, nothing charged, the original
  document untouched. Compare `sha256` of the **raw request bytes**; a single
  changed space is a conflict. Do not normalise the XML first.
- **A replay re-validates.** The whole input pipeline runs again, so a replay
  can legitimately answer `4xx` if the world changed. The only check skipped is
  the wallet balance, which is why `402` can never be the answer to a replay.
- Scope the key per sender company, not globally.

A client that loses its connection mid-request retries with the same key. Every
rule above exists so that retry is safe.

---

## 5. The late-issue guard ("Ochrana proti pokute")

Wallet-level, **on by default**, `max_age_days = 0`: an invoice whose issue
date is not today in Europe/Bratislava — or which is issued more than 15 days
after its tax point — is refused with `422 custody_refused`,
`reason: late_issue_guard`, and offered e-mail delivery in the web app instead.

Two consequences for anyone touching the server:

1. **It must stay a pre-custody check.** It refuses; it does not create a
   document in an error state. A refused submission stores nothing, so the
   idempotency key stays free and the ERP can re-issue and resubmit.
2. **It makes every static test fixture rot.** Any sample invoice shipped by
   anyone — including your own QA files — is refused the day after it is
   written. The reference client re-dates samples as it loads them; server-side
   fixtures need the same treatment or an explicitly disabled guard.

---

## 6. Delivery webhook — the contract

Three events, one endpoint per wallet, registered in
**Nastavenia → Notifikácia o doručení**.

| Event | Fires when | Dedupe key |
|---|---|---|
| `outbox.document.delivered` | the AS4 acknowledgement arrives from the receiving access point | the event — delivery happens once |
| `outbox.document.failed` | the send ends terminally | `event:reason`, so a *changed* reason fires again |
| `inbox.document.received` | an inbound document finishes processing | per document, once ever |

`delivered` must **not** fire when the document is handed to the access point.
That moment is a promise; an ERP that books on it books on a promise. The
acknowledgement is the first honest "it arrived".

A document can produce both `failed` and `delivered` (a manual re-send after
exhausted retries). Clients are told to let the later `occurredAt` win, so
`occurredAt` must be the moment the event describes, not the moment it was
queued.

### Signature

```
X-EP24-Timestamp: <unix seconds, integer, no padding>
X-EP24-Signature: v1=<hex hmac-sha256>
X-EP24-Delivery:  <uuid, stable across every retry of this event>
signature = HMAC-SHA256(secret, "<X-EP24-Timestamp>.<raw body>")
```

- The header is a **comma-separated list** and may carry more than one `v1=`
  element. During a secret rotation, sign with the previous and the new secret
  for 24 hours; a receiver that tries every element rides it out without a
  deploy. Publish that the list can grow, so nobody writes "take the first".
- Send integer seconds and keep it that way. Receivers are told to sign the
  header **string** rather than a re-formatted number.
- The secret is shown once, prefixed `whsec_`.

### Delivery semantics

- **At least once.** A timeout after the receiver committed still counts as a
  failure to you, so the same event will arrive twice. `X-EP24-Delivery` is the
  receiver's only defence — it must be identical across every retry.
- **Answer-time budget: 10 s.** Anything slower earns retries.
- **Retries:** 6 attempts at 1 min, 5 min, 30 min, 2 h, 6 h after the first.
- **Honour `Retry-After`** from the receiver, clamped to 10 s … 6 h; outside
  that range fall back to the ladder.
- **Two auto-disable thresholds, not one.** A `4xx` other than 408/429 means
  "this endpoint refuses us" → disable after 20. A `5xx`, 408, 429, timeout or
  transport error means "it is unwell" → disable after 200. Collapsing these
  switches off an endpoint whose owner merely had not pasted the secret yet.
- **E-mail the wallet owner when you disable an endpoint**, naming the host and
  the last error. Silently disabling a customer's integration is the defect
  behind most "why did events stop?" tickets.
- **Do not follow redirects**, require HTTPS, refuse private and loopback hosts
  at configuration time.

### The `401` / `503` convention

Publish it, because it changes which threshold a failure counts against: a
receiver answers `401` for "this signature is wrong" and `503` for "I cannot
verify yet" (no secret configured, store unreachable). Receivers that answer
`401` while being merely unconfigured auto-disable themselves on day one.

---

## 7. `inbox.document.received` — the payload that carries a document

The UBL travels **inside** the event as a JSON string (not base64), because the
metadata summary is not posting-grade: a line is `{name, amount}` with no
quantity, unit price or per-line VAT, and the IBAN is present on a minority of
received invoices. An ERP cannot create a payable from that.

```jsonc
{
  "schemaVersion": 1,
  "event": "inbox.document.received",
  "deliveryId": "…", "occurredAt": "…", "attempt": 1,
  "received": {                    // §8.2 ReceivedDocumentDetailResponse, unchanged
    "metadata": { "documentId": "INV-2026-0001", "documentTypeId": "…",
                  "processId": "…", "senderParticipantId": "0245:…",
                  "receiverParticipantId": "0245:…", "creationDateTime": "…" },
    "payload": "<?xml …",          // null is legal — see below
    "payloadFormat": "XML"
  },
  "payloadSha256": "…",            // outside `received`, so its shape stays fixed
  "payloadBytes": 4419,
  "payloadOmittedReason": null,
  "company":  { "dic": "…", "legalName": "…" },
  "supplier": { "name": "…", "taxId": "…" },
  "document": { "id": "…", "url": "https://…/dashboard/inbox/…" }
}
```

Rules worth defending in review:

- **Cap on the serialised event body, not on the document.** 4 MB, measured
  after serialisation including the envelope, so a receiver subtracts nothing.
  A receiver hosted on Vercel has a hard platform ceiling around 4.5 MB that it
  cannot even log.
- **`payload: null` must stay legal**, with `payloadOmittedReason` set and the
  metadata and `document.url` still present. A degraded event beats no event:
  the invoice is visible in the portal and the ERP needs to point a human at it.
- **`payloadSha256` is the hash of the payload string as UTF-8.** Publish
  exactly what it hashes. Receivers compare it to catch a payload truncated on
  your side of the signature — the signature alone cannot.
- A digest mismatch is the receiver's cue to acknowledge and flag, not to
  reject: the bytes carried a valid signature, so a retry sends the same ones.
  Make sure your own alerting treats a mismatch as *your* page.
- **Keep `received` byte-identical to the documented §8.2 shape.** Everything
  eP24-specific lives outside it. That is why the reference client adopted the
  event in an afternoon.

---

## 8. Money stays out of the event

There is deliberately no `billing` block. It shipped once and was withdrawn the
same day: a webhook body leaves for an endpoint eP24 does not operate,
configured by whoever holds the wallet, and ledger identifiers do not belong in
that traffic.

What clients are told instead, and what the server must therefore keep true:

- one `delivered` = exactly one charge, at the price in force at that moment;
- `failed` = no charge at all, because settlement happens on delivery, never on
  submission — so there is nothing to refund and no reversing entry;
- the wallet statement in the portal is the ledger, a human path.

If per-row reconciliation becomes a real need, build a **read endpoint**
authenticated with the same wallet token, returning rows for a period. Do not
re-add fields to the pushed event.

---

## 9. Sandbox-specific behaviour

- **`X-Ep24-Simulate`** forces an outcome: `insufficient_funds`,
  `sender_not_active`, `upstream_unavailable`, `rate_limited`. The forced
  response carries `X-Ep24-Simulated`, and the request still passes auth, key
  and body checks first. An unrecognised value is `400 invalid_simulation` —
  rejecting beats silently ignoring. **Production must not read the header at
  all**, so no test can force an outcome against a real wallet.
- **No webhooks.** The sandbox does not emit delivery or inbox events at all
  (decision of 2026-09-20). Anything a client can only learn from an event is
  therefore untestable there, which is why the reference client stops waiting
  and says so instead of polling. If this ever changes, say so in a memo — a
  client that has been told "no events here" will not notice them starting.
- **No SMP.** The mock answers `exists: true` for any identifier, so receiver
  reachability is not evaluated and `422 receiver_unreachable` cannot be
  produced. Consequence to state loudly in any sandbox documentation: **a
  sandbox `202` is not evidence that production would accept the document.**
- **Reserved tenant.** Six demo companies, `007265631` + client index 1–6, all
  Peppol-active, recreated byte-identically by `POST /api/sandbox/reset`.
  (Note: an earlier memo announced `999999999`; the seed used `007265631`. If
  you change it again, grep the client repos — the constant is hardcoded there
  on purpose, since DIČ must never be generated.)
- Keep the sandbox spec and the production spec **reconciled**. They have
  diverged before: the sandbox documented `X-Ep24-Simulate` while production
  did not, and integrators generated clients from whichever they found.

---

## 10. What the reference client asserts

If you change the server, these are the checks that will catch you. Running
them is the fastest way to know whether a change is breaking.

| Check | Command | Asserts |
|---|---|---|
| Fixture validity | `EP24_WEB=../epodatelna24web npm run validate:fixtures` | every shipped sample is schematron-valid (or invalid on exactly the declared rule), both as shipped and as re-dated |
| Webhook receiver | `EP24_WEBHOOK_SECRET=… npm run test:webhook -- <url>` | 12 requests: valid delivered/failed/inbox events accepted; duplicate deduped; unknown event acknowledged with `202`; inbox with `payload: null` and with a digest mismatch accepted and flagged; two-signature rotation accepted; tampered body, stale timestamp and missing signature all `401`; `GET` is `405` |
| Deployment state | `GET /api/webhooks/epodatelna24` | the receiver reports build, whether a secret is configured and which store is live |

The client also encodes the scenario matrix — one sample and one expected
`code` per documented outcome — in `app/page.tsx`. If you add an outcome, add a
scenario there; if you remove one, that list is where an integrator will notice
first.

---

*Maintained with the ERP simulator: `github.com/ePodatelna24/epodatelna24-erp-simulator`.
The full integrator-facing manual is at `/prirucka` in that app.*
