Cookbook: send & receive with webhooks
This is a start-to-finish recipe for the eFakturuj test environment: create an API key, register webhooks, then create → validate → send an invoice from one company to another over Peppol — and watch the webhooks fire on both the sender and the receiver.
Everything here runs against the sandbox. No document reaches the real Peppol network, and nothing is billed.
| Setting | Value |
|---|---|
| Base URL | https://api.sandbox.efakturuj.sk/api/v1 |
| Keys | Use sandbox-created efk_… API keys |
| Money | Decimal strings ("85.00"), never floats |
| Dates | ISO 8601 YYYY-MM-DD |
See also: Authentication · Webhooks · Sending invoices · Receiving invoices.
What you'll build
register webhook register webhook
(invoice.delivered) (invoice.received)
| |
+-----+-----+ +-----+-----+
| SENDER | -- send -----> | RECEIVER |
| company | over Peppol | company |
+-----------+ +-----------+
POST /companies/{company_id}/invoices -> validate -> send
inbound invoice appears
webhook: invoice.received
You need two companies in your test workspace — one acts as the sender, one as
the receiver. Both must have a peppol_id so they are routable.
Step 0 — Authenticate
Two credentials are in play:
- Session token (JWT) — from signing in to the dashboard. Required to mint
API keys (needs the
adminrole) and to list your companies. - API key (
efk_…) — created in Step 1, used for all invoice calls, sent as theX-API-Keyheader.
Throughout, $JWT is your session bearer token and $KEY is the API key.
Step 1 — Create an API key
POST /api-keys (requires the admin role — this is an authenticated dashboard /
JWT action, not something an API key can do). The plaintext key is returned
once — store it now; only its prefix is retrievable later.
curl -sS -X POST https://api.sandbox.efakturuj.sk/api/v1/api-keys \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{
"name": "Test send cookbook",
"scopes": ["invoices:read", "invoices:write", "invoices:send"]
}'
{
"id": "…",
"name": "Test send cookbook",
"key_prefix": "efk_a1b2c3d4",
"scopes": ["invoices:read", "invoices:write", "invoices:send"],
"is_active": true,
"api_key": "efk_a1b2c3d4e5f6…"
}
The api_key field is shown only once — copy it now. Valid scopes: invoices:read,
invoices:write, invoices:send, validate:only.
export KEY="efk_a1b2c3d4e5f6…"
A company-scoped key is bound to the company you were acting as when you created it. Create one for the sender company (the invoice calls in Step 4 run as the sender).
Step 2 — Register webhooks (both sides)
POST /connect/webhooks. The signing secret is returned once. Register one
endpoint on the sender company and one on the receiver company so you see
both directions. (Alternatively register a single workspace-scoped webhook via
POST /workspaces/{workspace_id}/webhooks — it fires for every company in the
workspace.)
curl -sS -X POST https://api.sandbox.efakturuj.sk/api/v1/connect/webhooks \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-endpoint.example.com/hooks",
"events": "invoice.delivered,invoice.fs_acknowledged,invoice.rejected"
}'
{
"id": "…",
"url": "https://your-endpoint.example.com/hooks",
"events": "invoice.delivered,invoice.fs_acknowledged,invoice.rejected",
"is_active": true,
"secret": "whsec_…"
}
Repeat while acting as the receiver company, subscribing to the inbound event:
{ "url": "https://your-endpoint.example.com/hooks", "events": "invoice.received" }
Event types:
| Event | Fires for | When |
|---|---|---|
invoice.delivered | sender | the invoice is delivered to the recipient's access point |
invoice.fs_acknowledged | sender | the tax-authority (FS) acknowledgement is recorded |
invoice.rejected | sender | delivery or validation was rejected |
invoice.received | receiver | an inbound invoice was received for the company |
Verifying the signature
Each delivery carries these headers:
| Header | Meaning |
|---|---|
efk-signature | Recommended v2 signature: t=<unix>,v1=<hex HMAC> |
X-Webhook-Signature | Legacy HMAC-SHA256 signature retained for one deprecation cycle |
X-Webhook-Event | the event type (e.g. invoice.received) |
X-Webhook-Delivery | unique id for this delivery attempt |
Verify efk-signature against the raw body bytes. The signed string is
{timestamp}.{raw_body} and the HMAC key is the plaintext whsec_ secret you
saved when creating or rotating the webhook:
import hashlib, hmac, time
def valid(raw_body: bytes, efk_signature: str, whsec_secret: str) -> bool:
parts = dict(item.split("=", 1) for item in efk_signature.split(","))
ts = int(parts["t"])
if abs(int(time.time()) - ts) > 300:
return False
expected = hmac.new(
whsec_secret.encode(),
f"{ts}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
Smoke-test the endpoint
Fire a test delivery without an invoice, then inspect attempts:
curl -sS -X POST https://api.sandbox.efakturuj.sk/api/v1/connect/webhooks/$WEBHOOK_ID/test \
-H "X-API-Key: $KEY"
curl -sS https://api.sandbox.efakturuj.sk/api/v1/connect/webhooks/$WEBHOOK_ID/deliveries \
-H "X-API-Key: $KEY"
Tip: during development, point the webhook
urlat an ngrok tunnel (ngrok http 8899) to a tiny local server. You'll see every delivery — headers and body — in ngrok's inspector athttp://localhost:4040, and can replay them.
Step 3 — Pick the sender and receiver
List the companies you can act on ("select from the list"). Each item carries the
organisation id you use as {company_id} in Step 4.
curl -sS https://api.sandbox.efakturuj.sk/api/v1/workspaces/$WORKSPACE_ID/companies \
-H "Authorization: Bearer $JWT"
{
"data": [
{ "id": "…sender-org-id…", "name": "Modrý Vrch s.r.o.", "role": "admin" },
{ "id": "…receiver-org-id…", "name": "Riečna Dielňa s.r.o.", "role": "admin" }
]
}
export SENDER="…sender-org-id…"
export RECEIVER="…receiver-org-id…"
Both companies need a peppol_id. If a company doesn't have one yet, set it on its
profile before sending; an unroutable recipient can't receive the document.
Step 4 — Create, confirm, validate, send
All four calls run as the sender, authenticated with the API key (X-API-Key).
4a. Create (draft)
POST /companies/{SENDER}/invoices. The buyer is the receiver, and
buyer.peppol_id is what routes the document. supplier_iban is required for a
credit-transfer invoice (payment means 30).
curl -sS -X POST https://api.sandbox.efakturuj.sk/api/v1/companies/$SENDER/invoices \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{
"invoice_number": "TEST-0001",
"invoice_type": "380",
"issue_date": "2026-07-02",
"due_date": "2026-08-01",
"currency_code": "EUR",
"supplier_iban": "SK3112000000198742637541",
"supplier": {
"name": "Modrý Vrch s.r.o.", "vat_id": "SK2007233001", "ico": "90723301",
"street": "Hlavná 12", "city": "Bratislava", "postal_code": "81101",
"country_code": "SK", "peppol_id": "9925:sk2007233001"
},
"buyer": {
"name": "Riečna Dielňa s.r.o.", "vat_id": "SK2007233002", "ico": "90723302",
"street": "Štúrova 5", "city": "Košice", "postal_code": "04001",
"country_code": "SK", "peppol_id": "9925:sk2007233002"
},
"lines": [
{
"line_number": 1, "item_name": "Konzultačné služby",
"quantity": "10", "unit_code": "HUR", "unit_price": "85.00",
"vat_rate": "23.00", "vat_category_code": "S"
}
],
"payment_means_code": "30",
"variable_symbol": "20260001"
}'
Response includes the new invoice id with "status": "draft".
export INV="…invoice-id…"
4b. Confirm (review the draft)
curl -sS https://api.sandbox.efakturuj.sk/api/v1/companies/$SENDER/invoices/$INV \
-H "X-API-Key: $KEY"
4c. Validate (XSD + Peppol BIS 3.0 + Slovak rules)
curl -sS -X POST https://api.sandbox.efakturuj.sk/api/v1/companies/$SENDER/invoices/$INV/validate \
-H "X-API-Key: $KEY"
{ "valid": true, "errors": [], "xml_validation": { "xsd_valid": true, "peppol_valid": true } }
Fix any valid: false before sending — see Troubleshooting.
4d. Send over Peppol
curl -sS -X POST https://api.sandbox.efakturuj.sk/api/v1/companies/$SENDER/invoices/$INV/send \
-H "X-API-Key: $KEY"
{ "invoice_id": "…", "status": "queued", "message": "Invoice queued for delivery" }
Delivery is asynchronous. Poll the invoice until it reaches a terminal state:
curl -sS https://api.sandbox.efakturuj.sk/api/v1/companies/$SENDER/invoices/$INV \
-H "X-API-Key: $KEY" | grep -o '"status":"[^"]*"'
Step 5 — Watch both sides
Receiver side (reliable in the test environment) — within a few seconds the
receiver automatically gets an inbound invoice (this is the receive bridge, not
a manual step) and its invoice.received webhook fires:
{
"id": "evt_…",
"event": "invoice.received",
"created_at": "2026-07-02T…Z",
"data": {
"invoice_id": "…receiver-side-invoice-id…",
"source": "peppol",
"invoice_number": "TEST-0001",
"supplier_vat_id": "SK…"
}
}
Confirm the inbound document on the receiver:
curl -sS "https://api.sandbox.efakturuj.sk/api/v1/companies/$RECEIVER/invoices?page=1&page_size=20" \
-H "X-API-Key: $RECEIVER_KEY"
It shows "source": "peppol" and supplier_name = your sender.
Sender side — the sender's invoice.delivered / invoice.fs_acknowledged
webhooks fire only once a delivery-status callback confirms the AS4 hop and moves
the sender's invoice to delivered. In the current test loop the sender's own copy
may sit at sent_peppol until that callback arrives, so invoice.received on the
receiver is the signal to rely on for confirming the document moved.
Inspect delivery attempts for any webhook (status, HTTP code, retries):
curl -sS https://api.sandbox.efakturuj.sk/api/v1/connect/webhooks/$WEBHOOK_ID/deliveries \
-H "X-API-Key: $KEY"
A non-2xx endpoint is retried with exponential backoff (up to 5 attempts); each attempt is one row.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
valid:false → BR-61 | credit transfer (code 30) needs a payment account | add supplier_iban (Slovak SK + 22 digits) |
warning SK-BR-04 | variable_symbol contains non-digits | use digits only, e.g. "20260001" |
| send succeeds, receiver sees nothing | recipient not routable | ensure the buyer's peppol_id is set and the company exists in the test directory |
sender stays sent_peppol, no invoice.delivered | delivery-status callback not yet received in the test loop | rely on the receiver's invoice.received; the sender flips to delivered once delivery is confirmed |
409 invoice_number_conflict | number already used for the org/tax-year | pick a new invoice_number |
| webhook signature mismatch | body changed, wrong secret, or stale timestamp | verify efk-signature with the plaintext whsec_ secret over {timestamp}.{raw_body} (Step 2) |
403 on /api-keys | not an admin | mint keys with an admin session token |
Quick reference
| Action | Call |
|---|---|
| Create API key | POST /api-keys (JWT, admin) |
| Register webhook (company) | POST /connect/webhooks |
| Register webhook (workspace) | POST /workspaces/{workspace_id}/webhooks |
| Test a webhook | POST /connect/webhooks/{webhook_id}/test |
| Webhook deliveries | GET /connect/webhooks/{webhook_id}/deliveries |
| List companies | GET /workspaces/{workspace_id}/companies |
| Create invoice | POST /companies/{company_id}/invoices |
| Validate | POST /companies/{company_id}/invoices/{invoice_id}/validate |
| Send | POST /companies/{company_id}/invoices/{invoice_id}/send |
| Read / poll | GET /companies/{company_id}/invoices/{invoice_id} |
| List (see inbound) | GET /companies/{company_id}/invoices |