Code Agency
15 min readBy Fabio Tielen

Payment methods across EU markets: one shop, local trust everywhere

Bancontact in Belgium, iDEAL in the Netherlands, TWINT in Switzerland — conversion follows familiarity, and a card form is not a neutral default. How we structure Mollie or Stripe so every market pays the way it expects, why asynchronous methods are the part that actually breaks your order flow, and what the cost conversation looks like when someone finally has it.

A Dutch customer lands on a Belgian shop, fills a cart, reaches checkout, and finds a card form. Not a broken one — a perfectly good card form, PCI-compliant, 3-D Secure ready, with the little Visa and Mastercard marks under it. They leave. Not because they don't have a card, but because paying online in the Netherlands means iDEAL, it has meant iDEAL for twenty years, and a shop that doesn't offer it reads as a shop that isn't really Dutch.

That's the whole subject in one paragraph. Payment methods are not a feature list you tick off, they're a trust signal, and the signal is local. The mistake we see most often isn't offering too few methods — it's treating the card as the neutral default and everything else as an extra, when in half of Europe the card is the extra.

The mistake we see second-most-often is the overcorrection: switch on all thirty methods your PSP supports, render fourteen logos at checkout, and watch conversion drop because you turned a two-second decision into a menu.

The map, and it isn't a long tail

Here's the thing that makes this tractable: in almost every European market, one or two methods carry the overwhelming majority of online payments. It's not a long tail you have to chase. It's a short list per country, and it barely moves year to year.

MarketWhat people reach for first
BelgiumBancontact (card is second, and mostly for B2B)
NetherlandsiDEAL — the large majority of consumer e-commerce
GermanyPayPal and invoice/instalments (Klarna); cards trail badly
FranceCards, but domestic Cartes Bancaires rails under the co-badge
PolandBLIK, then P24
SwitzerlandTWINT
PortugalMultibanco and MB WAY
SpainCards, with Bizum climbing fast
ItalyCards, PayPal, Satispay
AustriaEPS, plus the German habits next door
NordicsVipps MobilePay, Swish, and Klarna as the default for "buy now, decide later"

Two things worth pulling out of that table.

Germany is the market people get wrong. It's the biggest e-commerce market on the list and the one where an Anglo-style card-first checkout underperforms hardest. Germans pay on invoice — buy first, receive the goods, transfer the money within fourteen or thirty days — and Klarna built a business on productising that habit. A German checkout without PayPal and without a pay-later option is leaving real money on the table, whatever the card acceptance rate looks like.

The list is not stable in the way people assume. giropay, which German shops integrated for a decade, was wound down. Sofort folded into Klarna's own flow. Meanwhile Bizum went from nothing to a checkout button, and Wero — the European Payments Initiative's wallet — is on a stated path to absorb both Bancontact and iDEAL. If your integration treats a method as a config line, none of that is a project. If it treats a method as a branch in your checkout component, all of it is.

What the flag actually decides

Adding a market is never just adding a payment button, and this is where scope quietly triples. Picking a method drags five other decisions along with it:

  • Currency. TWINT settles in CHF, BLIK in PLN, Swish in SEK. You cannot offer BLIK on a euro-priced basket. So "we'd like to sell to Poland" is a pricing and catalogue decision before it is a payments decision.
  • Timing. Bancontact, iDEAL and BLIK confirm in seconds. Multibanco hands the customer a reference to pay later at an ATM or in their banking app — that order might confirm tomorrow, or never. SEPA Direct Debit can fail days after you shipped.
  • Reversibility. Card payments carry chargeback rights with a long window. iDEAL and Bancontact, being push payments from the customer's own bank, effectively don't. That changes your fraud posture, your refund policy, and how nervous you need to be about high-value first orders.
  • Cost. Domestic debit schemes are usually much cheaper than commercial credit cards; pay-later providers take a percentage that looks like a marketing budget line, not a payments one. More on this below, because it's the conversation nobody has until year two.
  • Everything that isn't payments. Switzerland is outside the EU customs union, so TWINT comes with import VAT, customs paperwork and a shipping conversation. Cross-border EU sales past the €10,000 distance-selling threshold come with OSS VAT registration. The payment button is the easy half.

