Skip to content
Simple Peppol Get API access

API documentation

Technical guide for integrating your software with Simple Peppol: one REST API to send and receive documents on the Peppol network.

Authentication

All API requests require authentication using a Bearer token. You'll receive an API token when your account is created.

Authorization: Bearer SBG_your_api_token_here

API tokens are prefixed with SBG_ and should be kept secure. Never commit tokens to version control.

Security Note: Tokens are hashed using SHA-256 with your account's salt. Store tokens securely and rotate them periodically.

Base URL

The base URL is the same for every customer. The path selects the environment. Each token belongs to one environment: use a production token on /api/v1 and a sandbox token on /api-sandbox/v1. A token on the other path gets 403.

  • Production: https://peppol-api.simplebill.pro/api/v1 — sends to the live Peppol network.
  • Sandbox: https://peppol-api.simplebill.pro/api-sandbox/v1 — a safe rehearsal with a sandbox token and the same requests and responses as production. Sending and registering never touch the live network. Receiver lookup is read-only and does.

How sandbox works. A document you POST to /api-sandbox/v1/documents is validated against the Peppol rules exactly as in production. Its delivery lifecycle is then simulated: the document moves queued → processing → sent, and a copy of it comes back to your inbox with an inbound webhook, so you can exercise your receiving code. Some test receiver IDs make a send fail on purpose: see Outbound status. To receive a document without sending one, use POST /api-sandbox/v1/inbox: see Receiving documents. Nothing is sent onto the live Peppol network, and sandbox activity is not billed. Registering participants is simulated in sandbox too: see Participants. When your integration succeeds in sandbox, switch the base URL to /api/v1 and use your production token — the behaviour is identical.

Timestamps

Every timestamp is ISO 8601 and carries a UTC offset. Parse the offset. Do not assume +00:00.

  • Webhook events, registrations, checked_at of the receiver lookup, the items of the inbox in cursor mode and the answer of POST /inbox/{id}/ack use the offset of Brussels time: +01:00 in winter, +02:00 in summer. Example: 2026-11-05T11:00:00+01:00.
  • GET /documents/{id}, the GET /documents list, GET /inbox/{id} and the inbox in page mode write UTC with a Z and microseconds. Example: 2026-11-05T10:00:00.000000Z.

Both forms name an exact instant. A standard ISO 8601 parser reads both. Compare instants, not strings.

Sending Documents

Send Peppol documents (invoices, credit notes, orders) via the POST /api/v1/documents endpoint. You can only send as a participant you have registered. See Participants.

Request

POST /api/v1/documents
Content-Type: application/json
Authorization: Bearer SBG_your_api_token_here
Idempotency-Key: your-unique-idempotency-key (optional)

{
  "sender_peppol_id": "0208:0123456749",
  "receiver_peppol_id": "0208:0987654394",
  "document_type_id": "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",
  "process_id": "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0",
  "payload_format": "ubl",
  "payload_xml": "<Invoice xmlns='urn:oasis:names:specification:ubl:schema:xsd:Invoice-2'>...</Invoice>",
  "client_reference": "simplebill-invoice-1234"
}

Request Parameters

Field Type Required Description
sender_peppol_id string Yes Your registered Peppol participant ID (e.g., "0208:0123456749"). Register it first with POST /api/v1/participants; it can send once its status is active. View or remove your registered senders in the portal at /portal/senders.
receiver_peppol_id string Yes Recipient's Peppol participant ID
document_type_id string Yes The Peppol BIS Billing 3.0 document type. Use one of these two identifiers:
Invoice: 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
Credit note: 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
With payload_format ubl, a UBL document type that contradicts the root element of the payload is refused with DOCUMENT-TYPE-MISMATCH.
process_id string Yes Peppol process URN
payload_format string Yes Either "ubl" or "sbdh"
payload_xml string Yes The UBL XML document (max 10 MB)
client_reference string No Your unique reference for idempotency (prevents duplicate sends)

Response

HTTP/1.1 200 OK
Content-Type: application/json

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "status": "queued",
  "warnings": []
}

For a new document the status is queued. We have stored it and send it in the background. Follow it with the peppol.outbound_document.status_changed event or with GET /documents/{id}: see Outbound status.

A request with a client_reference you already used returns the existing document, whatever its status is now, and sends nothing new. The Idempotency-Key header is used as the client_reference when the body has none.

Refused documents

A sender that is not an active registration of your account, in the environment of the URL, gets 422. registration_status is the status of your registration of that ID, or null when you never registered it.

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json

{
  "message": "Sender Peppol ID registration is still pending. Wait until its status is active.",
  "sender_peppol_id": "0208:0123456749",
  "environment": "production",
  "registration_status": "pending"
}

If the registration is not pending, the message is "Sender Peppol ID is not registered for this customer and environment."

With payload_format ubl, a document_type_id that contradicts the payload is refused before validation and before the document is queued. The check does not run for sbdh. This sample sends a credit note with the Invoice document type:

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json

{
  "message": "Document type does not match the payload.",
  "vesid": null,
  "profile_name": null,
  "errors": [
    {
      "rule_id": "DOCUMENT-TYPE-MISMATCH",
      "error_code": "DOCUMENT_TYPE_MISMATCH",
      "error_message": "The document_type_id is for an Invoice but the payload root element is CreditNote.",
      "severity": "fatal",
      "location": "/"
    }
  ],
  "warnings": []
}

A document that fails the Peppol rules gets 422 with the same fields and the failing rules in errors. Check a document first with POST /api/v1/validate.

Checking Document Status

Read one document with GET /api/v1/documents/{id}. All values below are sample data.

GET /api/v1/documents/550e8400-e29b-41d4-a716-446655440000
Authorization: Bearer SBG_your_api_token_here

Response:
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "direction": "outbound",
  "status": "sent",
  "environment": "production",
  "sender_peppol_id": "0208:0123456749",
  "receiver_peppol_id": "0208:0987654394",
  "document_type_id": "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",
  "process_id": "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0",
  "client_reference": "simplebill-invoice-1234",
  "ap_message_id": "…",
  "payload_format": "ubl",
  "payload_size_bytes": 4821,
  "error_message": null,
  "error": null,
  "sent_at": "2026-11-05T10:00:03.000000Z",
  "received_at": null,
  "failed_at": null,
  "created_at": "2026-11-05T10:00:00.000000Z",
  "updated_at": "2026-11-05T10:00:03.000000Z"
}

An outbound document has the status queued, processing, sent or failed. delivered is the status of an inbound document in your inbox. What each outbound status means, and what error holds, is under Outbound status.

GET /api/v1/documents lists your documents, newest first. It takes direction (outbound or inbound), status, page and per_page (1 to 100, default 15). The answer is paginated like the participant list. A row carries the fields of the answer above, and some more: rely on the fields shown here.

Outbound status

