Skip to main content
Engineering

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:invoices and send:invoices, kept on your server and sent as a bearer token. The fakturo() helper below is the same thin fetch wrapper 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.voided and credit_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: false stops 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 details array 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 fiscalized flag decide, as in F1.
  • • Fiscalized, but not delivered. If the buyer's access point refuses the message or cannot be reached, as4_delivery_status is failed and 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 as pending and becomes delivered when 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 same invoice.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.

Filip Lauc

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.