Fiscalizing Stripe Invoices in Croatia: F2 B2B eRačuni With Stripe Invoicing and FakturoApp
Stripe sends the payment link and collects the money, FakturoApp issues, fiscalizes and delivers the eRačun, and two webhooks keep the invoice and its payment in step with Porezna uprava.
2026-11-05 · By Filip Lauc
Stripe Invoicing is a good way to bill business clients: a hosted payment page, cards and SEPA, reminders and dunning. Since Fiskalizacija 2.0 took effect on 1 January 2026, though, an invoice from one Croatian business to another has to be an eRačun. That is a structured document that is fiscalized with Porezna uprava and delivered to the buyer over the eRačun network, and the PDF Stripe generates is neither.
Our post on F1 B2C receipts covered fiscalizing card payments from consumers. This one covers the other half: billing your business clients through Stripe while FakturoApp issues, fiscalizes and delivers each invoice as an eRačun, then reports the payment once Stripe collects it.
What F2 means for a Stripe invoice
F2 is the B2B half of Fiskalizacija 2.0. Since 2026, every taxpayer in the VAT system must issue and receive eRačuni for domestic sales, and taxpayers outside the VAT system must at least be able to receive them (they start issuing in 2027). An eRačun is a UBL invoice that follows EN 16931 and the Croatian HR CIUS, with a KPD 2025 product code on every line. It goes through two separate fiscal events:
- • Issuing. The invoice is fiscalized with Porezna and delivered to the buyer's eRačun address, which is looked up by the buyer's OIB in Porezna's address register (AMS). This happens when the invoice is issued, not when it is paid.
- • Payment. When the buyer pays, the seller reports the payment (eIzvještavanje o naplati), at the latest by the 20th of the following month.
This splits the work between the two systems cleanly. Stripe is billing and collection: the hosted invoice page, payment methods, reminders and the record of what was paid. FakturoApp is the legal invoice: it numbers the bill, builds the UBL, validates it against EN 16931 and HR CIUS, fiscalizes it, delivers it over AS4 and reports the payment. It also receives the eRačuni your suppliers send you, so the same account covers both directions.
You do not need a FINA certificate for this part. FakturoApp fiscalizes eRačuni as your information intermediary, so the setup is a pair of authorizations in ePorezna rather than a certificate upload. It is free for the first six months, signing up needs no card, and paid plans start at 4,99 € a month for up to 10 fiscalized invoices.
One-time setup
- • B2B invoicing on in FakturoApp, with your OIB on the business. Start in the sandbox eRačun environment and move to production once the whole flow works. Turning B2B on registers your OIB in the AMS, so other access points can deliver to you.
- • Two steps in FiskAplikacija (ePorezna, Fiskalizacija 2.0, Administracija): authorize FakturoApp to fiscalize for you under Ovlaštenja za fiskalizaciju, and confirm its row under Adrese za zaprimanje eRačuna. Without the authorization, Porezna rejects every eRačun FakturoApp sends for you.
- • A sales location and an inventory item per product or service, each item with its VAT rate and KPD code. The KPD code is what most often makes a first eRačun fail validation. Keep each item's FakturoApp id next to your product.
- • A personal API key scoped to
write:contacts,write:invoices,read:invoicesandsend:invoices, kept on your server and sent as a bearer token. Thefakturo()helper below is the same thinfetchwrapper as in the F1 post. - • In Stripe, a tax rate for each VAT rate you charge (25% PDV, exclusive), and
a webhook endpoint subscribed to
invoice.finalized,invoice.paid,invoice.voidedandcredit_note.created.
Onboard each client in both systems
An eRačun is addressed by OIB, so the client's OIB is the one field you cannot do without. Collect
it with the legal name, address and billing email when the client signs up, then create the client
once in each system and keep both ids. In FakturoApp that is a b2b contact. In Stripe it is a customer with an hr_oib tax ID, which Stripe prints on its invoice page.
// once per business client: one FakturoApp contact, one Stripe customer
export async function onboardClient(client: Client) {
const contact = await fakturo('/contacts', 'POST', {
name: client.legalName,
email: client.billingEmail, // required: the send step refuses a contact without one
contact_type: 'b2b',
vat_number: client.oib, // 11 digits, no HR prefix
vat_registered: client.vatRegistered,
street_name: client.address.line1,
city_name: client.address.city,
postal_zone: client.address.postalCode,
country_code: 'HR'
});
if (contact.status !== 201) throw new Error(contact.data.error);
const customer = await stripe.customers.create({
name: client.legalName,
email: client.billingEmail,
address: {
line1: client.address.line1,
city: client.address.city,
postal_code: client.address.postalCode,
country: 'HR'
},
tax_id_data: [{ type: 'hr_oib', value: client.oib }],
metadata: { client_id: client.id }
});
await db.clients.update(client.id, {
fakturoContactId: contact.data.id,
stripeCustomerId: customer.id
});
} Create the Stripe invoice
As with orders in the F1 post, your own bill record is the source of truth for what is being billed, and both documents are built from it. B2B prices are usually net, so each Stripe invoice item carries a net unit amount and an exclusive tax rate:
// server: turn a bill from your own records into a Stripe invoice
export async function issueBill(billId: string) {
const bill = await db.bills.get(billId);
const client = await db.clients.get(bill.clientId);
const invoice = await stripe.invoices.create({
customer: client.stripeCustomerId,
collection_method: 'send_invoice',
days_until_due: 15,
auto_advance: false, // we send it ourselves, after the eRačun
footer: 'Ovo je poveznica za plaćanje. Račun vam je dostavljen kao eRačun.',
metadata: { bill_id: bill.id }
});
for (const line of bill.lines) {
await stripe.invoiceItems.create({
customer: client.stripeCustomerId,
invoice: invoice.id,
description: line.description,
quantity: line.quantity,
unit_amount_decimal: String(line.unitPriceCents), // net, in cents
tax_rates: [STRIPE_TAX_RATE_IDS[line.vatRate]] // e.g. 25 -> txr_... (exclusive)
});
}
await stripe.invoices.finalizeInvoice(invoice.id); // fires invoice.finalized
} Two settings in that call matter more than they look:
- •
auto_advance: falsestops Stripe from emailing the invoice the moment it is finalized. The client should get the eRačun first and the payment link second, and an invoice that fails eRačun validation should never reach them at all. - • The footer tells the client's bookkeeper that the Stripe document is a payment request. FakturoApp deliberately does not email a PDF alongside a delivered eRačun, so the client does not book one invoice twice. Your Stripe email should not undo that.
Fiscalize and deliver from invoice.finalized
The webhook works as in the F1 flow: verify the signature, enqueue a job, return 200. Fiscalizing and delivering an eRačun is a round trip to Porezna and then to the buyer's access point, which is far too slow for a webhook response.
// inside the verified webhook handler: enqueue and return 200
switch (event.type) {
case 'invoice.finalized':
if (event.data.object.metadata?.bill_id) {
await jobs.enqueue('issue-eracun', { billId: event.data.object.metadata.bill_id });
}
break;
case 'invoice.paid':
if (event.data.object.metadata?.bill_id) {
await jobs.enqueue('report-payment', { stripeInvoiceId: event.data.object.id });
}
break;
case 'invoice.voided':
case 'credit_note.created':
// the job resolves the bill, storno only for a void or a full credit
await jobs.enqueue('credit-or-void', { eventId: event.id });
break;
} The job uses the same draft-then-send shape as F1, for the same reason: an F2 fiscalization cannot be repeated. The send claims the draft first, so a duplicate webhook, a retry and a click in the FakturoApp UI can never fiscalize the same invoice twice. For a B2B contact, one send does all of it, strictly in this order: number the bill, validate the UBL, fiscalize it with Porezna, and deliver it over AS4.
export async function issueERacun(billId: string) {
const bill = await db.bills.get(billId);
if (bill.eracunNumber) return; // already fiscalized, nothing to do
const client = await db.clients.get(bill.clientId);
const stripeInvoice = await stripe.invoices.retrieve(bill.stripeInvoiceId);
// 1. One draft per Stripe invoice, created once
if (!bill.fakturoInvoiceId) {
const draft = await fakturo('/invoices', 'POST', {
contact_id: client.fakturoContactId,
sales_location_id: FAKTURO_SALES_LOCATION_ID,
invoice_type: '1-bill',
date_issued: zagrebDate(stripeInvoice.status_transitions.finalized_at!),
due_date: zagrebDate(stripeInvoice.due_date!),
currency: 'EUR',
vat_included: false, // Stripe prices here are net, VAT on top
payment_method: 'transfer',
order_reference: bill.purchaseOrder ?? undefined, // the client's PO number (BT-13)
notes: `Stripe ${stripeInvoice.number}`,
items: bill.lines.map((line) => ({
inventory_id: line.fakturoInventoryId, // the item carries the KPD code
quantity: line.quantity,
unit_price: line.unitPriceCents / 100,
vat_rate: line.vatRate
}))
});
if (draft.status !== 201) throw new Error(draft.data.error);
// 2. The eRačun must add up to what Stripe will collect, to the cent
if (Math.round(Number(draft.data.total_amount) * 100) !== stripeInvoice.total) {
throw new FatalError(`Total mismatch on bill ${billId}`); // alert, do not retry
}
await db.bills.update(billId, { fakturoInvoiceId: draft.data.id });
bill.fakturoInvoiceId = draft.data.id;
}
// 3. Send: claims the draft, numbers it, validates the UBL, fiscalizes it (F2)
// and delivers it to the client's eRačun address over AS4
const sent = await fakturo(`/invoices/${bill.fakturoInvoiceId}/send`, 'POST', {});
if (sent.status === 422) {
// fails EN 16931 / HR CIUS (e.g. an item without a KPD code): nothing was fiscalized
throw new FatalError(sent.data.details?.join('\n') ?? sent.data.error);
}
// 4. Whatever the answer, trust the invoice's own state
const eracun = await fakturo(`/invoices/${bill.fakturoInvoiceId}`);
if (!eracun.data.fiscalized) {
throw new Error(`Not fiscalized yet (${sent.status}): ${sent.data.error}`); // job retries
}
await db.bills.update(billId, {
eracunNumber: eracun.data.invoice_number, // e.g. 117/1/1
eracunDelivery: eracun.data.as4_delivery_status // pending, delivered or failed
});
if (eracun.data.as4_delivery_status === 'failed') {
alert(`eRačun ${eracun.data.invoice_number} not delivered: ${eracun.data.as4_error_description}`);
}
// 5. Only now email Stripe's payment link
await stripe.invoices.update(stripeInvoice.id, {
metadata: { bill_id: billId, eracun_number: eracun.data.invoice_number }
});
await stripe.invoices.sendInvoice(stripeInvoice.id);
} The responses tell you which kind of failure you have:
- • 422: the eRačun fails EN 16931 or HR CIUS. The
detailsarray lists every broken rule, for example an item without a KPD code or a VAT exemption with no reason. Validation runs before fiscalization, so nothing was registered and the invoice is still a draft. Retrying will not help. Fix the data, then run the job again. - • 502: Porezna did not fiscalize it. The claim is released and the invoice stays
a draft, so the job retries with backoff. A 409 means another send holds the claim or already
finished. Either way, the follow-up GET and its
fiscalizedflag decide, as in F1. - • Fiscalized, but not delivered. If the buyer's access point refuses the
message or cannot be reached,
as4_delivery_statusisfailedand FakturoApp emails the PDF instead. Do not retry the job: the invoice is fiscalized and stays that way. Alert a person to resolve the delivery. A successful send starts aspendingand becomesdeliveredwhen the buyer's side confirms receipt. - • The totals check runs before anything is fiscalized. Stripe and FakturoApp each round VAT their own way. If they disagree by a cent, you find out while both documents can still be fixed: void the Stripe invoice and issue a new one. Whole-cent net unit prices avoid most of these.
Leave invoice_number out of the draft. FakturoApp gives the bill the next number of its sales location when it is sent,
in the Croatian number/premises/device format. Stripe's own number goes into the notes, and the
eRačun number goes back into the Stripe invoice's metadata, so either one finds the other.
Report the payment from invoice.paid
F1 has nothing to report after a payment, but F2 does. When Stripe marks the invoice paid, POST /invoices/{id}/mark-paid marks the FakturoApp invoice paid and reports the payment to Porezna with its date, amount and
method:
export async function reportPayment(stripeInvoiceId: string) {
const stripeInvoice = await stripe.invoices.retrieve(stripeInvoiceId);
const bill = await db.bills.get(stripeInvoice.metadata!.bill_id);
if (bill.paymentReported) return;
if (!bill.eracunNumber) throw new Error('eRačun not issued yet'); // retry later
// Card through Stripe is "card". A SEPA payment or one marked paid out of band
// (money straight to the IBAN on the eRačun) is a transfer: leave it out.
const { data: payments } = await stripe.invoicePayments.list({
invoice: stripeInvoice.id,
status: 'paid',
expand: ['data.payment.payment_intent.latest_charge']
});
const intent = payments[0]?.payment.payment_intent as Stripe.PaymentIntent | undefined;
const charge = intent?.latest_charge as Stripe.Charge | undefined;
const paymentMethod = charge?.payment_method_details?.type === 'card' ? 'card' : undefined;
const paid = await fakturo(`/invoices/${bill.fakturoInvoiceId}/mark-paid`, 'POST', {
// the date part is what gets reported, so give it in Zagreb time
paid_at: zagrebDateTime(stripeInvoice.status_transitions.paid_at!), // 2026-11-05T00:30:00+01:00
payment_method: paymentMethod
});
if (paid.status !== 200) {
// 400 "already marked as paid" means an earlier attempt got through
const eracun = await fakturo(`/invoices/${bill.fakturoInvoiceId}`);
if (eracun.data.status !== 'paid') throw new Error(paid.data.error); // job retries
}
await db.bills.update(bill.id, { paymentReported: true });
} - • Never mark an unissued eRačun paid. On a B2B invoice that is not fiscalized yet, mark-paid fiscalizes and delivers it itself. That is handy in the UI, but in this flow it would race the issuing job, so the payment job waits for the eRačun number.
- • Payment method. A card payment through Stripe is reported as card. Leave the field out for everything else: FakturoApp then reports a bank transfer, which is right for a SEPA payment and for a client who paid to the IBAN printed on the eRačun.
- • Payments outside Stripe. Business clients often pay from their own banking
software, using the eRačun's payment reference. Record those in Stripe as paid out of band
(
stripe.invoices.pay(id, { paid_out_of_band: true })). That fires the sameinvoice.paid, so every payment is reported through one path. - • Retries are safe. If the report to Porezna fails, mark-paid returns an error and leaves the invoice unpaid, so the job simply runs again. A 400 for an invoice that is already paid means an earlier attempt succeeded.
Credit notes and voids become stornos
A fiscalized eRačun is never edited or deleted, and voiding the Stripe invoice does not take it back.
When a Stripe invoice whose eRačun is already issued is voided, or receives a credit note for its full
amount, issue a storno. POST /invoices/{id}/storno creates a draft with every line negated, and its eRačun names the original in its billing
reference. Send it like any other bill.
export async function stornoBill(billId: string) {
const bill = await db.bills.get(billId);
if (!bill.eracunNumber || bill.stornoNumber) return;
if (!bill.fakturoStornoId) {
const storno = await fakturo(`/invoices/${bill.fakturoInvoiceId}/storno`, 'POST');
await db.bills.update(billId, { fakturoStornoId: storno.data.id });
bill.fakturoStornoId = storno.data.id;
}
await fakturo(`/invoices/${bill.fakturoStornoId}/send`, 'POST', {});
// same state check as issueERacun: GET the storno and trust its fiscalized flag
} A partial credit note needs a corrective invoice for just the credited part rather than a full storno. Agree the exact form with your accountant and handle it by hand until the pattern is clear. FakturoApp also refuses to storno bills that carry a document-level discount or delivery fee, which is one more reason to keep discounts on the lines.
Test it end to end
Run Stripe in test mode against FakturoApp's sandbox eRačun environment and forward events with stripe listen --forward-to. If you can register a second business of your own as the buyer in the sandbox, you also see
the eRačun arrive on the other side. Then break it on purpose. Replay invoice.finalized, kill the worker between the draft and the send, bill an item with no KPD code (expect a 422 and
nothing fiscalized), and deliver invoice.paid before the eRačun job has finished. Each case should end with one fiscalized eRačun and at most one
payment report. In production, a daily reconciliation should flag finalized Stripe invoices with no
eRačun number, paid ones that were never reported and eRačuni stuck at failed.
Key Takeaways
- • Since 1 January 2026 every invoice between Croatian businesses is an eRačun: a structured EN 16931 invoice that is fiscalized with Porezna uprava and delivered to the buyer over the eRačun network. A Stripe invoice PDF is not one
- • Let Stripe do billing and collection and let FakturoApp issue the legal invoice: fiscalize and send the eRačun from the invoice.finalized webhook, then send Stripe's payment link
- • Create one FakturoApp draft per Stripe invoice, check that its total matches Stripe's to the cent, and send it with POST /invoices/{id}/send: a retry or a duplicate webhook can never fiscalize it twice
- • F2 has a second fiscal event. Report every payment from invoice.paid with POST /invoices/{id}/mark-paid, well before the 20th of the following month
- • A fiscalized eRačun is never edited or deleted: a full credit note or a voided invoice becomes a storno, sent as its own eRačun
We build billing and invoicing integrations like this for Croatian and European businesses, from Stripe Invoicing to the accounting systems behind it. If you are working on Stripe in Osijek, come say hello at the Stripe Osijek Community.
Frequently Asked Questions
Is a Stripe invoice a valid eRačun in Croatia?
No. A Stripe invoice is a PDF and a hosted payment page. An eRačun is a structured UBL document that follows EN 16931 and the Croatian HR CIUS, is fiscalized with Porezna uprava and is delivered to the buyer through an eRačun access point. Use Stripe to collect the payment and deliver the eRačun as the invoice.
Do I need a FINA certificate to send eRačuni through FakturoApp?
Not for B2B. FakturoApp fiscalizes and sends eRačuni as your information intermediary, after you authorize it in FiskAplikacija. The FINA fiscal certificate is only needed for F1, the receipts you issue to consumers.
When does the payment of an eRačun have to be reported?
By the 20th of the month after the month in which it was paid. Reporting from the Stripe invoice.paid webhook means it is reported within minutes of the payment, so the deadline never comes into play.
What if the client pays straight to our IBAN instead of through Stripe?
That is common, because the eRačun carries your bank account and the invoice number as the payment reference. Mark the Stripe invoice as paid out of band, which fires invoice.paid, and the same job reports the payment as a bank transfer.
What happens if the eRačun is fiscalized but not delivered?
The invoice stays fiscalized and must not be fiscalized again. FakturoApp records the delivery failure on the invoice and emails the PDF to the buyer instead. Treat it as an alert: find out why the buyer's eRačun address did not accept it and resolve the delivery.
Sources
Written by
Filip Lauc
CEO, Jaspero
Filip Lauc is the CEO of Jaspero, a software development agency based in Osijek, Croatia, and a Stripe Community Builder who runs the Stripe Osijek Community. A full-stack JavaScript developer with over a decade of experience across Angular, Svelte, and Node.js, he leads Jaspero's work as a long-term embedded engineering partner for clients like GlycanAge, where his team has served as the dedicated engineering team for six years.
Let's Build Together
Your vision,
our expertise.
From AI integration to full-stack development, we turn ambitious ideas into products that perform.