POST /documents answers queued. We send the document in the background. You learn how it went from the peppol.outbound_document.status_changed event, or by reading GET /documents/{id}. The sandbox follows the same steps, simulated.

Statuses

Status Meaning Event
queued We stored the document and will send it. This is the status in the answer to POST /documents. None.
processing We are sending the document, or retrying a temporary failure. Sent when the document reaches this status.
sent The receiver's access point confirmed the document. Final. Sent when the document reaches this status.
failed The document was not sent, or we cannot confirm that it was. error says why. Final. Sent when the document reaches this status.

Each change of status sends one event, once. A retry sends no event.

status is the only field that tells you where a document stands. Do not infer it from sent_at or error_message. A document can briefly show a sent_at while its status is still processing: we record that the receiver's access point confirmed the send a moment before we move the status to sent.

Temporary failures are retried

Retries depend on what happened, not on the code. We send a document again only when we know that nothing left our side, for example when our access point could not be reached. If the document may have reached the receiver, it fails at once as delivery_unconfirmed, and we never send it again ourselves.

While we retry, the document stays processing, error is null and no event is sent. We make up to 4 attempts. The retries come 1, 5 and 15 minutes after the previous attempt failed. After the last failed attempt the document becomes failed, with the code of that last failure. A retrying document can stay processing for up to about 35 minutes before it fails. If it stays processing for more than a few minutes, it is being retried: you can show it as Retrying… in your own screens. Raise an alert on your side only after about 35 minutes.

When a document fails

error is null unless status is failed. A failed document has an error object with a code and a message. The same object is in the failed event. All values below are sample data.

GET /api/v1/documents/550e8400-e29b-41d4-a716-446655440000

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "direction": "outbound",
  "status": "failed",
  "environment": "production",
  "sender_peppol_id": "0208:0123456749",
  "receiver_peppol_id": "0208:0987654394",
  "document_type_id": "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",
  "process_id": "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0",
  "client_reference": "simplebill-invoice-1234",
  "ap_message_id": null,
  "payload_format": "ubl",
  "payload_size_bytes": 4821,
  "error_message": "…",
  "error": {
    "code": "receiver_not_registered",
    "message": "The receiver is not registered on the Peppol network for this document type. Send it another way, for example by email."
  },
  "sent_at": null,
  "received_at": null,
  "failed_at": "2026-11-05T10:00:03.000000Z",
  "created_at": "2026-11-05T10:00:00.000000Z",
  "updated_at": "2026-11-05T10:00:03.000000Z"
}

There are five codes. Each message is fixed, and you can show it to your user.

error.code Category error.message What to do
receiver_not_registered Not on Peppol The receiver is not registered on the Peppol network for this document type. Send it another way, for example by email. Send the document another way, for example by email. Receiver lookup tells you before you send.
receiver_rejected Permanent The receiver's access point refused the document. Fix the document if you can, then send it again with a new client_reference. Ask the receiver why if the cause is not clear.
transport_failed Temporary The document could not reach the receiver's access point after several attempts. Wait, then send it again with a new client_reference. The receiver's access point could not be reached. This is usually temporary.
gateway_unavailable Temporary Our access point was unavailable, so the document was not sent. Wait, then send it again with a new client_reference. Nothing was sent. See the service status.
delivery_unconfirmed Unconfirmed We could not confirm that the receiver's access point got the document. Check with the receiver before you send it again. Check with the receiver before you send it again; do not resend automatically.

A delivery_unconfirmed document may already be in the receiver's hands, and a second copy can become a duplicate invoice. Ask the receiver whether it arrived before you do anything else.

A request with a client_reference you already used returns the existing document, failed or not. To send a failed document again, use a new client_reference.

Where the error appears

  • GET /documents/{id} and the failed event carry the error object.
  • The rows of the GET /documents list carry the raw error_code and error_message columns. The error object is only on the single-document endpoint and in the event. For a row with the status failed, take the message from the table above.
  • A row can carry an error_code while its status is processing. That is the last temporary failure, and we are retrying. Only the status failed makes it final.
  • error_message is a diagnostic text for our support team. Do not parse it and do not show it to your users.

Sandbox failure receivers

Send a document in the sandbox to one of these receiver IDs and the send fails on purpose, with the code shown. IDs compare without regard to case. Any other receiver ends in sent. A failed document produces no copy in your inbox and no inbound event. The document goes processing, then failed, with one event for each. In the sandbox these fail at once: the sandbox never shows the retrying state.

Receiver ID error.code
0208:0000000295
9925:be0000000295
receiver_not_registered
0208:0000000394
9925:be0000000394
receiver_rejected
0208:0000000493
9925:be0000000493
transport_failed
0208:0000000592
9925:be0000000592
delivery_unconfirmed

Participants

A participant is a company on the Peppol network. Register it before you send as it. Registration publishes the company on our SMP and on the Peppol SML, so other Access Points can find it and deliver documents to it. It also lets you send as that company.

The SMP (Service Metadata Publisher) and the SML (Service Metadata Locator) are the directories Access Points use to find where a company receives documents.

Which IDs you can register

You can register Belgian identifiers only:

  • 0208: a Belgian enterprise number, 10 digits, for example 0208:0123456749. The first digit is 0 or 1. The last two digits are check digits.
  • 9925: a Belgian VAT number, BE followed by the same 10 digits, for example 9925:be0123456749.

IDs are stored and returned in lower case, and compared without regard to case. 9925:BE0123456749 and 9925:be0123456749 are the same participant.

An invalid participant_id gets 422, and errors.participant_id holds one of these messages. A missing value gets the usual "required" message.

Message When
A participant ID looks like 0208:0123456749 (four-digit scheme, colon, identifier). The value is not four digits, a colon and an identifier.
Only Belgian identifiers can be registered: scheme 0208 (enterprise number) or 9925 (VAT number). The scheme is not 0208 or 9925.
A 0208 identifier is a 10-digit Belgian enterprise number, for example 0208:0123456749. A 0208 value is not 10 digits starting with 0 or 1.
A 9925 identifier is a Belgian VAT number, BE followed by 10 digits, for example 9925:be0123456749. A 9925 value is not BE followed by 10 digits starting with 0 or 1.
This Belgian enterprise number fails its check digits. Check for a typo. The last two digits of the number do not match its first eight.

Token ability

Registering and deregistering needs the participants:write ability. Listing and reading participants needs send. The tokens shown in your portal have every ability. A token with a restricted ability list must include participants:write. Without it, POST and DELETE answer 403 with "This token is not allowed to perform this action." Ask us for a token that includes it.

Register a participant

All values below are sample data.

POST /api/v1/participants
Content-Type: application/json
Authorization: Bearer SBG_your_api_token_here

{
  "participant_id": "0208:0123456749"
}
HTTP/1.1 202 Accepted
Content-Type: application/json