We put that list in front of clients before writing any code, because it usually reorders the roadmap. "Add TWINT" is a quarter. "Add iDEAL" is an afternoon.

Mollie or Stripe: an honest split

We build on both, and we don't think either one is the right answer for everybody.

Mollie is the better fit for a Benelux-first shop. The local methods are first-class rather than an add-on, pricing is per-transaction and legible enough that a shop owner can predict next month's bill without a spreadsheet, and the dashboard is something a finance person can actually be handed. Its API is small, which is a compliment. If your markets are BE, NL, DE and FR and your shop is a shop, Mollie is usually the shortest line between here and live.

Stripe earns its place when payments are a system rather than a checkout: marketplaces splitting payouts, subscriptions with real dunning, usage billing, or a custom application where money moves in more than one direction. The primitives are better, the coverage is wider, and the tooling around testing and reconciliation is genuinely ahead. You pay for that with more surface to hold correctly.

What matters more than the choice: wrap it. One internal interface — create a payment, get a status, handle a webhook, issue a refund — with the PSP behind it. Not because you'll switch providers (most shops never do), but because it's the only way the async states below stay in one file instead of leaking into every controller that touches an order. On headless setups that wrapper lives in the API layer next to everything else that talks to the ERP, for the same reason we put a NestJS layer in front of Odoo.

Show the right methods, and let the PSP decide

The method list is not a constant. It depends on the customer's market, the basket amount, and the currency — and both PSPs will compute it for you if you ask properly.

lib/payments/methods.ts — ask, don't hardcode
// Country comes from the shipping address, never from IP or Accept-Language.
// A Belgian expat browsing in English from a Berlin hotel still wants Bancontact.
export async function availableMethods(order: Order) {
  const res = await mollie.methods.list({
    amount: { value: order.total.toFixed(2), currency: order.currency },
    billingCountry: order.billingAddress.country,
    locale: order.locale,
    resource: "payments",
  })
 
  // Local hero first, then the familiar fallback, then the rest.
  return res.sort(byPreferenceFor(order.billingAddress.country))
}

Three rules we apply on top of whatever the API returns.

Detect from the address, not the browser. IP geolocation puts VPN users and expats in the wrong country, and Accept-Language tells you what someone reads, not where they bank. Belgium alone breaks language-based detection twice over. The shipping or billing country is the only signal that means what you think it means — which is also why we keep locale and market as separate concerns in the frontend.

Order matters more than presence. The dominant local method goes first, pre-selected, with its real logo at a size people recognise. Everything else can live under a "more options" disclosure. A Dutch checkout that offers iDEAL fourteenth in an alphabetical list has technically integrated iDEAL.

Amount changes the list. Pay-later providers cap basket values. Some methods have minimums. If you render a static list, a customer with a €4,000 basket sees a button that will fail when they press it, which is worse than not offering it.

And keep the checkout itself fast. This is the page where every hundred milliseconds is measurable in revenue, and the temptation to load four SDKs — one per method — is exactly how a checkout ends up at three seconds to interactive.

The webhook is the truth, and the redirect is a rumour

Here's the part that breaks real shops, and it breaks them quietly.

Every redirect-based method sends the customer back to your redirectUrl when they're done. It is enormously tempting to treat that return as "payment succeeded" and create the order there. Don't. That return happens in a browser you don't control: the customer closes the tab on the bank's confirmation screen, their phone dies, mobile Safari restores a stale page, the app switch back to your site never happens. The redirect tells you the customer came back. It does not tell you money moved.

The webhook tells you money moved. So the order state changes there, and only there.

