eFakturuj / API Docs
eFakturuj Guides

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:

FieldNotes
invoice_numberOptional. Omit to let eFakturuj reserve the next number from your workspace's pattern (preview via GET /companies/{company_id}/invoices/numbering/next).
invoice_typeUN/CEFACT 1001 code. 380 = commercial invoice (default), 381 = credit note, 325 = proforma.
currency_codeISO 4217. EUR for domestic Slovak invoices. Must match the IBAN currency.
payment_means_codeUN/CEFACT 4461. 30 = SEPA credit transfer (default), 42 = bank account, 48 = card.
variable_symbolSlovak banking convention — the numeric reference printed on bank statements. Conventionally the invoice number stripped to digits.
delivery_dateOptional. 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_codeUN/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_rateSlovak rates as of 2025: 0, 5 (food / medicines), 19 (intermediate), 23 (standard).
lines[].vat_category_codeUN/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>;

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:

  1. invoice.send_peppol — generates UBL XML, stores it in MinIO, sends it via AS4 to the buyer's Access Point. On success transitions to sent_peppol and stamps peppol_message_id.
  2. invoice.send_fs_copy — submits the same UBL to the Slovak Tax Authority (Finančná správa) C5 corner. On success transitions to sent_fs and stamps fs_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:

ruleWhereExtra fields
vat_rate_capamounts[i].gross above the rate's left_grossleft_base, left_gross
vat_rate_caplines: the picked rows' returned base or VAT above their own bucket's leftleft_base, left_vat, left_gross
vat_rate_not_on_invoiceamounts[i] names a rate/category/exemption-code the invoice does not havenone
vat_rate_repeateda second amounts[] entry resolves to a bucket an earlier entry in this request already usednone
row_caplines[i].quantity above that row's own left_quantityleft_quantity
row_not_on_invoicelines[i].source_line_id is not a row of this invoicenone
row_repeateda second lines[] entry names a row an earlier entry in this request already returnsnone

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. PUT on it returns 409 with error: "locked_credit_note". A storno cannot be edited at all, and neither can a partial_credit, price_correction or returned_goods note, 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}/void still 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) and credit_notes; a credit note returns credited_invoice and settlement. A fully credited invoice has payment_status: "credited".
  • A refund is a payment recorded on the credit note (POST /payments with the credit note's id).
  • A credit note that has not been issued yet (draft, queued, failed, rejected) owes nothing: its amount_due and its payment summary's remaining are 0.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 (status delivered) and you hand the buyer its PDF.
  • A received invoice, a proforma, or a quotation cannot be credited: the endpoint returns 409 with error: "not_creditable".

Common pitfalls

  • vat_rate and vat_category_code must be consistent. A 0% rate with vat_category_code=S is rejected — pair 0 with Z, E, AE, or K depending 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), dic is the 10-digit tax ID, ico is 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_id directly or run a participant lookup first (POST /tools/peppol-check).
  • Currency mismatch. currency_code must 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