{
  "registration_reference": "reg_550e8400-e29b-41d4-a716-446655440000",
  "participant_id": "0208:0123456749",
  "environment": "production",
  "status": "pending",
  "error": null,
  "created_at": "2026-10-20T11:00:00+02:00",
  "updated_at": "2026-10-20T11:00:00+02:00"
}
  • registration_reference: the ID of the registration. It starts with reg_. Use it to read or deregister.
  • participant_id: the participant, in lower case.
  • environment: production or sandbox.
  • status: where the registration stands. See the table below.
  • error: null, or an object with code and message when the status is rejected.
  • created_at, updated_at: ISO 8601 timestamps.

Registration is asynchronous. 202 means we accepted it and the status is pending. We then publish the company, and the status becomes active or rejected. Poll GET /api/v1/participants/{registration_reference} or use the status webhook below.

Before we write anything, we check the SML. If another provider already holds the ID, the answer is 409.

If your account already holds a pending or active registration of the ID, the API answers 200 with that registration, unchanged. Nothing is published twice. If the registration is rejected or revoked, send the POST again. You get 202, the same registration_reference, and the status is pending again.

Registration status values

Status Meaning
pending Being published. You cannot send as this participant yet.
active Published. You can send as this participant, and it can receive.
rejected Publication failed. error says why. Send the POST again to retry.
revoked Deregistered. In production it is removed from the SMP and the SML.

Registration errors

Every error body has a message. The 409 and 503 bodies also have an error_code.

Status Code Meaning and message
422 none The participant_id is not valid. See the messages above.
409 participant_registered_elsewhere Another provider holds the ID on the Peppol network. The body adds participant_id and smp_host, the host of that provider's SMP.
"This participant is already registered on the Peppol network with another provider. Ask that provider to deregister it, then try again."
409 participant_unavailable Another Simple Peppol account holds a live registration of the ID in this environment. The body adds participant_id. It says nothing about that account.
"This participant is already registered with Simple Peppol under another account."
503 registration_unavailable Registration is not available right now, or we could not read the SML to check the ID. Try again later.
"Participant registration is not available right now. Try again later or contact support."
403 none The token lacks participants:write, or belongs to the other environment.
429 none Too many registration requests. See Rate limits.
HTTP/1.1 409 Conflict
Content-Type: application/json

{
  "message": "This participant is already registered on the Peppol network with another provider. Ask that provider to deregister it, then try again.",
  "error_code": "participant_registered_elsewhere",
  "participant_id": "0208:0123456749",
  "smp_host": "smp.other-provider.example"
}

Rejections after the 202

A registration can fail after we accepted it. It then has the status rejected, and error.code is one of these three values. The same error object is in the status webhook. To retry, send the POST again.

error.code Meaning error.message
participant_registered_elsewhere The Peppol network refused the publication because another provider holds the ID. This happens when the ID was registered elsewhere after our check. This participant is already registered on the Peppol network with another provider. Ask that provider to deregister it, then try again.
smp_publication_failed We could not publish the company. Our team is alerted. We could not publish this participant on the Peppol network. Our team has been alerted. Try again later.
registration_unavailable Registration became unavailable before we could publish. Participant registration is not available right now. Try again later or contact support.

List and read participants

GET /api/v1/participants?participant_id=0208:0123456749&per_page=50
Authorization: Bearer SBG_your_api_token_here

Response:
{
  "data": [
    {
      "registration_reference": "reg_550e8400-e29b-41d4-a716-446655440000",
      "participant_id": "0208:0123456749",
      "environment": "production",
      "status": "active",
      "error": null,
      "created_at": "2026-10-20T11:00:00+02:00",
      "updated_at": "2026-10-20T11:00:12+02:00"
    }
  ],
  "current_page": 1,
  "per_page": 50,
  "total": 1
}

Both query parameters are optional. participant_id filters to one ID, in any case. per_page is 1 to 200 and defaults to 50. A malformed participant_id, or a per_page that is not a whole number from 1 to 200, gets 422. The list is sorted by participant_id. The response also carries the usual pagination links.

GET /api/v1/participants/{registration_reference} returns one registration, with the same fields as above. An unknown reference, or a reference of another account, gets 404 with {"message": "Not found."}.

Deregister a participant

DELETE /api/v1/participants/{registration_reference}
Authorization: Bearer SBG_your_api_token_here

Response:
{
  "registration_reference": "reg_550e8400-e29b-41d4-a716-446655440000",
  "participant_id": "0208:0123456749",
  "environment": "production",
  "status": "revoked",
  "error": null,
  "created_at": "2026-10-20T11:00:00+02:00",
  "updated_at": "2026-10-21T10:30:00+02:00"
}

The registration is revoked at once, and you can no longer send as it. In production we remove the company from the SMP and the SML in the background. This follows within minutes. Deregistering a revoked registration is harmless: you get 200 again.

Status webhook

Every status change sends peppol.sender_registration.status_changed to your webhook endpoint, in the same environment as the registration. The first event of a new registration has a previous_status of null. Headers, signature, retries and the rules for your handler are the same as for every other webhook: see Webhook events. participant_id is in lower case. error is null unless the status is rejected. All values below are sample data.

POST https://your-endpoint.com/webhooks/peppol
X-SimpleBill-Event: peppol.sender_registration.status_changed
X-SimpleBill-Event-Id: 3f6c1a52-8d47-4b0e-9a1c-5e2d7b8f0a11
X-SimpleBill-Signature: sha256=abc123...
Content-Type: application/json

{
  "event": "peppol.sender_registration.status_changed",
  "registration_reference": "reg_550e8400-e29b-41d4-a716-446655440000",
  "participant_id": "0208:0123456749",
  "environment": "production",
  "status": "active",
  "previous_status": "pending",
  "error": null,
  "changed_at": "2026-10-20T11:00:12+02:00",
  "event_id": "3f6c1a52-8d47-4b0e-9a1c-5e2d7b8f0a11",
  "occurred_at": "2026-10-20T11:00:12+02:00"
}

A rejected registration carries the reason:

{
  "event": "peppol.sender_registration.status_changed",
  "registration_reference": "reg_660e8400-e29b-41d4-a716-446655440001",
  "participant_id": "0208:0987654394",
  "environment": "production",
  "status": "rejected",
  "previous_status": "pending",
  "error": {
    "code": "smp_publication_failed",
    "message": "We could not publish this participant on the Peppol network. Our team has been alerted. Try again later."
  },
  "changed_at": "2026-10-20T11:03:40+02:00",
  "event_id": "9b2e4d71-0c35-4f68-a7d9-6c1e3f5a8b22",
  "occurred_at": "2026-10-20T11:03:40+02:00"
}

Sandbox. Register with POST /api-sandbox/v1/participants and a sandbox token. Sandbox registration never contacts the SMP or the SML. It runs the same checks as production, with the same statuses, the same one-live-registration rule and the same webhooks, and it publishes nothing. A participant you register in sandbox exists only in sandbox. Any valid ID that is not a test identifier becomes active within seconds.