app/api/webhooks/mollie/route.ts — idempotent, re-entrant, boring
export async function POST(req: Request) {
  const { id } = await req.json()
 
  // The webhook body carries an id, not a status. Always re-read from the PSP —
  // never trust a status that arrived over the wire.
  const payment = await mollie.payments.get(id)
  const orderId = payment.metadata.orderId
 
  await db.transaction(async (tx) => {
    const order = await tx.orders.selectForUpdate(orderId)
 
    // Mollie retries on any non-2xx, and will happily deliver out of order.
    // Terminal states never move again; everything else is a no-op replay.
    if (isTerminal(order.paymentStatus)) return
 
    switch (payment.status) {
      case "paid":
        await tx.orders.update(orderId, { paymentStatus: "paid" })
        await tx.outbox.insert("order.paid", { orderId })
        break
      case "failed":
      case "canceled":
      case "expired":
        await tx.orders.update(orderId, { paymentStatus: payment.status })
        await tx.outbox.insert("order.payment_failed", { orderId })
        break
      // "open" and "pending" are not failures. Leave the order alone.
    }
  })
 
  return new Response(null, { status: 200 })
}

Four things in there are load-bearing, and each of them comes from a project where it was missing.

Re-read the payment. A webhook body is an announcement, not evidence. Fetching the payment from the PSP costs one request and removes an entire class of forged-or-stale-state bug.

Lock the row and make terminal states terminal. Webhooks arrive twice, arrive out of order, and arrive again next week when someone replays them from a dashboard. A paid order that a late expired webhook can knock back into limbo is a support ticket with an angry customer attached.

Emit an event, don't do the work inline. Invoice creation, stock reservation, the confirmation mail, the ERP write — all of it hangs off order.paid through a transactional outbox, so a slow ERP can't cause the webhook to time out and the PSP to retry a payment you already have. Same outbox we use for cart events, and Postgres handles the queue without another moving part in the diagram.

Return 200 fast, from a URL that's actually reachable. Every PSP retries on non-2xx, which is a feature until your handler is slow and the retries pile up. And a webhook endpoint behind basic auth, a staging password, a WAF rule or a VPN-isolated backend is the single most common reason a "payments are stuck in pending" ticket exists.

Asynchronous methods break the state machine everyone drew

Most order flows are drawn assuming payment is instant: customer pays, order confirmed, warehouse picks. Multibanco doesn't work like that. Neither does a plain SEPA credit transfer, nor pay-later, nor — in a delayed and much nastier way — SEPA Direct Debit, which can bounce back days after you shipped, and where consumers have an eight-week no-questions refund right.

So the state machine needs a state that a lot of shops don't have: awaiting payment, order real, nothing picked.

That state has consequences you have to decide on deliberately:

  • Stock. Reserve it or don't? Reserve, and a customer who never pays their Multibanco reference holds your last unit for three days. Don't reserve, and you'll oversell. We usually reserve with a short expiry that matches the method's own window, and release automatically.
  • Communication. The customer needs a mail that says "we have your order, here is your reference, here's how to pay" — which is a different mail from the confirmation, and shops routinely only have the confirmation.
  • Expiry. Every pending order needs a deadline and a job that closes it out. Without one, your "pending" bucket grows forever and stops being something anyone looks at.
  • Fulfilment gate. One rule, enforced in one place: nothing ships on a payment that isn't in a terminal paid state. Ecommerce fraud in this corner is almost always a screenshot of a "payment initiated" page.

In Odoo-backed shops this maps cleanly onto the sales order / invoice split, and the bank reconciliation side is where the pending ones eventually resolve — the transfer lands, the reconciliation model matches it on the structured reference, and the order releases. That's the design we reach for by default on Odoo implementations: let the ERP own the money truth, let the shop own the customer experience of waiting.

The cost conversation nobody has until year two

Payment fees usually get agreed once, during the build, by whoever is least equipped to negotiate them, and then never looked at again. On a shop doing real volume, the difference between an unexamined setup and a deliberate one is comfortably a salary.

