Business Gateway
For businesses

Webhooks

Get told the moment a document arrives, instead of polling for it.

A webhook is an HTTPS address of yours that we call when something happens. It is the difference between finding out about an invoice within seconds and finding out on your next scheduled check.

It works with both the API and SFTP.

Setting one up

curl -sS -X POST https://api.businessgateway.se/v1/webhooks \
  -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
  -d '{
    "endpoint": "https://example.com/hooks/peppol",
    "secret": "a long random string you generate",
    "timeoutMs": 10000
  }'

The address must be https:// and reachable from the internet — not localhost, not an address inside your own network. timeoutMs is between 1 and 30 seconds.

secret is optional. Give one of at least 16 characters, or leave it out and we generate one. Either way, the response is the only time it is shown — store it then. It is what lets you prove a call really came from us. PATCH /v1/webhooks/{id} with rotateSecret: true issues a new one, also shown once.

If you set your webhook up from a deployment script, use PUT /v1/webhooks with the same body instead. It is safe to run on every deploy: an endpoint with the same address and companyId is updated in place (200, created: false, no secret in the response — the existing one stays unless you send a new one), and otherwise one is created (201, created: true, with the secret). A changed address is a new endpoint; delete the old one.

If your credentials do not allow creating webhooks, whoever gave them to you can set this up for you.

What you receive

{
  "id": "…",
  "type": "message.inbound.received",
  "accountId": "…",
  "companyId": "…",
  "documentId": "…",
  "status": "VALID"
}
typeWhen
message.inbound.receivedA document arrived and is ready for you
message.inbound.validation_failedAn inbound document failed validation
message.outbound.as4_acceptedSomething you sent reached the recipient's provider
message.outbound.failedSomething you sent failed for good — VALIDATION_FAILED, DISCOVERY_FAILED, REJECTED or SEND_FAILED
message.mlsThe recipient's provider reported back on something you sent

status is the document's status when the event was raised. A document of a type with no rules to check arrives with "unvalidated": true as well.

The event tells you a document exists. Fetch the document itself — over the API, or let it appear in in/ if you use SFTP. Treating the webhook as a notification rather than as the data means a missed call costs you nothing.

A 2xx answer to message.inbound.received counts as taking delivery: the document is marked delivered, as if you had acknowledged it, and leaves the delivery=pending list. So store the documentId before you answer, and fetch the document from there.

message.mls

When the recipient's provider sends a recipient status about a document you sent, the event carries it:

{
  "id": "…",
  "type": "message.mls",
  "accountId": "…",
  "companyId": "…",
  "documentId": "…",
  "status": "AS4_ACCEPTED",
  "mlsEventId": "…",
  "responseCode": "RE",
  "reasons": ["BV"],
  "issues": [
    { "location": "/Invoice/cac:LegalMonetaryTotal", "reasonCode": "BV", "description": "…" }
  ]
}
FieldContents
responseCodeAP (delivered and confirmed), AB (delivered) or RE (rejected)
reasonsThe distinct reason codes among issues: SV, BV, BW or FD
issuesOne entry per point raised — location in the document, reasonCode, and description where given
mlsEventIdThe matching entry in the document's mlsEvents

status is still the document's own status — usually AS4_ACCEPTED. Each document gets at most one of these, and not every provider sends one.

Checking it really came from us

Every call carries these headers:

HeaderContents
X-Peppol-Event-IDUnique id for this event
X-Peppol-Event-TypeSame as type in the body
X-Peppol-TimestampWhen we sent it, RFC 3339 in UTC
X-Peppol-Signaturesha256= + HMAC-SHA256 of timestamp + "." + body, keyed with your secret

To verify: take the raw request body exactly as it arrived, join it to the timestamp with a ., compute HMAC-SHA256 with your secret, and compare.

Two things to get right. Use the raw bytes — if you parse the JSON and re-serialise it before hashing, the bytes differ and the signature will never match. And compare with a constant-time comparison, not ==.

Reject anything whose timestamp is far from now, so an old call cannot be replayed at you later.

Retries

If your endpoint answers with an error or does not answer within the timeout, we retry — straight away, then with growing gaps up to a day apart. After roughly 40 hours of failures the delivery is set aside and needs someone to release it, so an endpoint that is down for a long weekend is worth knowing about.

Answer quickly. Return 2xx as soon as you have recorded the event and do the real work afterwards — if you fetch and process the whole document before replying, a slow import turns into a timeout and a retry, and then you process it twice.

Be ready to receive the same event more than once. Retries and network failures both cause it. Keying on X-Peppol-Event-ID, or on the documentId, makes that harmless.

Delivery history

GET /v1/webhooks/deliveries lists what we tried to send you, newest first: each delivery's eventType, documentId, status and attempts, and lastError when it failed. Filter with status, companyId, from and to.

statusMeaning
PENDING / RETRYWaiting for its first attempt, or for the next one
DELIVEREDYour endpoint answered 2xx
DEAD_LETTERGave up after the last retry. The document itself is unaffected — fetch it over the API
CANCELLEDNo longer needed — the document was acknowledged before the event got through

It is the first place to look when you expected a call and none came.

On this page