In sandbox, register your own company's number, not a sample from these docs. Identifiers are unique per environment across all customers, so a sample number may already be held by another integrator.

One account holds a live registration of an ID at a time, in sandbox too. If another account holds it, you get 409 participant_unavailable. Deregistering frees it. Receiver lookup is the one exception to the rule that sandbox stays off the network: it reads the live Peppol network in both environments.

These test identifiers simulate failures in sandbox:

Participant ID Result
0208:0000000097
9925:be0000000097
409 participant_registered_elsewhere, with smp_host smp.example.com
0208:0000000196
9925:be0000000196
202, then rejected with smp_publication_failed
Any other valid ID 202, then active within seconds

Receiver lookup

Ask the Peppol network whether a receiver can take your documents before you send. GET /api/v1/lookup?participant_id=… accepts any participant ID, in any scheme, not only Belgian ones. It needs the send ability.

GET /api/v1/lookup?participant_id=0208:0987654394
Authorization: Bearer SBG_your_api_token_here

HTTP/1.1 200 OK
Content-Type: application/json

{
  "participant_id": "0208:0987654394",
  "registered": true,
  "smp_host": "smp.example-provider.eu",
  "capabilities": {
    "invoice": true,
    "credit_note": true
  },
  "checked_at": "2026-10-20T11:00:00+02:00"
}

All values above are sample data.

  • participant_id: the ID you asked for, in lower case.
  • registered: whether the receiver is registered on the Peppol network.
  • smp_host: the host of the receiver's SMP, or null when it is not registered.
  • capabilities.invoice, capabilities.credit_note: whether the receiver accepts a Peppol BIS Billing 3.0 Invoice and CreditNote.
  • checked_at: when we read the network. An answer can be up to 5 minutes old.

"registered": false means the receiver is not on the Peppol network. Do not send it a document over Peppol. Deliver it another way, for example by e-mail. In that case smp_host is null and both capabilities are false.

"registered": true with both capabilities false means the receiver is on the network but cannot take an Invoice or a CreditNote. Do not send it one over Peppol.

The lookup is the same in both environments. It reads the live Peppol network and writes nothing, so sandbox lookups show real receivers. Answers are cached for 5 minutes. A failed lookup is never cached.

We call the receiver's SMP only when the SML points to a plain host name or a public IP address, on port 80 or 443, over http or https. Any other address is refused, including a host that resolves to a private address, and the lookup answers 502 smp_unreachable.

Lookup errors

Status Code Meaning
422 none The participant_id is missing or is not four digits, a colon and an identifier.
502 smp_unreachable The receiver's SMP did not answer, answered with an error or an unreadable document, or is not a host we may call. The answer carries Retry-After: 60.
503 lookup_unavailable We could not read the Peppol network, so we cannot tell whether the receiver is registered. We never answer "registered": false in that case. The answer carries Retry-After: 60.
429 none Too many lookups. See Rate limits.
HTTP/1.1 502 Bad Gateway
Retry-After: 60
Content-Type: application/json

{
  "message": "The receiver's Peppol SMP did not answer. Retry in a minute.",
  "error_code": "smp_unreachable",
  "participant_id": "0208:0987654394"
}

The 503 body has the same fields, with "error_code": "lookup_unavailable" and the message "The Peppol network lookup is unavailable. Retry in a minute."

Validation

Check a document against the Peppol rules without sending it. POST /api/v1/validate takes the UBL XML and answers 200 with a report, whether the document is valid or not. It applies the same Peppol validation as POST /api/v1/documents. Nothing is stored, nothing is sent and nothing is billed. It needs the send ability.

POST /api/v1/validate
Content-Type: application/json
Authorization: Bearer SBG_your_api_token_here

{
  "payload_xml": "<Invoice xmlns='urn:oasis:names:specification:ubl:schema:xsd:Invoice-2'>...</Invoice>"
}

A valid document

HTTP/1.1 200 OK
Content-Type: application/json

{
  "valid": true,
  "vesid": "eu.peppol.bis3:invoice:2025.11",
  "profile_name": "Peppol BIS Billing UBL Invoice V3",
  "document_type_id": "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",
  "process_id": "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0",
  "errors": [],
  "warnings": []
}

An invalid document

HTTP/1.1 200 OK
Content-Type: application/json

{
  "valid": false,
  "vesid": "eu.peppol.bis3:invoice:2025.11",
  "profile_name": "Peppol BIS Billing UBL Invoice V3",
  "document_type_id": "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",
  "process_id": "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0",
  "errors": [
    {
      "rule_id": "BR-CO-15",
      "error_code": "BR-CO-15",
      "error_message": "[BR-CO-15]-Invoice total amount with VAT (BT-112) = Invoice total amount without VAT (BT-109) + Invoice total VAT amount (BT-110).",
      "severity": "fatal",
      "location": "/:Invoice[1]"
    }
  ],
  "warnings": []
}

All values in these reports are sample data.

valid is false when errors is not empty. Warnings never make a document invalid. A document that is not well-formed XML, or that carries a DTD, is reported the same way, with the rule_id XML-NOT-WELL-FORMED or XML-DTD-DISALLOWED. Its vesid, profile_name, document_type_id and process_id are null.

POST /validate takes no document_type_id, so it does not run the document type check described under Sending documents. A document over 10 MB, or a request without payload_xml, gets 422. A request body over 16 MB gets 413 instead: see Error handling. Validation is identical in sandbox: POST /api-sandbox/v1/validate.

When the validator is unavailable

If we cannot reach the validator, the answer is 503 with Retry-After: 30. We never answer "valid": true unless the validator checked the document.

HTTP/1.1 503 Service Unavailable
Retry-After: 30
Content-Type: application/json

{
  "message": "Peppol validator is temporarily unavailable. Please retry shortly.",
  "retry_after_seconds": 30
}

Receiving Documents

Inbound Peppol documents reach your inbox. You can learn about them in two ways: a webhook tells you at once, and the inbox API holds every document until you acknowledge it. Use both. The webhook is fast, and a regular reconciliation makes sure you miss nothing.

Who receives a document

A document goes to the account that holds the one pending or active registration of the receiver ID, in the environment of the document. See Participants. IDs compare without regard to case. A registration that is still pending already receives. A rejected or revoked one does not.

  • Production receives documents from the live Peppol network, for production registrations only. It never receives sandbox-only IDs.
  • Sandbox never receives real Peppol documents. It only receives the copy of each document you send, and the documents you simulate with POST /api-sandbox/v1/inbox (see below).
  • A document for an ID with no pending or active registration in that environment is not delivered to any inbox; it is held and we are alerted. Once the ID is registered, ask us to deliver the held document.

Each received document is a document with the status delivered. Its id is the ID you use in the inbox endpoints.

Token ability

