Sending invoices
The end-to-end recipe for getting an invoice from your system, through eFakturuj, onto the Peppol network, and into the Slovak Tax Authority's inbox — and then knowing when it arrived. If you read one guide, read this one.
1. Look up or create the customer
Invoices embed a snapshot of the buyer (name, VAT ID, address, Peppol
ID) so your customer directory is optional — you can ship the buyer
block inline on every POST /companies/{company_id}/invoices. Most integrators
still use the directory because it (a) deduplicates buyer details across
invoices, (b) lets you cache the discovered Peppol participant id, and (c) lets
the dashboard show "X invoices from this customer".
Set COMPANY_ID to the UUID of the company you are acting for.
Look up first, create only if missing:
# Search by VAT id, IČO, or name fragment
curl -G https://api.efakturuj.sk/api/v1/companies/$COMPANY_ID/customers/search \
-H 'X-Api-Key: efk_...' \
--data-urlencode 'q=Acme'
# Returns up to 10 slim matches; pick the right id, or POST a new one.
curl -X POST https://api.efakturuj.sk/api/v1/companies/$COMPANY_ID/customers \
-H 'X-Api-Key: efk_...' \
-H 'Content-Type: application/json' \
-d '{"name": "Acme s.r.o.", "vat_id": "SK1234567890", "ico": "12345678"}'
A 409 Conflict on POST /companies/{company_id}/customers means a customer
with that VAT id already exists in your organisation — search and reuse.
2. Create a draft invoice
POST /companies/{company_id}/invoices accepts the full document in one call.
Key fields and their Slovak / Peppol semantics:
| Field | Notes |
|---|---|
invoice_number | Optional. Omit to let eFakturuj reserve the next number from your workspace's pattern (preview via GET /companies/{company_id}/invoices/numbering/next). |
invoice_type | UN/CEFACT 1001 code. 380 = commercial invoice (default), 381 = credit note, 325 = proforma. |
currency_code | ISO 4217. EUR for domestic Slovak invoices. Must match the IBAN currency. |
payment_means_code | UN/CEFACT 4461. 30 = SEPA credit transfer (default), 42 = bank account, 48 = card. |
variable_symbol | Slovak banking convention — the numeric reference printed on bank statements. Conventionally the invoice number stripped to digits. |
delivery_date | Optional. The Slovak dátum dodania (§ 74 zákona o DPH): the day the goods or services were supplied. Omit it when it equals issue_date. Rendered as cbc:TaxPointDate (BT-7) and cac:Delivery/cbc:ActualDeliveryDate (BT-72). |
lines[] | At least one line. Peppol BIS Billing 3.0 imposes a soft cap of 200 lines per document — large invoices may be rejected by downstream Access Points. |
lines[].unit_code | UN/ECE Recommendation 20 unit of measure. Common values: EA / C62 / H87 (piece, ks), HUR (hour), DAY (day), E49 (working day, MD), MON (month), KGM (kg), MTR (m), LTR (l). Defaults to C62. |
lines[].vat_rate | Slovak rates as of 2025: 0, 5 (food / medicines), 19 (intermediate), 23 (standard). |
lines[].vat_category_code | UN/CEFACT 5305. S (standard) is the default. A 0% rate must be paired with a non-S category — Z (zero rated), E (exempt), AE (reverse charge), or K (intra-community supply). The validator rejects vat_rate=0 + vat_category_code=S. |
curl -X POST https://api.efakturuj.sk/api/v1/companies/$COMPANY_ID/invoices \
-H 'X-Api-Key: efk_...' \
-H 'Content-Type: application/json' \
-d '{
"issue_date": "2026-05-06",
"due_date": "2026-06-05",
"delivery_date": "2026-05-04",
"currency_code": "EUR",
"supplier": {
"name": "Vendere s.r.o.",
"vat_id": "SK2020000001",
"ico": "47000001",
"street": "Hlavná 12",
"city": "Bratislava",
"postal_code": "811 01",
"country_code": "SK",
"peppol_id": "0245:2020000001"
},
"supplier_iban": "SK0900000000123456789012",
"buyer": {
"name": "Acme s.r.o.",
"vat_id": "SK1234567890",
"ico": "12345678",
"street": "Račianska 88",
"city": "Bratislava",
"postal_code": "831 02",
"country_code": "SK",
"peppol_id": "0245:1234567890"
},
"variable_symbol": "2026001",
"payment_means_code": "30",
"lines": [
{
"line_number": 1,
"item_name": "Consulting — May 2026",
"quantity": "10",
"unit_code": "HUR",
"unit_price": "100.00",
"vat_rate": "23.00",
"vat_category_code": "S"
}
]
}'
The response is the freshly-created InvoiceResponse (status 201) —
the server has assigned a UUID, computed total_net / total_vat /
total_gross, the per-rate vat_breakdown, and the Slovak-specific
zzz_correction_amount (the document-level UBL AllowanceCharge
with reason code ZZZ that reconciles Slovakia's
reverse-from-VAT-inclusive total with Peppol's forward-from-net
calculation):
{
"id": "8f5a1c20-1f6e-4f6c-9f5e-2f5a1c201f6e",
"invoice_number": "2026-001",
"status": "draft",
"total_net": "1000.00",
"total_vat": "230.00",
"total_gross": "1230.00",
"zzz_correction_amount": "0.00",
"lines": [{ "id": "...", "line_number": 1, "line_gross_amount": "1230.00" }]
}
Dart equivalent:
final res = await http.post(
Uri.parse('https://api.efakturuj.sk/api/v1/companies/$companyId/invoices'),
headers: {
'X-Api-Key': apiKey,
'Content-Type': 'application/json',
},
body: jsonEncode(invoice),
);
if (res.statusCode != 201) throw ApiError.from(res);
final created = jsonDecode(res.body) as Map<String, dynamic>;
3. Validate the UBL (recommended during integration)
POST /companies/{company_id}/invoices/{id}/validate runs the full pipeline
against a stored invoice — Python business-rule checks plus, when the schema
files are installed, the UBL XSD, the Peppol BIS Billing 3.0 Schematron (both
the EN 16931 base and the Peppol overlay), and the Slovak v1.3 Schematron. If
everything passes and the invoice is still in DRAFT, it is auto-promoted to
validated.
curl -X POST https://api.efakturuj.sk/api/v1/companies/$COMPANY_ID/invoices/$ID/validate \
-H 'X-Api-Key: efk_...'
{
"valid": true,
"errors": [],
"warnings": [],
"xml_validation": {
"xsd_valid": true,
"peppol_valid": true,
"slovak_valid": true,
"errors": [],
"warnings": []
},
"schemas_available": {
"xsd_invoice": true,
"schematron_peppol": true,
"schematron_slovak": true
},
"status": "validated"
}
Always validate during development; POST /companies/{company_id}/invoices/{id}/send
will re-run the business-rule check anyway and refuse to queue an invalid
invoice (returning 422 with the same error list). In steady state you can skip
the standalone validate call.
4. Send via Peppol
POST /companies/{company_id}/invoices/{id}/send returns 202 Accepted —
delivery is asynchronous. The route flips the invoice to queued, writes a
sent_peppol audit log entry with PENDING result, and dispatches two Celery
tasks onto the critical queue:
invoice.send_peppol— generates UBL XML, stores it in MinIO, sends it via AS4 to the buyer's Access Point. On success transitions tosent_peppoland stampspeppol_message_id.invoice.send_fs_copy— submits the same UBL to the Slovak Tax Authority (Finančná správa) C5 corner. On success transitions tosent_fsand stampsfs_submission_id.
curl -X POST https://api.efakturuj.sk/api/v1/companies/$COMPANY_ID/invoices/$ID/send \
-H 'X-Api-Key: efk_...'
{
"invoice_id": "8f5a1c20-1f6e-4f6c-9f5e-2f5a1c201f6e",
"status": "queued",
"message": "Invoice queued for delivery"
}
Only invoices in draft, validated, or approved status may be
sent — others return 409 with error: "invalid_status". A failing
business-rule check returns 422 with the offending rules.
5. Track delivery
Both options are supported; webhooks are recommended for production.
Webhook (preferred)
Subscribe once to invoice.delivered, invoice.rejected,
invoice.fs_acknowledged and invoice.fs_rejected (the default list; see the
Webhooks guide). invoice.delivered and invoice.rejected
fire when the receiver's Access Point reports the AS4 delivery status;
invoice.fs_acknowledged and invoice.fs_rejected fire when Finančná správa's
C5 returns its MLS verdict on the tax-data document, typically minutes after
the send. Your handler receives the invoice_id; fetch the full invoice via
GET /companies/{company_id}/invoices/{id} if you need the updated status /
message id, and read c5 on that response for the filing detail.
Polling
Poll GET /companies/{company_id}/invoices/{id} and watch status. The full
lifecycle enum (from app/models/invoice.py) is:
draft → validated → approved → queued → sent_peppol → delivered
↘ sent_fs → fs_acknowledged
↘ failed | rejected | cancelled | voided
Polling cadence: 30 seconds is plenty — most deliveries settle inside
3 seconds, AS4 retries can take a few minutes. Stop polling once
status reaches delivered, fs_acknowledged, failed, or
rejected.
Credit notes
A delivered invoice is never edited: it is corrected by a credit note (type 381), which gets
its own number from the credit-note series (default DOB-{year}-{seq}).
POST /companies/{company_id}/invoices/{invoice_id}/credit-notes creates the credit note and, in
the same request, delivers it on the invoice's own rail (send If-Match with the invoice's
version). It returns the new credit note, not the original invoice: 202 when it is queued for
Peppol + FS delivery, 201 when it is issued or delivered at once, on the private PDF + e-mail
rail, or because the invoice was finished by hand (mark-sent), or because its buyer has no
electronic address.
201 also covers an accountant whose company has an admin or approver: the credit note is
created with status pending_approval and nothing is delivered until an approver sends it, the
same rule as invoices. A failed check (422) creates nothing. Creating a credit note changes the
invoice's version, so repeating the same request with the old If-Match answers 412 (reload
the invoice first).
The four reasons
reason picks one of four request shapes. note is optional on all four and is appended to the
credit note's note (printed on the PDF, sent in the UBL). Only full_reversal cancels the
invoice; the other three use up its allowance instead (below), so you can issue several credit
notes against the same invoice, in any order, as long as each stays within what is left.
full_reversal: the storno. The whole invoice, copied row for row; once the invoice was
already partly credited, exactly what is left of it instead, one row per VAT bucket. Cancels the
invoice (voided) once issued.
{ "reason": "full_reversal", "note": "Objednávka zrušená zákazníkom" }
partial_credit / price_correction: a discount, a reduced total, or a price fix: one
row per VAT rate named in amounts. Same request shape for both; only the row text and the
stored correction reason differ.
{
"reason": "partial_credit",
"amounts": [{ "vat_rate": "23.00", "vat_category_code": "S", "gross": "87.50" }]
}
returned_goods: the picked invoice rows, at their own price and VAT rate, for the
quantity returned.
{
"reason": "returned_goods",
"lines": [{ "source_line_id": "8f5a1c20-1f6e-4f6c-9f5e-2f5a1c201f6e", "quantity": "2" }]
}
Amounts: VAT-inclusive, split base = gross / (1 + rate)
amounts[].gross is what you want to credit at that rate, VAT included, not the net base.
eFakturuj splits it into base = round(gross / (1 + rate), 2) (half up) and vat = gross - base,
so the two always add back to exactly the amount you typed, to the cent. On the invoice from
step 2 (1 000,00 net + 230,00 VAT at 23 % = 1 230,00 gross),
crediting {"vat_rate": "23.00", "gross": "87.50"} gives base = 87.50 / 1.23 = 71.14 and
vat = 87.50 - 71.14 = 16.36.
When the amount you send equals exactly what is left at that rate, eFakturuj credits the rate's own remaining base and VAT instead of re-splitting the gross, so a mixed-rate invoice's last credit note cannot miss the rate's totals by a cent through independent rounding. Any smaller amount takes the reverse split above, nudged by at most a cent if it would otherwise push the base or the VAT past what is left.
vat_category_code defaults to S. vat_exemption_reason_code may be omitted when the invoice
has one VAT subtotal at that vat_category_code + vat_rate, or when that subtotal itself
carries no exemption code; with two subtotals at the same rate under different exemption codes,
name the one GET .../credit-notes/allowance lists for the one you mean, or the entry is refused
(vat_rate_not_on_invoice).
What is left to credit: GET .../credit-notes/allowance
Before building the request, GET /companies/{company_id}/invoices/{invoice_id}/credit-notes/allowance
shows what is still available, per VAT bucket (category + rate + exemption code) and per invoice
row. It counts every credit note that is issued or on its way (queued); a draft, a failed or a
rejected one does not count yet.
{
"invoice_id": "8f5a1c20-1f6e-4f6c-9f5e-2f5a1c201f6e",
"currency_code": "EUR",
"creditable": true,
"refusal": null,
"total_gross": "1230.00",
"credited_gross": "87.50",
"left_gross": "1142.50",
"buckets": [
{
"vat_rate": "23.00",
"vat_category_code": "S",
"vat_exemption_reason_code": null,
"base": "1000.00",
"vat": "230.00",
"gross": "1230.00",
"credited_base": "71.14",
"credited_gross": "87.50",
"left_base": "928.86",
"left_vat": "213.64",
"left_gross": "1142.50"
}
],
"lines": [
{
"id": "8f5a1c20-1f6e-4f6c-9f5e-2f5a1c201f6e",
"line_number": 1,
"item_name": "Consulting (May 2026)",
"unit_code": "HUR",
"unit_price": "100.00",
"quantity": "10",
"vat_rate": "23.00",
"vat_category_code": "S",
"vat_exemption_reason_code": null,
"discount_percentage": null,
"discount_amount": null,
"line_net": "1000.00",
"line_vat": "230.00",
"line_gross": "1230.00",
"returned_quantity": "0",
"left_quantity": "10",
"left_net": "1000.00",
"left_vat": "230.00"
}
]
}
left_net / left_vat on a line only shrink when a returned_goods credit note names that row.
A partial_credit or price_correction credit note never touches a row's own allowance, only
its bucket's. When creditable is false, refusal says why (nothing left, a live draft or a
queued storno blocking, the invoice's own status) and buckets / lines still carry the real
numbers.
creditable answers for every reason at once: a storno (full_reversal) is additionally
refused with 409 while another credit note of the invoice is being delivered (queued), which
this allowance does not report, since a partial_credit, price_correction or returned_goods
request is not refused by that same condition.
The caps, and the 422 credit_limit_exceeded body
Every error on this API, this one included, sits inside the response body's detail object:
{
"detail": {
"error": "credit_limit_exceeded",
"message": "The credit note credits more than is left on the invoice.",
"details": [
{
"rule": "vat_rate_cap",
"field": "amounts[0].gross",
"vat_rate": "23.00",
"vat_category_code": "S",
"vat_exemption_reason_code": null,
"left_base": "928.86",
"left_gross": "1142.50"
}
]
}
}
details names every broken rule, one entry per amounts[] or lines[] index:
rule | Where | Extra fields |
|---|---|---|
vat_rate_cap | amounts[i].gross above the rate's left_gross | left_base, left_gross |
vat_rate_cap | lines: the picked rows' returned base or VAT above their own bucket's left | left_base, left_vat, left_gross |
vat_rate_not_on_invoice | amounts[i] names a rate/category/exemption-code the invoice does not have | none |
vat_rate_repeated | a second amounts[] entry resolves to a bucket an earlier entry in this request already used | none |
row_cap | lines[i].quantity above that row's own left_quantity | left_quantity |
row_not_on_invoice | lines[i].source_line_id is not a row of this invoice | none |
row_repeated | a second lines[] entry names a row an earlier entry in this request already returns | none |
A return is capped twice: its own row (row_cap, on quantity) and its VAT bucket (vat_rate_cap,
on base and VAT). An earlier partial_credit can use up a bucket's VAT without touching any
row's own allowance, so a return can still be refused even while its row still has quantity left.
A storno after partial credit notes reverses only the rest
full_reversal on an invoice that already carries issued or queued credit notes builds one row
per VAT bucket for exactly what is left (the bucket's own left_base / left_vat, not
recomputed), so the invoice's credit notes always add up to its total to the cent. It is refused
(409 not_creditable) while another credit note of the same invoice is still queued: a storno
needs to know exactly what is left, and a queued one may yet fail or succeed. An issued or queued
partial_credit, price_correction or returned_goods never blocks another credit note this
way; only the allowance caps it.
Other facts
- A credit note made this way is locked once created.
PUTon it returns409witherror: "locked_credit_note". A storno cannot be edited at all, and neither can apartial_credit,price_correctionorreturned_goodsnote, because its rows were checked against the invoice's allowance at the moment it was created. Delete the draft and create the credit note again instead. - The older
POST .../{invoice_id}/voidstill creates a full-reversal draft credit note that you send yourself. - Every issued credit note reduces its invoice: single-invoice reads return
credited_amount,amount_due(negative when a refund is due) andcredit_notes; a credit note returnscredited_invoiceandsettlement. A fully credited invoice haspayment_status: "credited". - A refund is a payment recorded on the credit note (
POST /paymentswith the credit note's id). - A credit note that has not been issued yet (draft, queued, failed, rejected) owes nothing:
its
amount_dueand its payment summary'sremainingare0.00. - When the invoice was finished by hand (
mark-sent) or its buyer has no electronic address, the credit note is issued the same way in this request (statusdelivered) and you hand the buyer its PDF. - A received invoice, a proforma, or a quotation cannot be credited: the endpoint returns
409witherror: "not_creditable".
Common pitfalls
vat_rateandvat_category_codemust be consistent. A 0% rate withvat_category_code=Sis rejected — pair0withZ,E,AE, orKdepending on why VAT is zero.- Three Slovak business identifiers, three different fields.
vat_id(IČ DPH) is the SK-prefixed VAT number (SK+ 10 digits),dicis the 10-digit tax ID,icois the 8-digit business register number. They go into different columns; mixing them up is the most common Schematron failure. - Missing buyer Peppol ID. You can create an invoice for a buyer
without one, but the send call will fail at the AS4 layer because
there is nothing to look up in the SMP. Either supply
buyer.peppol_iddirectly or run a participant lookup first (POST /tools/peppol-check). - Currency mismatch.
currency_codemust match the IBAN currency. A EUR invoice with a USD account triggers a Slovak Schematron failure during validation. - More than 200 lines. Peppol BIS Billing 3.0 caps documents at 200 lines. Receivers may accept more; many do not. Split larger invoices.
See also
- Webhooks — preferred path for tracking delivery.
- Errors — handling 422 / 402 / 429 / 502 responses.
- Receiving invoices — the inbound counterpart.
- API reference → Invoices — endpoint schemas.
- Peppol BIS Billing 3.0 specification — the canonical UBL profile we generate against.
- UBL 2.1 schema — underlying OASIS standard.
- Slovak Finančná správa e-invoicing portal — Slovak v1.3 rules + 2027 mandate documentation.