Archiles Peppol API

Archiles Peppol API integration guide#

DRAFT — not the final 1.0 version. This guide and the contract it describes are circulated for review. Paths, schemas, field names and status codes may still change, without notice and without a version bump, until version 1.0.0 is published. Build against it to give feedback, not to ship.

This guide is the narrative companion to openapi.yaml, which is the authoritative machine-readable contract. Where the two ever disagree, the contract is right and this document is a bug. Please report it.

It describes how to use Archiles as a Peppol Access Point entirely over an API: onboarding a participant, checking whether a trading partner is reachable, validating an invoice before sending, sending it, tracking delivery, and pulling received invoices out. No interaction with the Archiles user interface is required at any point.

Language. This guide and the contract are maintained in English. Slovak vocabulary (DIČ, IČ DPH, IČO) is used only where it names a real Slovak identifier.

The rest of the documentation set

WhereWhat it is
openapi.yamlthe contract itself — the authoritative machine-readable source
API referenceevery endpoint and schema of the contract, rendered for reading
API explorerthe same contract, with a form per endpoint for trying calls

Contents

  1. What the API does
  2. The two surfaces
  3. Authentication and tenancy
  4. Provisioning a participant
  5. Sending
  6. Delivery status
  7. Receiving
  8. Errors
  9. Idempotency, limits, rate limits and retention
  10. Paging
  11. Environments
  12. A worked end-to-end example
  13. Support
  14. Sandbox test data

1. What the API does#

#ScenarioEndpoints
1Create a new participant accountPOST /v1/peppol/participants
2Check whether a DIČ is registered in Peppol, and for which document typesGET /v1/peppol/registry/{participantId}, POST /v1/peppol/registry/lookup
3Validate e-invoices before sendingPOST /v1/e-invoices/validate, POST /v1/e-invoices/validate/batch
4Send e-invoices, including attachmentsPOST /v1/e-invoices/submissions, POST /v1/e-invoices/submissions/batch
5Check the status of sent e-invoicesGET /v1/e-invoices/submissions/{submissionId}, GET /v1/e-invoices/submissions, GET /v1/e-invoices/submissions/batch/{batchId}
6Resend undelivered e-invoicesPOST /v1/e-invoices/submissions/{submissionId}/retry, POST /v1/e-invoices/submissions/retry
7Find out whether new invoices have been receivedGET /v1/e-invoices/inbox?since=<cursor>&status=New
8Download received invoices, attachments and an optional PDF renderingGET /v1/e-invoices/inbox/{documentId}/content
9Confirm custody of a received invoicePOST /v1/e-invoices/inbox/{documentId}/acknowledge, POST /v1/e-invoices/inbox/acknowledge

Every scenario for which bulk processing is meaningful has both a single and a bulk form.

Identifiers#

Four names appear in the table below, covering three distinct ids. Learn them before anything else.

IdentifierWhat it isAccepted by
documentIdthe archive identity of an invoice, sent or receivedall /v1/e-invoices/inbox/{documentId}* routes and /v1/sapi/document/receive/{documentId}
submissionIdone submission of an outgoing documentGET /v1/e-invoices/submissions/{submissionId}, POST …/retry
providerDocumentIdthe same value as submissionId, under SAPI's name for itreturned by POST /v1/sapi/document/send; pass it to the submission routes above
batchIdone bulk sendGET /v1/e-invoices/submissions/batch/{batchId}

What follows from that:

The single exception is SAPI's metadata.documentId on send, which SAPI defines as the client's own invoice number. It is declared metadata, checked against the payload, and is not an id you can later address anything with.


2. The two surfaces#

Archiles exposes two HTTP surfaces over one core, and they share authentication:

A token obtained from either surface works on both, so an integrator using both authenticates once.

What SAPI covers, and what it does not#

SAPI is a thin contract by design, scoped to technical acceptance and custody transfer. It does not cover semantic validation, Peppol delivery confirmation or legal effect. It covers 3 of the 9 scenarios above (send, list received, retrieve received) plus custody acknowledgement.

It has no account provisioning, no registry check, no pre-send validation and no sent-status endpoint. Those are native-only extensions with nothing in SAPI to conflict with.

If you are a SAPI-only client, note the sent-status gap, and how to close it. SAPI defines no endpoint for asking what happened to a document after it was accepted, and Archiles will not extend the SAPI surface to fill that gap. The profile stays frozen. Instead the SAPI send hands you the key to the native one: providerDocumentId in its response is the submissionId, so GET /v1/e-invoices/submissions/{providerDocumentId} with the same token returns the full delivery status. Store that value against your own invoice number when you send. It is the only handle on the submission the SAPI response carries. If you would rather not leave the SAPI surface at all, treat 202 as technical receipt only and reconcile out of band.

Path prefix#