GET /inbox, GET /inbox/{id} and POST /inbox/{id}/ack need the inbox:read ability. The tokens shown in your portal have every ability. Without the ability these endpoints answer 403 with "This token is not allowed to perform this action."

Polling Method

List inbound documents using GET /api/v1/inbox. By default the list is in page mode: newest first, with page and per_page (1 to 100, default 15). All values below are sample data.

GET /api/v1/inbox?page=1&per_page=50
Authorization: Bearer SBG_your_api_token_here

Response:
{
  "data": [
    {
      "id": "660e8400-e29b-41d4-a716-446655440000",
      "status": "delivered",
      "environment": "production",
      "sender_peppol_id": "0208:0987654394",
      "receiver_peppol_id": "0208:0123456749",
      "document_type_id": "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",
      "received_at": "2026-11-05T10:00:00.000000Z",
      "acknowledged_at": null
    }
  ],
  "current_page": 1,
  "per_page": 50,
  "total": 1
}

A row carries more fields than shown, and the answer carries the usual pagination links. Rely on the fields shown here. To page through the inbox, use the cursor described under Inbox reconciliation. Start each run without after: a document a run passes over stays unacknowledged, and the next run picks it up. Both modes take acknowledged=true or acknowledged=false as a filter, and a status filter (a document in your inbox has the status delivered).

Retrieving Document Payload

Get the full XML payload using GET /api/v1/inbox/{id}:

GET /api/v1/inbox/660e8400-e29b-41d4-a716-446655440000
Authorization: Bearer SBG_your_api_token_here

Response:
{
  "id": "660e8400-e29b-41d4-a716-446655440000",
  "status": "delivered",
  "environment": "production",
  "sender_peppol_id": "0208:0987654394",
  "receiver_peppol_id": "0208:0123456749",
  "document_type_id": "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",
  "process_id": "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0",
  "payload_format": "sbdh",
  "payload_size_bytes": 4821,
  "payload_xml": "<StandardBusinessDocument>...</StandardBusinessDocument>",
  "received_at": "2026-11-05T10:00:00.000000Z",
  "created_at": "2026-11-05T10:00:01.000000Z",
  "updated_at": "2026-11-05T10:00:01.000000Z",
  "acknowledged_at": null
}

A document you receive from the live network always has payload_format sbdh: payload_xml is the Standard Business Document as it arrived. In the sandbox, the copy of a document you sent has the payload exactly as you sent it, so it is plain UBL when you sent ubl. Read payload_format to tell which you hold. An unknown id, or the id of another account or environment, gets 404 with {"message": "Not found."}. An id that is not a UUID also gets 404, with the framework's message, for example {"message": "The route api/v1/inbox/not-a-uuid could not be found."}. Do not match on the text.

Simulate an inbound document (sandbox)

The sandbox has no sender on the Peppol network. POST /api-sandbox/v1/inbox puts an invoice or a credit note in your sandbox inbox, with its peppol.inbound_document.received event, as if it had been received. It needs the send ability, and it spends the same per-minute budget as POST /documents. It is never billed. There is no production counterpart: POST /api/v1/inbox answers 405.

POST /api-sandbox/v1/inbox
Content-Type: application/json
Authorization: Bearer SBG_your_sandbox_token_here

{
  "receiver_peppol_id": "0208:0123456749",
  "sender_peppol_id": "0208:0987654394",
  "payload_xml": "<Invoice xmlns='urn:oasis:names:specification:ubl:schema:xsd:Invoice-2'>...</Invoice>"
}
  • receiver_peppol_id (required): one of your own sandbox registrations, pending or active. Register it first: see Participants.
  • sender_peppol_id (optional): any well-formed participant ID. The default is 0208:0987654394.
  • payload_xml (required): a well-formed UBL Invoice or CreditNote, up to 10 MB, without a DTD. We do not run the Peppol rules on it. The document type follows the root element, and the process is Peppol BIS Billing 3.0.

The answer is 201 with the inbox item of the new document, in the same shape as an item of the cursor mode (see Inbox reconciliation). All values below are sample data.

HTTP/1.1 201 Created
Content-Type: application/json

{
  "id": "770e8400-e29b-41d4-a716-446655440002",
  "status": "delivered",
  "environment": "sandbox",
  "sender_peppol_id": "0208:0987654394",
  "receiver_peppol_id": "0208:0123456749",
  "document_type_id": "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",
  "process_id": "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0",
  "payload_format": "sbdh",
  "payload_size_bytes": 4821,
  "received_at": "2026-11-05T11:00:00+01:00",
  "created_at": "2026-11-05T11:00:00+01:00",
  "acknowledged_at": null
}

A request that fails the checks gets 422 with errors keyed by field:

Field Message When
receiver_peppol_id Use one of your registered sandbox participant IDs. The ID is malformed, or you hold no pending or active sandbox registration of it. A missing value gets the usual "required" message.
sender_peppol_id A participant ID looks like 0208:0123456749 (four-digit scheme, colon, identifier). The value is not a well-formed participant ID.
payload_xml The payload must be a UBL Invoice or CreditNote. The root element is neither Invoice nor CreditNote.
payload_xml The payload is too large. The payload is over 10 MB.
payload_xml A message that says what is wrong. The XML is not well-formed, or it carries a DTD. A missing value gets the usual "required" message.

Inbox reconciliation

A webhook can be missed: your endpoint was down for two days, or a deployment dropped a request. The inbox never loses a document. Reconcile with it on a schedule, for example every few minutes, and again after any outage of your endpoint.

The loop

  1. Poll GET /api/v1/inbox?acknowledged=false&order=asc&per_page=100. Start each run without after.
  2. For each item, fetch the payload with GET /api/v1/inbox/{id} and store it durably on your side.
  3. Only then acknowledge it with POST /api/v1/inbox/{id}/ack.
  4. Follow next_cursor: pass it as after and read the next page. Stop when next_cursor is null.

Acknowledge only after you have stored the document. A document you did not acknowledge is listed again in the next run, so a crash between the two steps costs you a repeat, never a loss. Deduplicate on the document id.

Start each run without after. The cursor orders documents by the second they reached your inbox. Some cases can make a run pass over a document, for example:

  • A document arrives in the same second as the document your cursor points to.
  • The clocks go back in autumn, and the repeated hour of local time puts a new document at a time your cursor has already passed.

The passed document stays unacknowledged, so the next run, which starts again at the oldest unacknowledged document, picks it up. Do not keep a cursor between runs.

A webhook delivery does not acknowledge a document. Only POST /inbox/{id}/ack does.

Cursor mode

order=asc, or any after, selects cursor mode. The documents come oldest first, in the order they reached your inbox, and a page never repeats a document. Acknowledging documents while you page does not disturb the cursor. A cursor is only good within one run: in rare cases a new document can be passed over (see the cases above), which is why each run starts without after. Never treat a cursor kept between runs as complete. You can ask for up to 100 documents per page with per_page. The default is 15. All values below are sample data.

