Fiscalizing Stripe Payments in Croatia: F1 B2C Receipts With Stripe Elements and FakturoApp
Stripe confirms the payment, FakturoApp fiscalizes the receipt with Porezna uprava, and a small amount of glue code makes sure every paid order ends up with exactly one JIR.
2026-11-01 · By Filip Lauc
Stripe is the easiest way for a Croatian business to take card payments online. It does not, on its own, issue a fiscal receipt. Every sale to a consumer still needs a receipt registered with Porezna uprava, carrying a ZKI, a JIR and a QR code, and since Fiskalizacija 2.0 took effect on 1 January 2026 that applies to every payment method, not just cash and cards.
This post walks through the integration we would build for a web shop or SaaS checkout on Stripe Elements: what to collect at checkout, where in the payment lifecycle to fiscalize, and how to call the FakturoApp API so that a retried webhook or a crashed worker can never produce two fiscal receipts for one payment, or none.
What F1 means for a Stripe payment
Fiskalizacija 2.0 splits into two flows that people usually call F1 and F2. F1 is the B2C receipt (račun u krajnjoj potrošnji): the seller sends the receipt data, signed with its FINA fiscal certificate, to the Tax Administration and gets back a JIR. F2 is the B2B eRačun, a structured EN 16931 invoice that is fiscalized and delivered to the buyer through an access point. A consumer paying by card on your site is F1.
For a Stripe checkout, three practical rules follow:
- • Fiscalize when the money is taken. The receipt belongs to the payment, so it is issued when Stripe confirms the PaymentIntent, not in a nightly batch.
- • The payment method is card. Apple Pay and Google Pay through Stripe are card payments too, fiscalized with the card code (K).
- • Stripe's receipt is not the fiscal receipt. It has no ZKI or JIR. The customer should receive the fiscalized one, so turn off Stripe's automatic receipt emails to avoid sending two.
You could implement F1 yourself: build the RacunZahtjev SOAP message, compute the ZKI, sign it with XML-DSig, manage the certificate and number bills per
premises and device. FakturoApp does all of that behind a REST API, keeps the receipt, renders the
PDF with the QR code and emails it to the buyer, so the code you own shrinks to the glue described
below.
It is also cheap to try. FakturoApp is free for the first six months, and signing up needs no card. After that, plans start at 4,99 € a month for up to 10 fiscalized receipts, which makes it an easy fit for startups and small businesses that are just starting to sell online.
One-time setup in FakturoApp
Before any code runs, the business needs five things configured:
- • Fiscalization enabled, with the FINA fiscal certificate uploaded in the business settings. Start in Porezna's test environment (the business has a test mode switch) with a demo certificate, and switch to production once the flow works end to end.
- • A sales location for the web shop. Its number is the premises label (oznaka poslovnog prostora) in every bill number, and FakturoApp numbers bills per location, device and year, so concurrent payments never collide. Keep its id in an environment variable.
- • An inventory item per product, with its VAT rate. Bill lines reference inventory items, so store each item's FakturoApp id next to your product (or in the Stripe Product's metadata).
- • A personal API key from Postavke, API i MCP, scoped to
write:contacts,write:invoices,read:invoicesandsend:invoices. It is sent as a bearer token and lives only on your server. - • A Stripe webhook endpoint subscribed to
payment_intent.succeededandcharge.refunded, with its signing secret in your environment.
Collect what the receipt needs at checkout
A fiscal receipt in FakturoApp is issued to a contact, and a B2C contact needs a name and an address. The send step also emails the receipt, so it needs the buyer's email. Collect all three in the checkout, before the payment is confirmed, and save them on your order. Your order, not Stripe, is the source of truth for what was sold.
// server: create the PaymentIntent for an order you have already saved
const intent = await stripe.paymentIntents.create({
amount: order.totalCents, // gross, VAT included
currency: 'eur',
automatic_payment_methods: { enabled: true },
metadata: { order_id: order.id }
});
return { clientSecret: intent.client_secret }; On the client, the Payment Element takes the card (and wallets), and the Address Element in billing mode collects the name and address in a form Stripe also attaches to the payment method. Read its value and store it on the order before confirming:
// browser: Payment Element plus Address Element in billing mode
const elements = stripe.elements({ clientSecret });
const addressElement = elements.create('address', { mode: 'billing' });
addressElement.mount('#billing-address');
elements.create('payment').mount('#payment');
async function pay(email: string) {
const { complete, value } = await addressElement.getValue();
if (!complete) return;
// the fiscal receipt needs these, so store them on the order first
await fetch(`/api/orders/${orderId}/buyer`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, name: value.name, address: value.address })
});
const { error } = await stripe.confirmPayment({
elements,
confirmParams: { return_url: `${location.origin}/checkout/done` }
});
if (error) showError(error.message);
} Do not fiscalize on the return_url page. The customer may close the tab, some payment methods confirm asynchronously, and anyone can
load that URL. The only reliable signal that money was taken is the webhook.
Fiscalize from the webhook, in a background job
Fiscalization is a synchronous round trip to Porezna uprava, and Stripe wants a quick 2xx from your webhook. Keep the two apart: the webhook verifies the signature, enqueues a job keyed by the order and returns. Stripe delivers events at least once and sometimes out of order, so the job, not the webhook, is where duplicates are handled.
// server: the Stripe webhook only verifies and enqueues
export async function POST(request: Request) {
const payload = await request.text();
const signature = request.headers.get('stripe-signature') ?? '';
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(payload, signature, STRIPE_WEBHOOK_SECRET);
} catch {
return new Response('Invalid signature', { status: 400 });
}
if (event.type === 'payment_intent.succeeded') {
const intent = event.data.object;
await jobs.enqueue('fiscalize-order', { orderId: intent.metadata.order_id });
}
if (event.type === 'charge.refunded') {
const charge = event.data.object;
if (charge.refunded) await jobs.enqueue('storno-order', { paymentIntentId: charge.payment_intent });
}
return new Response(null, { status: 200 });
} The FakturoApp client is a thin wrapper over fetch. Errors come back as JSON with an error message:
const FAKTURO_API = 'https://fakturoapp.hr/api/v1';
async function fakturo(path: string, method = 'GET', body?: unknown) {
const res = await fetch(FAKTURO_API + path, {
method,
headers: {
Authorization: `Bearer ${FAKTURO_API_KEY}`, // fk_live_...
'Content-Type': 'application/json'
},
body: body === undefined ? undefined : JSON.stringify(body)
});
return { status: res.status, data: await res.json() };
} Draft first, then send: exactly one JIR per payment
FakturoApp can create and fiscalize a bill in a single call, but a single call is the wrong shape for a retried job. If the worker dies after FakturoApp answered and before you saved the result, the retry creates and fiscalizes a second bill for the same payment. Split it in two instead:
- • Create a draft with
POST /invoicesand save its id on the order. A draft has no fiscal number and no JIR. The worst a crash can leave behind is an orphaned draft, which you can delete. - • Send it with
POST /invoices/{id}/send. The send first claims the draft in the database, so two concurrent sends, a retry and a click in the FakturoApp UI can never fiscalize the same bill twice. For a B2C contact it fiscalizes the bill (F1), returns the JIR, ZKI and QR code link, and emails the PDF receipt to the buyer.
export async function fiscalizeOrder(orderId: string) {
const order = await db.orders.get(orderId);
if (order.jir) return; // already fiscalized, nothing to do
// 1. One draft bill per order, created once
if (!order.fakturoInvoiceId) {
const contact = await fakturo('/contacts', 'POST', {
name: order.buyer.name,
email: order.buyer.email, // FakturoApp emails the fiscal receipt here
contact_type: 'b2c',
street_name: order.buyer.address.line1,
city_name: order.buyer.address.city,
postal_zone: order.buyer.address.postal_code,
country_code: order.buyer.address.country
});
const today = zagrebDate(); // YYYY-MM-DD in Europe/Zagreb, not UTC
const draft = await fakturo('/invoices', 'POST', {
contact_id: contact.data.id,
sales_location_id: FAKTURO_SALES_LOCATION_ID,
invoice_type: '1-bill',
date_issued: today,
due_date: today,
currency: 'EUR',
vat_included: true, // Stripe amounts are gross
payment_method: 'card',
notes: `Stripe ${order.paymentIntentId}`,
items: order.lines.map((line) => ({
inventory_id: line.fakturoInventoryId,
quantity: line.quantity,
unit_price: line.unitPriceCents / 100,
vat_rate: line.vatRate
}))
});
if (draft.status !== 201) throw new Error(draft.data.error);
// 2. The bill must add up to what Stripe charged, to the cent
if (Math.round(Number(draft.data.total_amount) * 100) !== order.totalCents) {
throw new FatalError(`Total mismatch on order ${orderId}`); // alert, do not retry
}
await db.orders.update(orderId, { fakturoInvoiceId: draft.data.id });
order.fakturoInvoiceId = draft.data.id;
}
// 3. Send: claims the draft, fiscalizes it (F1) and emails the receipt
const sent = await fakturo(`/invoices/${order.fakturoInvoiceId}/send`, 'POST', {
payment_method: 'card'
});
if (sent.status === 200) {
return db.orders.update(orderId, { jir: sent.data.jir, zki: sent.data.zki });
}
// 4. Anything else: ask FakturoApp what state the bill is really in
const bill = await fakturo(`/invoices/${order.fakturoInvoiceId}`);
if (bill.data.fiscalized) {
return db.orders.update(orderId, {
jir: bill.data.fiscalization_jir,
zki: bill.data.fiscalization_zki
});
}
throw new Error(`Not fiscalized yet (${sent.status}): ${sent.data.error}`); // job retries
} A few details in that job carry most of the weight:
- • Prices are gross. Stripe charges VAT-inclusive amounts, so the draft sets
vat_included: trueand FakturoApp derives the net amounts and VAT. The cent check against the PaymentIntent catches a rounding or pricing drift before it is fiscalized, when it is still cheap to fix. - • Let FakturoApp number the bill. Leave
invoice_numberout. A bill gets its number from the sales location's counter at the moment it is sent. - • Read state after a failure. A non-200 from
/sendcan mean "already sent by an earlier attempt" (400 or 409), "another send is in progress" (409) or "Porezna did not answer" (502). Instead of branching on each, fetch the bill and trust itsfiscalizedflag. A failed fiscalization leaves the bill a draft, so the retry simply sends it again. - • Use the Zagreb date. A payment at 00:30 local time is still the previous
day in UTC. Compute
date_issuedin Europe/Zagreb.
Contacts are created per order here for brevity. If customers buy repeatedly, keep the FakturoApp contact id on your customer record and reuse it.
Refunds are stornos
A fiscalized receipt is never edited or deleted. When a Stripe payment is refunded in full,
issue a storno: POST /invoices/{id}/storno creates a draft linked to the original with every line negated, and you send it exactly like the
original bill, with the same draft-then-send safety.
export async function stornoOrder(paymentIntentId: string) {
const order = await db.orders.findByPaymentIntent(paymentIntentId);
if (!order.jir || order.stornoJir) return;
if (!order.fakturoStornoId) {
const storno = await fakturo(`/invoices/${order.fakturoInvoiceId}/storno`, 'POST');
await db.orders.update(order.id, { fakturoStornoId: storno.data.id });
order.fakturoStornoId = storno.data.id;
}
const sent = await fakturo(`/invoices/${order.fakturoStornoId}/send`, 'POST', {
payment_method: 'card'
});
// same success and state check as fiscalizeOrder
} Partial refunds need a correcting bill for only the refunded lines rather than a full storno. They are rare enough in most shops that it pays to handle them deliberately, with your accountant agreeing the exact form, instead of automating a guess.
Test it before the first real receipt
Run the whole flow against Stripe test mode and FakturoApp's test fiscalization environment at the
same time. The Stripe CLI (stripe listen --forward-to) forwards events to your local webhook. Then break it on purpose: replay the same payment_intent.succeeded event twice, kill the worker between the draft and the send, and point the job at an unreachable
API. Each case should end with exactly one fiscalized bill. Finally, add a daily reconciliation
that lists succeeded PaymentIntents without a JIR, so a stuck job turns into an alert instead of a
missing receipt.
Key Takeaways
- • Since 1 January 2026 every B2C receipt in Croatia is fiscalized regardless of payment method, so each Stripe card payment needs a receipt with a ZKI, a JIR and a QR code
- • Fiscalize from the payment_intent.succeeded webhook in a background job, never from the browser redirect, and acknowledge the webhook before calling Porezna
- • Create one FakturoApp draft per order and fiscalize it with POST /invoices/{id}/send: the send claims the draft, so retries and duplicate webhooks can never produce a second JIR
- • Collect the buyer's email and billing address at checkout, check that FakturoApp's total matches the PaymentIntent amount to the cent, and let FakturoApp email the fiscal receipt
- • A refund is a storno: create it from the original bill with POST /invoices/{id}/storno and send it the same way
We build payment and invoicing integrations like this for Croatian and European businesses, from Stripe checkouts to the systems of record behind them. If you are working on Stripe in Osijek, come say hello at the Stripe Osijek Community.
Frequently Asked Questions
Do online card payments through Stripe have to be fiscalized in Croatia?
Yes, for sales to consumers. Card payments were already in scope before 2026, and since 1 January 2026 fiscalization of B2C receipts applies to every payment method, including payments to a bank account. The receipt the customer receives must carry the ZKI, the JIR returned by Porezna uprava and a QR code.
Is the Stripe email receipt a valid fiscal receipt?
No. Stripe's receipt confirms the card payment, but it has no ZKI or JIR and is not registered with the Tax Administration. Send the fiscalized receipt instead. Most teams turn off Stripe's automatic receipt emails so the customer does not get two documents that look like receipts.
Which payment method code does a Stripe payment use?
Card payments, including Apple Pay and Google Pay, are fiscalized as cards (K). In FakturoApp's API that is payment_method: "card". Other Stripe payment methods need their own mapping, for example bank transfers to "transfer".
What happens if Porezna uprava is unavailable when the payment succeeds?
The send call fails, FakturoApp releases its claim on the bill and it stays a draft, ready to be sent again. Retry the job with backoff and alert someone if it keeps failing, so the receipt is issued as soon as the service answers again.
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.