Things worth actually checking:

  • Domestic debit is cheap; commercial credit is not. European interchange on consumer debit is capped low. Corporate cards, and non-EEA cards, are not capped and cost multiples of it. If your B2B customers pay by corporate card, that mix is a line in your P&L, and a Bancontact or SEPA option is not just a convenience.
  • French cards are co-badged, and routing is worth money. Most French cards run both Cartes Bancaires and an international scheme. The domestic rails are typically cheaper for domestic transactions, and which one you route over is a setting, not a fact of nature. Ask your PSP; the answer is often "we can change that."
  • Pay-later is a marketing spend wearing a payments costume. The percentage is high enough that it belongs in a conversion conversation — does it add orders and basket size, or is it just re-pricing the customers you already had? Measure it the same way we'd measure any flow that claims credit: with a holdout, not with the provider's dashboard.
  • Refunds, chargebacks and failed direct debits all have their own fees. A shop with a 20% return rate and a per-refund fee is running a cost centre nobody put in the model.
  • Get the reconciliation data before you sign. A PSP that settles a week of transactions as one lump with no per-transaction breakdown will cost your bookkeeper more per month than the fee difference you negotiated.

Build so a method is a config line

Given giropay's exit, Sofort's absorption, Bizum's rise and Wero's announced path to swallow both Bancontact and iDEAL, the only safe assumption is that this list will look different in three years. Which is an architecture requirement, not a news item:

  • No method names in your checkout components. The frontend renders whatever the API returns — id, display name, logo, sort order.
  • No method names in your order logic. Orders care about synchronous or not, reversible or not, and paid or not. Everything else is a PSP detail.
  • Method availability per market lives in config, ideally editable without a deploy, because turning a method off during an incident should take a minute.
  • Every method your shop supports gets a real end-to-end test in the PSP's test mode, run in CI. Every PSP ships test credentials for the local methods; the ones nobody tests are the ones that break silently after an API version bump.

Do that and adding Wero is a Tuesday. Skip it and you'll rediscover why in about eighteen months.

Where we tell people not to bother

  • Before the market exists. Adding TWINT because Switzerland is on a slide is not a payments project, it's a hypothesis. Ship the market with cards and PayPal, see whether orders arrive, then buy the local method with real revenue.
  • When you already have too many. Some shops arrive with eleven methods and no data. The fix is deletion. Look at ninety days of order counts and remove everything under a percent — a shorter checkout converts better than a comprehensive one.
  • When the blocker is somewhere else. If your delivery costs surprise people at step three, or your checkout is slow, no payment method fixes that. Payment method choice is a last-mile optimisation on a funnel that otherwise works.
  • When the fulfilment side isn't ready. Selling into Switzerland without customs paperwork sorted turns a payments win into a support queue. The button is the last thing you should add, not the first.

The short version

Conversion follows familiarity, so let each market pay the way it already pays: Bancontact in Belgium, iDEAL in the Netherlands, PayPal and invoice in Germany, BLIK in Poland, TWINT in Switzerland. Ask the PSP which methods apply to this basket in this country instead of hardcoding a list, detect the market from the address rather than the browser, and put the local hero first and pre-selected rather than fourteenth. Change order state on the webhook and never on the redirect, make the handler idempotent and terminal-safe, and hang the real work off an event. Give asynchronous methods the pending state they need, with a reservation policy, a mail, an expiry and a fulfilment gate. Then look at the fees once a year, because nobody else will.

We build this into the headless storefronts and WooCommerce shops we run, wired through the ERP so the money story and the order story agree — on infrastructure we operate ourselves, which is a large part of why the webhook endpoint is always reachable. The customer only ever sees a button they recognise. That's the entire point.

Want us to publish something specific?

Tell us what you'd like to read and we'll add it to our writing queue.

Get the next one in your inbox

New articles, videos and the occasional engineering note — a short mail when there’s something worth reading, nothing else.