GET /api/v1/inbox?acknowledged=false&order=asc&per_page=1
Authorization: Bearer SBG_your_api_token_here

Response:
{
  "data": [
    {
      "id": "660e8400-e29b-41d4-a716-446655440000",
      "status": "delivered",
      "environment": "production",
      "sender_peppol_id": "0208:0987654394",
      "receiver_peppol_id": "0208:0123456749",
      "document_type_id": "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",
      "process_id": "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0",
      "payload_format": "sbdh",
      "payload_size_bytes": 4821,
      "received_at": "2026-11-05T11:00:00+01:00",
      "created_at": "2026-11-05T11:00:01+01:00",
      "acknowledged_at": null
    }
  ],
  "per_page": 1,
  "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0xMS0wNSAxMTowMDowMSIsImlkIjoiNjYwZTg0MDAtZTI5Yi00MWQ0LWE3MTYtNDQ2NjU1NDQwMDAwIiwiX3BvaW50c1RvTmV4dEl0ZW1zIjp0cnVlfQ",
  "next_page_url": "https://peppol-api.simplebill.pro/api/v1/inbox?acknowledged=false&order=asc&per_page=1&after=eyJjcmVhdGVkX2F0IjoiMjAyNi0xMS0wNSAxMTowMDowMSIsImlkIjoiNjYwZTg0MDAtZTI5Yi00MWQ0LWE3MTYtNDQ2NjU1NDQwMDAwIiwiX3BvaW50c1RvTmV4dEl0ZW1zIjp0cnVlfQ"
}
  • next_cursor is an opaque string, or null on the last page. Pass it back as after, unchanged. Do not build or read one.
  • next_page_url is the same next page as a ready link, with your filters and the cursor. It is null on the last page.
  • An item has no payload. Fetch it with GET /api/v1/inbox/{id}.
  • acknowledged is true, false, 1 or 0. Anything else gets 422 with "The acknowledged filter must be true or false."
  • per_page is 1 to 100 in both modes. A value above 100 gets 422.
  • after pages in ascending order. With order=desc it gets 422: "A cursor (after) pages in ascending order: use order=asc or leave order out."
  • A cursor we did not issue gets 422: "The cursor is not valid. Use the next_cursor of a previous answer."

Acknowledge a document

POST /api/v1/inbox/660e8400-e29b-41d4-a716-446655440000/ack
Authorization: Bearer SBG_your_api_token_here

HTTP/1.1 200 OK
Content-Type: application/json

{
  "id": "660e8400-e29b-41d4-a716-446655440000",
  "acknowledged_at": "2026-11-05T11:05:00+01:00"
}

The request has no body. It is safe to repeat: every call returns the same acknowledged_at, the time of the first acknowledgement. An unknown id, or the id of another account or environment, gets 404 with {"message": "Not found."}. An id that is not a UUID also gets 404, with the framework's message, for example {"message": "The route api/v1/inbox/not-a-uuid could not be found."}. Do not match on the text. The acknowledgement has its own rate limit: see Rate limits.

Page mode is unchanged. GET /api/v1/inbox without order and after lists newest first, as described under Receiving documents.

Webhooks

Configure webhooks to receive a notification when something happens to your documents or registrations. Webhooks are sent via HTTP POST to your configured endpoint. The events are described under Webhook events.

Configure your webhook endpoint in the portal at /portal/webhooks, separately for each environment (sandbox and production). Your endpoint receives every event type. Use the Send test event button to verify your endpoint — it delivers a signed payload with event webhook.test that your handler can safely ignore. A test event is sent once and never retried.

  • The endpoint must be an HTTPS URL.
  • The host must be a plain host name, or an IP address, and every address it resolves to must be public. An endpoint on a private, loopback or link-local address is refused. Give an internationalised domain name in its xn-- form.
  • We do not follow redirects. A redirect counts as a failed attempt.

Every payload carries an environment field — production for live documents and sandbox for the simulated callbacks you receive while testing — so your handler can tell them apart.

Webhook Signature Verification

Verify webhook authenticity by checking the X-SimpleBill-Signature header. Its value is sha256= followed by the lower case hex of HMAC-SHA256(request_body, webhook_secret). The signature covers the exact bytes of the request body. Verify it on the raw body, before you parse the JSON, and do not re-encode the JSON first. The secret of each endpoint is shown in the portal at /portal/webhooks. A retry is signed with the secret that is current when it is sent.

$body = file_get_contents('php://input');
$expected = 'sha256='.hash_hmac('sha256', $body, $webhookSecret);
$received = $_SERVER['HTTP_X_SIMPLEBILL_SIGNATURE'] ?? '';

if (! hash_equals($expected, $received)) {
    http_response_code(401);
    exit;
}

$event = json_decode($body, true);
import hashlib, hmac

def is_genuine(raw_body: bytes, signature: str, secret: str) -> bool:
    expected = 'sha256=' + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

Important: Always verify webhook signatures to ensure requests are from Simple Peppol Gateway.

Webhook events

There are three event types. Every event is stored when it happens, and sent to your endpoint for the same environment. Timestamps carry a UTC offset: see Timestamps. Parse the offset.

Event When it fires
peppol.inbound_document.received A document reached your inbox. In production it came from the Peppol network. In the sandbox it is the copy of a document you sent, or a document you simulated.
peppol.outbound_document.status_changed A document you sent moved to processing, sent or failed. Not for queued, and not for a retry.
peppol.sender_registration.status_changed A registration changed status. See Participants.

Request headers

Header Value
X-SimpleBill-Event The event type, the same as event in the body.
X-SimpleBill-Event-Id The ID of the event, the same as event_id in the body. It is the same on every retry.
X-SimpleBill-Signature sha256= and the HMAC of the body. See Webhooks.
Content-Type application/json

Every body carries event_id and occurred_at after the fields of the event. For the inbound event, payload_xml comes after them. occurred_at is when we recorded the event. The body is fixed when the event happens: a retry sends the same bytes.

Inbound document received

id is the ID of the document in your inbox: use it with GET /inbox/{id} and POST /inbox/{id}/ack. payload_xml is the document itself. A webhook delivery does not acknowledge the document. All values below are sample data.

POST https://your-endpoint.com/webhooks/peppol
X-SimpleBill-Event: peppol.inbound_document.received
X-SimpleBill-Event-Id: 7d1c2f4e-3b5a-4c88-9e0f-1a2b3c4d5e6f
X-SimpleBill-Signature: sha256=abc123...
Content-Type: application/json