The SAPI document itself carries no version segment. Its paths are /sapi/auth/token, /sapi/document/send. Archiles hosts the profile behind a version prefix as /v1/sapi/*, matching the native surface so that both surfaces version identically. This is not a deviation: SAPI declares /sapi through OpenAPI servers, which is a deployment address, and every client is configured with a base URL regardless. Point a conforming SAPI client at https://<host>/v1/sapi and every path, body and status code below it is unchanged.

The three deviations from SAPI#

All three are supersets or reinterpretations of an underspecified field. A conforming SAPI client is unaffected by any of them.

#SAPI saysArchiles doesWhy
1payload: "Maximum size: 10 MB"accepts up to 20 MBSAPI's wording is a ceiling, not a floor. Accepting more than a conforming client will ever send cannot break it. Applies to both surfaces.
2DocumentMetadata.documentId is client-assignedfor received documents Archiles assigns itTwo senders can trivially both use INV-2026-0001 towards the same receiver, which would make the id non-unique and GET /document/receive/{documentId} ambiguous. Archiles assigns its own document id instead, so the path is well-defined. Consequence: SAPI's metadata does not carry the sender's invoice number. Read it from the payload, or use the native InboxDescriptor.documentNumber.
3DocumentMetadata.creationDateTime is the document creation timestamptreated as the submission timestamp, and excluded from the payload-mismatch checkUBL has no field that dependably means "when the client created this record", and rejecting a send over a clock difference would be hostile. It is recorded, not compared.

One more SAPI behaviour worth knowing: its listing response returns bare metadata objects that carry neither status nor receivedAt, even though the endpoint offers a status filter. Archiles implements it exactly as specified. To see custody status or receipt time, use the filter, or use the native GET /v1/e-invoices/inbox, which returns both.

Custody status across the two surfaces#

There is one custody state per received document, and the two surfaces spell its values differently:

Native (/v1/e-invoices/inbox)SAPI (/v1/sapi/document/receive)
NewRECEIVED
AcknowledgedACKNOWLEDGED
All (filter only)omit the status parameter

Because it is one state and not two, acknowledging through either surface is immediately visible through the other. There is nothing to synchronise, and no risk of handling a document twice because you switched surfaces.

Three things this contract writes down that SAPI's schema does not#

None of them changes behaviour, and none can break a conforming client. They only document what the wire already does:


3. Authentication and tenancy#

Getting a token#

One contract, shared by both surfaces: client_credentials in, a bearer JWT out.

POST /v1/oauth/token                 (alias: POST /v1/sapi/auth/token)
GET  /v1/oauth/token/status          (alias: GET  /v1/sapi/auth/token/status)
POST /v1/oauth/renew                 (alias: POST /v1/sapi/auth/renew)
POST /v1/oauth/revoke                (alias: POST /v1/sapi/auth/revoke)

The aliases are the same handlers over the same token store. The pair exists only because SAPI mandates its route names. Use whichever matches the surface you are already coding against.

curl -X POST "https://api.test.archiles.sk/v1/oauth/token" -H "Content-Type: application/json" -d '{"grant_type":"client_credentials","client_id":"...","client_secret":"..."}'
{
  "access_token": "eyJhbGciOi...",
  "refresh_token": "eyJhbGciOi...",
  "token_type": "Bearer",
  "expires_in": 900
}

Send the access token as Authorization: Bearer <access_token> on every subsequent call.

Lifetimes and refresh#

scope is accepted and ignored#

The token request accepts an optional scope, for SAPI conformance. It changes nothing in v1, and nothing comes back. The response does not echo a scope, because echoing one would suggest a grant was evaluated. Permissions are governed by the list of participants your client_id is entitled to, plus the X-Peppol-Participant-Id header on each call. Requesting document:send document:receive and requesting nothing at all give you identical access. Do not design around the field; narrowing permissions in a later version will be backwards compatible.

Lockout#

Repeated failed authentication attempts lock the credential temporarily, and the token endpoint returns 423. This is SAPI's defined behaviour. Back off rather than retrying a rejected secret in a loop.

No IP allow-list. Archiles does not implement per-client IP restrictions, so SAPI's 403 on the token endpoint ("IP address not allowed for this client") is never emitted. You will not be asked for your IP ranges, and you should not expect address-based filtering to protect your credential. Keep the secret secret.

Tenancy and the participant header#

One credential acts on behalf of many participants: an ERP vendor, accounting firm or portal holds one client_id and manages many DIČs. Which participant a given call acts on is selected per request:

X-Peppol-Participant-Id: 0245:2022182030

Archiles checks entitlement against your credential's allowed-participant list before any other processing. A participant you are not entitled to returns 403, whether or not it exists. Nothing leaks about participants that are not yours.

The header is required on every operation that reads or writes data belonging to one participant: all of /v1/e-invoices/submissions*, /v1/e-invoices/inbox*, /v1/e-invoices/validate*, and the whole SAPI document surface. Five native operations deliberately take no participant context:

OperationNo header becauseReturns 403?
POST /v1/peppol/participantsthe participant does not exist yetno
GET /v1/peppol/participantsit is the operation that tells you which participants you haveno
GET /v1/peppol/participants/{participantId}the path segment is the tenancy keyyes
GET /v1/peppol/registry/{participantId}public SMP directory datano
POST /v1/peppol/registry/lookuppublic SMP directory datano

On send, X-Peppol-Participant-Id must match the supplier endpoint derived from the payload (both surfaces), and on the SAPI surface it must also equal metadata.senderParticipantId. Either disagreement is a 400, category: VALIDATION, see §5.

Why require the header at all, when the sender is in the payload anyway? The entitlement check runs on the header before anything parses the body, so an unentitled call costs nothing to refuse. Metering, rate limiting and the audit trail get a participant to attribute the call to even when the payload turns out to be malformed. And it catches one participant's invoice being sent in another's context, before anything reaches the network.


4. Provisioning a participant#

POST /v1/peppol/participants        create an account and a participant, keyed by DIČ
GET  /v1/peppol/participants        list the participants you are entitled to
GET  /v1/peppol/participants/{id}   state, including registrationState

A newly created participant can neither send nor receive until its Peppol registration has completed. Registration in the Peppol directory happens out of band, not as part of this call. Poll GET /v1/peppol/participants/{id} and wait for registrationState to become Active; until then it is Pending.

In rare cases an account cannot be provisioned automatically. That returns 422. The request was valid and the DIČ was free, but the operation needs a human. Contact support (§13) rather than retrying; 422 is not retryable.

Checking a trading partner#

Before sending to a new counterparty, check that they are reachable and accept the document type you intend to send:

GET /v1/peppol/registry/0245:2022182030
{
  "participantId": "0245:2022182030",
  "registered": true,
  "documentTypes": [
    "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2::Invoice##urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0::2.1",
    "urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2::CreditNote##urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0::2.1"
  ]
}

documentTypes carries fully qualified Peppol document type identifiers, verbatim from the SMP. It is an open list of strings, not a closed set: the directory can advertise types Archiles does not process, and new BIS versions must not require a contract change. Match on the strings you care about; do not assume the list is exhaustive of anything but what that participant publishes.

An unregistered participant returns 200 with registered: false and an empty documentTypes, not an error.

POST /v1/peppol/registry/lookup takes many participant ids and returns per-item results, so one flaky lookup does not sink a 5000-partner sweep. Because failures are per item, the bulk endpoint has no whole-request 503; the single-participant GET does, with Retry-After.

Registry checks are not entitlement-scoped. SMP registration data is public Peppol directory information, so any authenticated client may look up any participant. That is deliberate. The usual subject of a lookup is a trading partner, which you do not manage.


5. Sending#

Validate first#

POST /v1/e-invoices/validate runs the identical chain the send path runs, with zero side effects: XSD → CEN/Peppol Schematron → SK national rules → a mapping dry-run. The mapping dry-run matters: without it you could get a 400 at send that no pre-check could have predicted.

{
  "valid": false,
  "clientReference": "ERP-2026-000123",
  "errors": [
    { "source": "Schematron", "rule": "BR-CO-10", "location": "/Invoice/cac:LegalMonetaryTotal", "message": "Sum of line net amounts must equal the line extension amount" },
    { "source": "Mapping", "rule": "SUPPLIER_TAX_ID_MISSING", "message": "Supplier tax identifier could not be resolved" }
  ],
  "warnings": [
    { "source": "Mapping", "rule": "CHARGE_CODE_UNMAPPED", "message": "Allowance/charge reason code 64 has no Archiles equivalent" }
  ]
}

source is Xsd, Schematron or Mapping, so you can tell a CEN rule violation apart from an Archiles mapping limitation. The result echoes clientReference back, and batch results are keyed by it.

Validation requires X-Peppol-Participant-Id even though it has no side effects, because it runs as a participant: the header selects the account whose validation strictness setting, EAS scheme and default currency/country change the verdict, and it is what entitlement, metering and rate limiting attribute the call to. So you cannot validate before you own a participant. POST /v1/peppol/participants comes first.

POST /v1/e-invoices/validate/batch is the bulk form: one result per item, in request order, each echoing its clientReference when you supplied one.

The document envelope#

Validation and sending take the same body, so the document you validate is exactly the document you would send:

{
  "clientReference": "ERP-2026-000123",
  "format": "peppol-bis",
  "payload": "<base64-encoded document>"
}

When email delivery ships, fallback will send over Peppol where the buyer is a registered participant and by email where it is not; only skips Peppol; always does both. The email leg will report its own outcome in a separate emailDelivery object on the status response, so status keeps meaning Peppol delivery. Delivered will stay a Peppol-only statement, because email cannot prove delivery.

What happens to your bytes#

Archiles transmits a Peppol BIS payload byte-for-byte as you supplied it, never re-serialised through an Archiles exporter. Nothing is lost in remapping, and a signed payload reaches the buyer unaltered.

In parallel, Archiles maps the document into its own archive model, for search, PDF rendering, approvals and format conversion. That mapping is a precondition of sending:

This is why the mapping dry-run in POST /v1/e-invoices/validate matters, and why you should validate new document shapes before relying on them in production.

Declared metadata must match the payload#

On the SAPI surface, metadata is required and declares the Peppol routing explicitly. Archiles also derives that routing from the UBL payload, so the two inputs can disagree.

Rule: Archiles rejects any disagreement with 400, category: VALIDATION, retryable: false.

Declared fieldCompared against
senderParticipantIdcac:AccountingSupplierParty endpoint ID, built via the configured EAS scheme
receiverParticipantIdcac:AccountingCustomerParty endpoint ID
documentTypeIdUBL root element + cbc:CustomizationID
processIdcbc:ProfileID
documentIdcbc:ID of the invoice or credit note
creationDateTimenot compared. It is a submission timestamp, not a document field

checksum, when supplied, is verified as SHA-256 over the exact payload bytes; a mismatch is the same 400.

The reasoning: silently preferring the payload would deliver an invoice you believe went elsewhere. Silently preferring the metadata would route a document to a participant it does not name internally, which the receiving access point may then reject after transmission. A mismatch is a client bug, and the cheapest place to catch it is synchronously, at submission.

Single send#

POST /v1/e-invoices/submissions            Idempotency-Key recommended

Full synchronous validation, then 202:

{
  "clientReference": "ERP-2026-000123",
  "submissionId": "8f14e45f-ea0a-4c1f-9b2e-71cbd4a1c2f0",
  "documentId": "66c1f0a2b3d4e5f6a7b8c9d0",
  "conversationId": "urn:uuid:5d9c1a7e-3f42-4b18-9a55-2c0d7e8f1b34",
  "status": "Queued"
}

Delivery itself is asynchronous. 202 means validated, archived and enqueued, not delivered. This matches SAPI's ACCEPTED, which is technical acceptance.

Batch send#

POST /v1/e-invoices/submissions/batch      ≤ 500 items or ≤ 100 MB, Idempotency-Key recommended

Batch admission runs cheap checks only, meaning entitlement, well-formedness, size and count, and then returns 202. Schematron, mapping and publishing run asynchronously per item.

{
  "batchId": "b7d2c3e4-5f60-4718-8293-a1b2c3d4e5f6",
  "accepted": 498,
  "rejected": 2,
  "items": [
    { "clientReference": "ERP-2026-000123", "submissionId": "8f14e45f-ea0a-4c1f-9b2e-71cbd4a1c2f0" },
    { "clientReference": "ERP-2026-000124",
      "error": { "category": "VALIDATION", "code": "ARCH-VAL-002",
                 "message": "Payload is not well-formed XML",
                 "retryable": false,
                 "correlation_id": "550e8400-e29b-41d4-a716-446655440000" } }
  ]
}

accepted and rejected count the synchronous admission step only. Schematron and mapping have not run when the 202 is written, so an item counted in accepted can still fail afterwards. Those outcomes appear only in GET /v1/e-invoices/submissions/batch/{batchId}. accepted + rejected always equals the number of items you submitted.

Batch results come back in request order and echo each item's clientReference. Correlate by the reference where you set one and by position where you did not, but set one on every item. A reference keeps correlation correct no matter what else changes, and it is what GET /v1/e-invoices/submissions filters on later.


6. Delivery status#

GET  /v1/e-invoices/submissions/{submissionId}
GET  /v1/e-invoices/submissions?since=<cursor>&changedFrom=<ts>&status=Sent&clientReference=…
GET  /v1/e-invoices/submissions/batch/{batchId}
POST /v1/e-invoices/submissions/{submissionId}/retry
POST /v1/e-invoices/submissions/retry                     { "submissionIds": [ … ] }

The statuses#

Under Slovak legislation a document receives at most one MLS (Peppol message level status), and that MLS is final. There is therefore no separate business-response phase to wait for: the MLS is the recipient's verdict.

StatusFinal?Retry allowed?Meaning
Queuednonovalidated, archived, waiting for hand-off to the network
Sentnonohanded off, on the network, awaiting the MLS
Deliveredyesnopositive and final. Includes the case where the receiving access point forwarded the document onward without a verifiable acknowledgement, which SK treats as accepted
Rejectedyesnothe recipient rejected the document. The content must change
Undeliverableyesyespermanent transport failure. Usually the receiver is not a registered participant, or not registered for this document type
DeliveryUnconfirmednoyesno MLS arrived inside the SLA window. Not proof of failure, because a late MLS still resolves it
Failedyesyesan Archiles-side failure. failureReason says what

An unrecognised status must be treated as non-final. New values can be added over time, and the safe reading of one your client does not know is "not finished yet": keep watching it and put it in front of a human rather than filing it as an outcome. Every new value arrives with its finality stated in the changelog entry that introduces it.

Transitions.

Queued              → Sent | Undeliverable | Failed
Sent                → Delivered | Rejected | Undeliverable | DeliveryUnconfirmed | Failed
DeliveryUnconfirmed → Delivered | Rejected | Failed
Failed              → Delivered | Rejected      (only if a late confirmation arrives; see below)

Statuses do not simply move forward. DeliveryUnconfirmed → Delivered is a legal transition, because a late MLS resolves an unconfirmed delivery positively. What does hold is that a recorded verdict is never overturned. Nothing moves a submission out of Delivered or Rejected except an explicit retry, which puts it back into Queued. Failed is the exception, and only because it holds no verdict. It means we could not complete or confirm the send, which does not prove the document never reached the buyer. If a confirmation for it does arrive afterwards, the submission resolves to Delivered or Rejected, and you would want it to, because the alternative is retrying a document the buyer already has.

When to stop polling#

At Delivered, Rejected, Undeliverable or Failed. The pipeline will not move those again. DeliveryUnconfirmed is the only status that can still change by itself, and it is also the one you are expected to act on. Nothing is ever left in a status that can neither change nor be treated as final.

Two refinements, both in your favour:

Two further guarantees:

Tax reporting#

The status response also carries taxReporting: { state, message? }, so you can observe the Slovak tax-reporting side-flow. It is absent when no tax-reporting leg applies to the document. state is one of NotApplicable, Pending, Sent, Delivered, DeliveryUnconfirmed, Rejected, Unreachable, Disregarded. SAPI has no equivalent field.

Read it value by value rather than assuming it is the delivery-status enum again. Five values do mean the same kind of thing on both (Sent, Delivered, DeliveryUnconfirmed, Rejected, Disregarded), but the two sets are not interchangeable:

Retry#

POST /v1/e-invoices/submissions/{submissionId}/retry re-enqueues the payload already stored for that submission, same document and same submission id, and returns 202 with the status back to Queued. It is a retransmission: it cannot create a second invoice in Archiles, and it appends a new send attempt rather than replacing the history.

Allowed from DeliveryUnconfirmed, Undeliverable and Failed. Everything else is 409:

Current statusWhy not
Queued, Senta transmission is already in flight
Deliveredalready delivered; a resend risks a duplicate invoice at the buyer
Rejectedthe recipient rejected the content; the same bytes will be rejected again. Send a corrected document as a new submission

A retry costs one item against the rate limit, exactly like a send. The bulk form (POST /v1/e-invoices/submissions/retry, ≤ 500 ids, per-item results) exists because re-driving a night's worth of DeliveryUnconfirmed items one call at a time is the same problem bulk send solves.

This is also how you retry part of a batch. An admitted batch item has a submissionId and is re-driven here. An item rejected at admission has none, because nothing was created for it, so resubmit it as a new send or batch under a new Idempotency-Key.


7. Receiving#

Inbound delivery is pull-only in v1. There are no webhooks: nothing for us to retry, no endpoint of yours to secure, no delivery guarantee for us to own. You poll. Optional webhooks for send-status changes and for newly received invoices are on the roadmap.

Listing#

GET /v1/e-invoices/inbox?since=<cursor>&receivedFrom=<ts>&status=New&limit=100
{
  "items": [
    {
      "id": "66c1f0a2b3d4e5f6a7b8c9d0",
      "cursor": "eyJyIjoiMjAyNi0wOC0xOVQwOToxNDoyMloiLCJpIjoiNjZjMWYwYTJiM2Q0ZTVmNmE3YjhjOWQwIn0",
      "receivedAt": "2026-08-19T09:14:22Z",
      "senderParticipantId": "0245:9876543210",
      "documentType": "invoice",
      "documentNumber": "2026/00417",
      "issueDate": "2026-08-18",
      "totalAmount": "1234.56",
      "currency": "EUR",
      "attachmentCount": 2,
      "status": "New",
      "pdfState": "Ready"
    }
  ],
  "nextCursor": "eyJyIjoiMjAyNi0wOC0xOVQwOToxNDoyMloiLCJpIjoiNjZjMWYwYTJiM2Q0ZTVmNmE3YjhjOWQwIn0"
}

Descriptors carry no payload, so "has anything new arrived?" is one small call.

status on a descriptor is New or Acknowledged. The status query parameter also accepts All, which is a filter value only and never appears in a response.

receivedFrom bounds a first sweep. Omitting both since and receivedFrom starts from the beginning of the account's history, which for an established account can be years of invoices. Pass the current timestamp as receivedFrom to start from empty. It is ignored once since is supplied.

documentType, documentNumber, issueDate, totalAmount and currency are informational. They are products of the mapping layer and any of them may be absent when a received document maps imperfectly. The payload from /content is the authoritative document. Post to your ledger from that, never from these fields. totalAmount is a decimal string ("1234.56"), not a JSON number: money must not travel through binary floating point, and this field is for display and search, not for arithmetic that has to reconcile.

Content retrieval#

GET /v1/e-invoices/inbox/{documentId}
GET /v1/e-invoices/inbox/{documentId}/content?format=peppol-bis&pdf=include
GET /v1/e-invoices/inbox/{documentId}/attachments/{attachmentId}
Accept: application/json  →  { id, format, document (base64), pdf?, pdfState, attachments }
Accept: application/zip   →  document.xml
                             document.pdf                        (when pdf=include|only)
                             attachments/001_delivery-note.pdf   (original names, collision-suffixed)
                             manifest.json                       (ids, names, MIME, SHA-256, metadata)

The ZIP bundle exists because a received invoice with five attachments would otherwise be seven round trips.

pdfState: "Pending" means retry, not "no PDF". Inbound PDF rendering is asynchronous. The states are Ready, Pending and Unavailable.

Archiles extracts attachments embedded in the received UBL and exposes them as addressable sub-resources. The raw UBL still contains them, so nothing is lost either way.

Acknowledgement#

POST /v1/e-invoices/inbox/{documentId}/acknowledge     single
POST /v1/e-invoices/inbox/acknowledge                  bulk { "ids": [ … ] }

Both return 200 with an acknowledgedAt timestamp. The custody state goes New → Acknowledged, one-way and idempotent. acknowledgedAt is the timestamp of the first acknowledgement and does not change on later calls, so a repeated acknowledge is safe and visibly a no-op. There is no un-acknowledge; a client that loses its own state re-reads with status=All.

Three different things are called an acknowledgement in Peppol. Do not conflate them.

  1. MLS. The Peppol message level status. Automatic on both sides, never client-driven. Outbound it is the recipient's verdict (see §6). Inbound, Archiles generates it when the document is imported.
  2. /acknowledge. Custody transfer from Archiles to you. This is the one you call. It has nothing to do with the MLS, which was already sent at import time.
  3. UBL ApplicationResponse (Peppol Invoice Response). A separate, additional business response document. Out of scope for v1: Archiles neither sends nor consumes it, and nothing in the Slovak flow requires it. Its absence costs nothing, because the MLS already carries accept and reject.

8. Errors#

One envelope on both surfaces:

{ "error": {
    "category": "VALIDATION",
    "code": "SAPI-VAL-001",
    "message": "Document payload is invalid",
    "details": [ { "field": "metadata.receiverParticipantId",
                   "issue": "Does not match payload",
                   "value": "0245:9876543210" } ],
    "retryable": false,
    "correlation_id": "550e8400-e29b-41d4-a716-446655440000" } }

category, code, message, retryable and correlation_id are always present.

Do not branch on code. It exists for support, logging and display. Show it to the user together with message, and drive control flow from the HTTP status plus retryable. The code set is deliberately open and grows without a contract change, which is why we publish no exhaustive catalogue. An enumerated list would invite the branching we are asking you not to do. ARCH-* codes are native-only conditions, SAPI-* are SAPI's. A published code is never repurposed.

Status codes#

StatusCategoryRetryableUsed for
400VALIDATIONnoMalformed body, schema violation, failed Schematron or mapping, declared metadata disagreeing with the payload
401AUTHno; re-authenticate firstMissing, invalid or expired token
402PERMANENTnoThe account cannot send more documents right now
403AUTHnoParticipant not in your entitlements
404PERMANENTnoNo such resource for this participant
406PERMANENTnoThe Accept type or format requested cannot be produced for this document (content retrieval only)
409PERMANENTnoIdempotency key reused with a different payload; duplicate participant; retry from a status that forbids it
413PERMANENTnoPayload or batch over limit
415PERMANENTnoUnsupported content type
422PERMANENTnoParticipant cannot be provisioned automatically; the request is valid, the operation needs support
423AUTHno; retry after the lockout expiresCredential temporarily locked after repeated failed authentications
429TEMPORARYyes, after Retry-AfterRate limit
500PROCESSINGyes, with backoffUnexpected server fault
503TEMPORARYyes, after Retry-AfterSMP lookup unavailable (registry checks), and on any operation, maintenance or an infrastructure fault

402 is deliberately one condition, not three. It means "this account cannot send more documents right now" and does not distinguish a subscription that never existed from one that expired or one whose document allowance is exhausted. All three have the same remedy: extend the subscription, which is also the act of agreeing to the terms. A split would give you nothing to do differently.

403 versus 404. A participant you are not entitled to always returns 403, whether or not it exists. A document id that exists but belongs to another participant returns 404, not 403, so nobody can probe document ids for existence across tenants.

503 is listed on the registry check only, but can reach you anywhere. The contract declares it where the application itself produces it, on an SMP lookup that cannot be performed. During a deployment or an infrastructure fault any operation may answer 503, and we do not declare it on all thirty of them, because that would say nothing an integrator does not already have to handle. Treat any 503 as TEMPORARY, honour Retry-After, and keep your Idempotency-Key for the retry.

Retry guidance#

Retry only 429, 500, 503 and transport-level errors. Use exponential backoff starting at 1 second, capped at 5 minutes, with jitter. Always honour Retry-After. Always send an Idempotency-Key on send, so that a retry after an ambiguous timeout cannot duplicate an invoice.


9. Idempotency, limits, rate limits and retention#

Idempotency#

Idempotency-Key is honoured on five operations: POST /v1/e-invoices/submissions, POST /v1/e-invoices/submissions/batch, POST /v1/e-invoices/submissions/{submissionId}/retry, POST /v1/e-invoices/submissions/retry and the SAPI send. Keys are retained 24 hours and scoped to your client.

A key identifies one call to one operation on one resource. The method, the path (including the submissionId in it) and the body all take part. So retrying two different submissions under a single key is two different requests and both are honoured; it is only the same call repeated that replays the first response.

SituationResult
New keyProcessed normally; the response is stored
Same key, same bodyThe original stored response is replayed verbatim, including its status code
Same key, different body409 PERMANENT; the key is already bound to a different payload
No keyProcessed normally, no deduplication
Concurrent duplicate still in flightThe second request blocks briefly, then replays the first response

This is a transport guarantee only. If you resubmit the same invoice under a new key, it is sent twice. Idempotency protects you against ambiguous timeouts, not against your own duplicate logic.

Batches. The key covers the whole batch, so replaying it returns the original 202 and cannot re-drive part of the work. That is deliberate. The retry routes in §6 are what re-drive individual items.

Limits#

LimitValue
Maximum payload per document20 MB (both surfaces)
Maximum items per batch500
Maximum total size per batch100 MB
Maximum ids per bulk retry500
Inbox / submissions page sizedefault 100, maximum 500 (SAPI's receive listing: default 20, maximum 100)
Idempotency key retention24 hours
Access token lifetime15 minutes
Refresh tokenidle 30 minutes, maximum 10 hours, rotated on every use

The per-minute request rate depends on your subscription and comes with your credentials. The live values are always on the wire; see below.

Rate limits#

A token bucket per API client. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; read them rather than modelling the bucket yourself.

Bulk endpoints count per item, not per request. Otherwise a 500-item batch would cost the same as a single send. A retry counts as one item, like a send.

The budget is per credential, not per participant. If you front many participants, they share one budget and there is no fair-share scheme between them: one participant's nightly 5000-invoice sweep can consume the whole allowance. Interleave your work across participants rather than draining one at a time. Pacing is yours to manage.

A batch that would exceed your remaining budget is accepted whole and drained at the permitted rate. It is never partially accepted and never split. You get 429 only when the budget is already exhausted at admission. In that case nothing was enqueued, and the whole request is safe to retry after Retry-After.

Retention#

Nothing is purged automatically. A received document, its payload and its attachments remain available for as long as the account keeps them. A 404 on a document id means the account's own users deleted it, not that it aged out. The only time-bounded record is Idempotency-Key state (24 hours), which is a transport artefact rather than a document.


10. Paging#

Both list surfaces page forward only, but they signal the end differently. The difference is deliberate, because the SAPI half is frozen to its published schema.

Native (/v1/e-invoices/*)SAPI (/v1/sapi/*)
Cursor parametersincepageToken
Cursor in responsenextCursor, always presentnextPageToken, absent on the last page
How you stopwhen a page comes back with an empty items arraywhen nextPageToken is absent
Bounding a first sweepreceivedFrom (inbox), changedFrom (submissions)not available

If you use both surfaces, implement both stop conditions.

The native cursor is opaque, an encoding of a timestamp plus a document id. Do not parse it or construct one; store the last value you were given and pass it back.

Per-item cursor fields on inbox descriptors and submissions are for resuming mid-page. The list-level nextCursor equals the cursor of the last item on the page, so if you process whole pages you can ignore them entirely.

The submission listing is at-least-once; the inbox is exactly-once. The inbox orders by receivedAt, which never changes, so a document appears once per sweep. The submission listing orders by last status change, which is the point of it. A submission that moves while you are paging reappears at its new position rather than being skipped, so the same submissionId can appear on two pages of one sweep. Deduplicate on submissionId and keep your processing idempotent. Being shown something twice is recoverable; being silently skipped would not be.

Archiles ignores receivedFrom and changedFrom once you supply since, which is the more specific instruction.


11. Environments#

Test / sandboxProduction
API base URLhttps://api.test.archiles.skhttps://api.archiles.sk (final hostname confirmed at go-live)
Native surfacehttps://api.test.archiles.sk/v1/peppol, .../v1/e-invoiceshttps://api.archiles.sk/v1/peppol, .../v1/e-invoices
SAPI surfacehttps://api.test.archiles.sk/v1/sapihttps://api.archiles.sk/v1/sapi
Token endpointhttps://api.test.archiles.sk/v1/oauth/tokenhttps://api.archiles.sk/v1/oauth/token

The token endpoint is part of the API host. There is no separate identity host to configure, and no other host should appear in your client configuration. If any documentation ever points you at one, treat it as an error and tell us.

How credentials are issued#

Credentials are issued per integrator during onboarding. You receive:

ItemNotes
Base URLtest first; production at go-live
client_ide.g. archiles-api-client
client_secretsent over a separate channel, never in plain text by email
Participant listthe DIČs your credential is entitled to act on
Rate limitthe per-minute value for your subscription

We do not send you access or refresh tokens. You obtain those yourself from the token endpoint. From us you get only client_id and client_secret.

You will not be asked for IP ranges: nothing filters on them (§3).


12. A worked end-to-end example#

The whole loop, in order, with the same participant throughout. The base URL is the test host. The Authorization header is elided after step 1 but is required on every call, and X-Correlation-Id comes back on every response except the token call. The example shows the headers once, on step 2.

1. Get a token#

curl -X POST "https://api.test.archiles.sk/v1/oauth/token" -H "Content-Type: application/json" -d '{"grant_type":"client_credentials","client_id":"archiles-api-client","client_secret":"<secret>"}'
HTTP/1.1 200 OK
{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 900
}

The token routes are the one place that returns no X-Correlation-Id and no rate-limit headers. Every other response in this example carries all four (§8, §13).

2. Check that the buyer is reachable#

curl "https://api.test.archiles.sk/v1/peppol/registry/0245:9876543210" -H "Authorization: Bearer $TOKEN"
HTTP/1.1 200 OK
X-Correlation-Id: 550e8400-e29b-41d4-a716-446655440000
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 599
X-RateLimit-Reset: 1755594900
{
  "participantId": "0245:9876543210",
  "registered": true,
  "documentTypes": [
    "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2::Invoice##urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0::2.1"
  ]
}

3. Validate the invoice#

curl -X POST "https://api.test.archiles.sk/v1/e-invoices/validate" -H "Authorization: Bearer $TOKEN" -H "X-Peppol-Participant-Id: 0245:2022182030" -H "Content-Type: application/json" -d @validation-request.json

validation-request.json:

{
  "clientReference": "ERP-2026-000123",
  "format": "peppol-bis",
  "payload": "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4..."
}
{
  "valid": true,
  "clientReference": "ERP-2026-000123",
  "errors": [],
  "warnings": []
}

Note the empty arrays: they are always present, never omitted and never null.

4. Send it#

curl -X POST "https://api.test.archiles.sk/v1/e-invoices/submissions" -H "Authorization: Bearer $TOKEN" -H "X-Peppol-Participant-Id: 0245:2022182030" -H "Idempotency-Key: 4f8c2b10-6d3a-4e91-8a77-0b5f1c9e2d43" -H "Content-Type: application/json" -d @validation-request.json
HTTP/1.1 202 Accepted
{
  "clientReference": "ERP-2026-000123",
  "submissionId": "8f14e45f-ea0a-4c1f-9b2e-71cbd4a1c2f0",
  "documentId": "66c1f0a2b3d4e5f6a7b8c9d0",
  "conversationId": "urn:uuid:5d9c1a7e-3f42-4b18-9a55-2c0d7e8f1b34",
  "status": "Queued"
}

The body is the same file you validated in step 3, which is the point of the shared envelope.

5. Poll until a final status#

curl "https://api.test.archiles.sk/v1/e-invoices/submissions/8f14e45f-ea0a-4c1f-9b2e-71cbd4a1c2f0" -H "Authorization: Bearer $TOKEN" -H "X-Peppol-Participant-Id: 0245:2022182030"

First poll:

{
  "submissionId": "8f14e45f-ea0a-4c1f-9b2e-71cbd4a1c2f0",
  "clientReference": "ERP-2026-000123",
  "documentId": "66c1f0a2b3d4e5f6a7b8c9d0",
  "conversationId": "urn:uuid:5d9c1a7e-3f42-4b18-9a55-2c0d7e8f1b34",
  "cursor": "eyJjIjoiMjAyNi0wOC0xOVQwOToxMjowMVoiLCJzIjoiOGYxNGU0NWYifQ",
  "status": "Sent",
  "senderParticipantId": "0245:2022182030",
  "receiverParticipantId": "0245:9876543210",
  "documentType": "invoice",
  "submittedAt": "2026-08-19T09:11:48Z",
  "lastStateChangeAt": "2026-08-19T09:12:01Z",
  "taxReporting": { "state": "Sent" }
}

A later poll, once the MLS has arrived:

{
  "submissionId": "8f14e45f-ea0a-4c1f-9b2e-71cbd4a1c2f0",
  "clientReference": "ERP-2026-000123",
  "documentId": "66c1f0a2b3d4e5f6a7b8c9d0",
  "conversationId": "urn:uuid:5d9c1a7e-3f42-4b18-9a55-2c0d7e8f1b34",
  "cursor": "eyJjIjoiMjAyNi0wOC0xOVQwOToxNDozM1oiLCJzIjoiOGYxNGU0NWYifQ",
  "status": "Delivered",
  "senderParticipantId": "0245:2022182030",
  "receiverParticipantId": "0245:9876543210",
  "documentType": "invoice",
  "submittedAt": "2026-08-19T09:11:48Z",
  "lastStateChangeAt": "2026-08-19T09:14:33Z",
  "taxReporting": { "state": "Delivered" }
}

Delivered is final, so stop polling. Had it come back DeliveryUnconfirmed, you would keep polling or retry (§6); had it come back Rejected, you would correct the document and send it as a new submission.

6. Poll the inbox#

curl "https://api.test.archiles.sk/v1/e-invoices/inbox?status=New&limit=100&receivedFrom=2026-08-19T00:00:00Z" -H "Authorization: Bearer $TOKEN" -H "X-Peppol-Participant-Id: 0245:2022182030"
{
  "items": [
    {
      "id": "66c1f0a2b3d4e5f6a7b8c9d1",
      "cursor": "eyJyIjoiMjAyNi0wOC0xOVQwOToxNDoyMloiLCJpIjoiNjZjMWYwYTIifQ",
      "receivedAt": "2026-08-19T09:14:22Z",
      "senderParticipantId": "0245:9876543210",
      "documentType": "invoice",
      "documentNumber": "2026/00417",
      "issueDate": "2026-08-18",
      "totalAmount": "1234.56",
      "currency": "EUR",
      "attachmentCount": 1,
      "status": "New",
      "pdfState": "Ready"
    }
  ],
  "nextCursor": "eyJyIjoiMjAyNi0wOC0xOVQwOToxNDoyMloiLCJpIjoiNjZjMWYwYTIifQ"
}

Store nextCursor and pass it as since on the next poll. Keep polling until items comes back empty, because nextCursor is present even on the last page.

7. Fetch the content#

curl "https://api.test.archiles.sk/v1/e-invoices/inbox/66c1f0a2b3d4e5f6a7b8c9d1/content?pdf=include" -H "Authorization: Bearer $TOKEN" -H "X-Peppol-Participant-Id: 0245:2022182030" -H "Accept: application/json"
{
  "id": "66c1f0a2b3d4e5f6a7b8c9d1",
  "format": "peppol-bis",
  "document": "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4...",
  "pdf": "JVBERi0xLjcKJcfsj6IKNSAwIG9iago8PC9MZW5ndGgg...",
  "pdfState": "Ready",
  "attachments": [
    {
      "id": "66c1f0a2b3d4e5f6a7b8c9e0",
      "filename": "delivery-note.pdf",
      "mimeType": "application/pdf",
      "sizeBytes": 48213,
      "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
    }
  ]
}

document is the original received bytes. Post to your ledger from this, not from the descriptor's informational fields. For the whole thing in one file, ask for Accept: application/zip instead.

8. Acknowledge custody#

curl -X POST "https://api.test.archiles.sk/v1/e-invoices/inbox/66c1f0a2b3d4e5f6a7b8c9d1/acknowledge" -H "Authorization: Bearer $TOKEN" -H "X-Peppol-Participant-Id: 0245:2022182030"
{
  "id": "66c1f0a2b3d4e5f6a7b8c9d1",
  "acknowledged": true,
  "acknowledgedAt": "2026-08-19T09:31:07Z"
}

Calling it again returns the same acknowledgedAt, the timestamp of the first acknowledgement. That is how you can tell the operation is genuinely idempotent rather than merely tolerant.

Use POST /v1/e-invoices/inbox/acknowledge with { "ids": [ … ] } to acknowledge a whole page at once; it returns the same object per item, plus a per-item error where one failed.


13. Support#

Every response carries an X-Correlation-Id header, and every error body repeats it as error.correlation_id. It is logged on our side and resolves to the exact log lines for your call.

Quote the correlation id in every support request. With it, we can usually diagnose a problem without a reproduction. Without it, we are guessing.

Support channelsupport@archiles.sk
Includethe correlation id, the endpoint and HTTP status, your clientReference and, for send problems, the submissionId
For a token-endpoint problemthose routes carry no correlation id, so send your client_id and the time of the call (with timezone) instead
Do not includeyour client_secret, access tokens or refresh tokens

A 422 on POST /v1/peppol/participants, or a 402 you believe is wrong, are support cases rather than retry cases.


14. Sandbox test data#

The test environment is a real Peppol test network, so a send needs a receiver that actually resolves in the test SMP. You get sandbox test data with your test credentials during onboarding rather than published here, because we refresh it periodically and a stale participant id inside a document is worse than none.

You receive:

ItemWhat it is for
Sender participant ida test DIČ your credential is entitled to act as
Receiver participant ida test participant registered in the test SMP for BIS Billing 3.0 invoices
A receiver that is not registeredto exercise registered: false and the Undeliverable path
A sample BIS invoice that passes validationa known-good baseline for step 3 of §12
A sample BIS invoice that fails validationwith the rule it violates, to exercise your error handling

Ask support (§13) if you need these reissued, or if you need a test case for a specific document shape or failure mode.