{
  "event": "peppol.inbound_document.received",
  "id": "660e8400-e29b-41d4-a716-446655440000",
  "environment": "production",
  "sender_peppol_id": "0208:0987654394",
  "receiver_peppol_id": "0208:0123456749",
  "document_type_id": "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",
  "process_id": "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0",
  "received_at": "2026-11-05T11:00:00+01:00",
  "event_id": "7d1c2f4e-3b5a-4c88-9e0f-1a2b3c4d5e6f",
  "occurred_at": "2026-11-05T11:00:01+01:00",
  "payload_xml": "<StandardBusinessDocument>...</StandardBusinessDocument>"
}

In production payload_xml is the Standard Business Document as it arrived. In the sandbox, the copy of a document you sent carries the payload as you sent it, with the sender and the receiver swapped.

Outbound document status changed

id is the ID that POST /documents returned. client_reference is yours, or null. previous_status is the status before the change: queued for processing, and normally processing for sent and failed. error is null unless status is failed. ap_message_id is the AS4 message ID of the transmission, null until the document is sent. Treat it as an opaque string. changed_at is when the document changed. The codes and what to do about them are under Outbound status. All values below are sample data.

{
  "event": "peppol.outbound_document.status_changed",
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "client_reference": "simplebill-invoice-1234",
  "environment": "production",
  "sender_peppol_id": "0208:0123456749",
  "receiver_peppol_id": "0208:0987654394",
  "document_type_id": "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",
  "status": "sent",
  "previous_status": "processing",
  "error": null,
  "ap_message_id": "…",
  "changed_at": "2026-11-05T11:00:03+01:00",
  "event_id": "c41e8a07-5d92-4f3b-b6a0-2e7d9f1c4b38",
  "occurred_at": "2026-11-05T11:00:03+01:00"
}

A failed document carries the error:

{
  "event": "peppol.outbound_document.status_changed",
  "id": "550e8400-e29b-41d4-a716-446655440001",
  "client_reference": "simplebill-invoice-1235",
  "environment": "production",
  "sender_peppol_id": "0208:0123456749",
  "receiver_peppol_id": "0208:0987654394",
  "document_type_id": "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",
  "status": "failed",
  "previous_status": "processing",
  "error": {
    "code": "delivery_unconfirmed",
    "message": "We could not confirm that the receiver's access point got the document. Check with the receiver before you send it again."
  },
  "ap_message_id": null,
  "changed_at": "2026-11-05T11:02:10+01:00",
  "event_id": "e5a30b96-1c48-4d7f-8a2e-9b6f0d3c7a14",
  "occurred_at": "2026-11-05T11:02:10+01:00"
}

Sender registration status changed

The payload is described under Participants.

Retries

A delivery is a success when your endpoint answers 2xx. Any other answer is a failed attempt: a 4xx or 5xx status, a redirect, a timeout, a connection error, or an address we refuse. A 410 is not special. We wait 5 seconds to connect and 30 seconds in total for the answer.

We make 8 attempts over about 45 hours:

Attempt When
1 At once, when the event happens.
2 1 minute after attempt 1 failed.
3 5 minutes after attempt 2 failed.
4 30 minutes after attempt 3 failed.
5 2 hours after attempt 4 failed.
6 6 hours after attempt 5 failed.
7 12 hours after attempt 6 failed.
8 24 hours after attempt 7 failed.

We look for due retries every minute, so an attempt can start up to a minute after its time. After attempt 8 fails, the delivery is marked failed and we are alerted. We do not send it again by ourselves. The endpoint stays enabled, and your next events are delivered as usual. If you disable or delete an endpoint, its pending deliveries stop at their next attempt.

What your handler must do

  • Verify the signature first. See Webhooks.
  • Answer with a 2xx quickly. Store the event, answer, then do the work.
  • Deduplicate on event_id. A retry sends the same event again, for example when your answer was lost.
  • Events can arrive more than once and out of order. The resource is the source of truth: read GET /documents/{id}, GET /participants/{registration_reference} or GET /inbox/{id} to see the current state.
  • Ignore event types you do not handle; new types may be added. Answer 2xx to them: a 4xx or 5xx answer is a failed attempt, and we retry it for about 45 hours.
  • Do not rely on webhooks alone. Reconcile from the API: see Inbox reconciliation.

Sender Registration Status

View your registered senders and their registration status using GET /api/v1/senders. This endpoint returns the senders associated with your account for the selected environment.

GET /api/v1/participants supersedes it: it filters, paginates and returns the registration error. See Participants. GET /api/v1/senders stays available and its response does not change.

Request

GET /api/v1/senders
Authorization: Bearer SBG_your_api_token_here

# For sandbox (simulated lifecycle):
GET /api-sandbox/v1/senders
Authorization: Bearer SBG_your_api_token_here

Response

HTTP/1.1 200 OK
Content-Type: application/json

{
  "data": [
    {
      "registration_reference": "reg_550e8400-e29b-41d4-a716-446655440000",
      "participant_id": "0208:0123456749",
      "environment": "production",
      "status": "active"
    },
    {
      "registration_reference": "reg_660e8400-e29b-41d4-a716-446655440001",
      "participant_id": "0208:0987654394",
      "environment": "production",
      "status": "revoked"
    }
  ]
}

Sender Status Values

Status Description
pending The sender is being published. It cannot send yet.
active The sender is published and can send and receive Peppol documents.
rejected Publication failed. GET /api/v1/participants shows the reason in error. Register the participant again to retry.
revoked The sender was deregistered. It can no longer send documents, and in production it is removed from the SMP and the SML.

Webhook Event: Sender Registration Status Changed

When a sender's registration status changes, you receive a webhook event if you have configured a webhook endpoint. The event type is peppol.sender_registration.status_changed. Its payload, with the error field, is described under Participants.

Rate limits

Every endpoint has a per-customer limit, except GET /participants and GET /participants/{registration_reference}. Limits count per customer and per environment. The sandbox token and the production token of one customer have separate budgets, and two customers behind one IP address never share a per-customer budget. Endpoints in the same row share one budget.

Endpoint Limit
POST /documents and, in the sandbox, POST /inbox 120 requests per minute, shared by the two
GET /documents, GET /documents/{id}, GET /inbox, GET /inbox/{id}, GET /senders and GET /usage/current 600 requests per minute, shared by all of them
POST /inbox/{id}/ack 600 requests per minute, its own budget
POST /validate 30 requests per minute
GET /lookup 120 requests per minute
POST /participants and DELETE /participants/{registration_reference} 10 requests per minute, shared by the two
GET /participants and GET /participants/{registration_reference} no per-customer limit

Every endpoint above also counts toward a limit of 300 requests per minute per IP address. We apply it before authentication, so it also stops requests with an invalid token. Customers behind one IP address share it. It comes first: a request over it is refused before your per-customer budget is looked at.

When you hit a limit

The answer is 429 with a Retry-After header. Wait that many seconds, then retry. The per-customer limits answer with a body that also says how long to wait:

HTTP/1.1 429 Too Many Requests
Retry-After: 42
Content-Type: application/json

{
  "message": "Too many requests. Retry in 42 seconds.",
  "retry_after_seconds": 42
}

The per-IP limit answers 429 with {"message": "Too Many Attempts."} and the same Retry-After header.

Usage Tracking

Monitor your API usage for the current billing period:

GET /api/v1/usage/current
Authorization: Bearer SBG_your_api_token_here

Response:
{
  "period_start": "2025-11-01",
  "period_end": "2025-11-30",
  "outbound_count": 123,
  "inbound_count": 45,
  "outbound_bytes": 1234567,
  "inbound_bytes": 7654321
}

Service Status

The current health of the gateway and of the Peppol network is published at https://peppol-api.simplebill.pro/status, with a machine-readable version at GET /status/api/v2/status.json. Use it to drive your own dashboards, or to decide whether a failed request is worth retrying.

No authentication. This is the only endpoint that takes no API token. Do not send an Authorization header, and do not treat it as account-specific: it reports platform-wide status, identical for every caller.

Request

GET /status/api/v2/status.json

Response

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, max-age=60

{
  "page": {
    "id": "simplebill-peppol",
    "name": "SimplePeppol Status",
    "url": "https://peppol-api.simplebill.pro/status",
    "updated_at": "2026-08-18T11:42:04+02:00"
  },
  "status": {
    "indicator": "none",
    "description": "All systems operational"
  }
}

Status Indicators

Branch on status.indicator, not on status.description — the description is human-facing wording and may change.

Indicator Meaning
none Everything is operational. Normal processing.
minor Degraded performance or a partial outage. Requests generally succeed but may be slower, and delivery can lag. Keep sending; back off on retries.
major A major outage affecting at least one component. Queue your documents and retry later rather than looping on failures.
maintenance Planned maintenance is in progress. Announced in advance on the status page.

Polling

Our probes run every two minutes and the response is cached for 60 seconds, so polling more than once a minute returns the same payload and gains you nothing. Once a minute is ample.

page.updated_at is when the underlying checks last ran, and is null before the first check of a new deployment. If it is null or well in the past, treat the status as unknown rather than healthy: it means monitoring itself is not reporting.

Example

curl -s https://peppol-api.simplebill.pro/status/api/v2/status.json

# Gate a batch on platform health before sending
indicator=$(curl -s https://peppol-api.simplebill.pro/status/api/v2/status.json \
  | python3 -c "import json,sys; print(json.load(sys.stdin)['status']['indicator'])")

if [ "$indicator" = "major" ]; then
  echo "SimplePeppol reports a major outage - holding the batch"
  exit 1
fi

Monitoring Tools

The payload follows the Statuspage.io status.json convention, so most uptime dashboards and status aggregators can consume it as-is without a custom parser.

Worth knowing: this endpoint is served by the gateway itself, so a total gateway outage takes the status endpoint down with it. A request that times out is itself meaningful — do not read "no response" as healthy. If you need certainty independent of us, point an external uptime monitor at this URL.

Error Handling

The API uses standard HTTP status codes:

  • 200 - Success
  • 202 - Accepted (a participant registration was accepted and is being published)
  • 400 - Bad Request (invalid parameters)
  • 401 - Unauthorized (invalid or missing token)
  • 403 - Forbidden (insufficient permissions, or a token for the other environment)
  • 404 - Not Found
  • 405 - Method Not Allowed (the path exists, the method does not: for example POST /api/v1/inbox, because inbox simulation exists in the sandbox only)
  • 409 - Conflict (the participant is registered elsewhere or by another account)
  • 413 - Payload Too Large (the request body is over 16 MB)
  • 422 - Validation Error
  • 429 - Too Many Requests (rate limit exceeded, see Rate limits)
  • 500 - Internal Server Error
  • 502 - Bad Gateway (the receiver's SMP did not answer a lookup)
  • 503 - Service Unavailable (registration, lookup or validation is unavailable, retry later)

A payload_xml over 10 MB gets 422. A request body over 16 MB gets 413 with {"message": "The POST data is too large."}, before any other check. Above 20 MB our proxy refuses the request before it reaches the API, and that 413 has no JSON body.

Error responses include a JSON body with error details:

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json

{
  "message": "The given data was invalid.",
  "errors": {
    "sender_peppol_id": ["The sender peppol id field is required."]
  }
}

The 409, 502 and 503 answers of /participants and /lookup also carry an error_code. The 429 answers carry a Retry-After header. So do the 502 and 503 answers of /lookup, the 503 answer of /validate and the 503 answer of POST /documents when the validator is unavailable. A 503 registration_unavailable does not: retry it later. The codes are listed under Participants, Receiver lookup and Validation. Before you retry a 502 or 503 in a loop, check the service status.

A 405 answer has an Allow header with the methods the route accepts, and a message, for example "The POST method is not supported for route api/v1/inbox. Supported methods: GET, HEAD."

These are errors of the request. Why a document you sent failed is not an HTTP error: the document gets the status failed and an error object with one of five codes. See Outbound status.

Code Examples

cURL

curl -X POST https://peppol-api.simplebill.pro/api/v1/documents \
  -H "Authorization: Bearer SBG_your_api_token_here" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: invoice-1234" \
  -d '{
    "sender_peppol_id": "0208:0123456749",
    "receiver_peppol_id": "0208:0987654394",
    "document_type_id": "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",
    "process_id": "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0",
    "payload_format": "ubl",
    "payload_xml": "<Invoice>...</Invoice>",
    "client_reference": "invoice-1234"
  }'

PHP (Guzzle)

$client = new \GuzzleHttp\Client();

$response = $client->post('https://peppol-api.simplebill.pro/api/v1/documents', [
    'headers' => [
        'Authorization' => 'Bearer SBG_your_api_token_here',
        'Content-Type' => 'application/json',
        'Idempotency-Key' => 'invoice-1234',
    ],
    'json' => [
        'sender_peppol_id' => '0208:0123456749',
        'receiver_peppol_id' => '0208:0987654394',
        'document_type_id' => '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',
        'process_id' => 'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0',
        'payload_format' => 'ubl',
        'payload_xml' => $ublXml,
        'client_reference' => 'invoice-1234',
    ],
]);

$result = json_decode($response->getBody(), true);

Python (requests)

import requests

response = requests.post(
    'https://peppol-api.simplebill.pro/api/v1/documents',
    headers={
        'Authorization': 'Bearer SBG_your_api_token_here',
        'Content-Type': 'application/json',
        'Idempotency-Key': 'invoice-1234',
    },
    json={
        'sender_peppol_id': '0208:0123456749',
        'receiver_peppol_id': '0208:0987654394',
        'document_type_id': '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',
        'process_id': 'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0',
        'payload_format': 'ubl',
        'payload_xml': ubl_xml,
        'client_reference': 'invoice-1234',
    }
)

result = response.json()

Need help?

For support, questions, or to request API access, write to us.

Contact support