DEV Community

Mahmut Gündüzalp
Mahmut Gündüzalp

Posted on

Turkish E-commerce: Why Local POS Integration Beats Stripe (Most of the Time)

Turkish E-commerce: Why Local POS Integration Beats Stripe (Most of the Time)

If you're an English-speaking developer building e-commerce in any market, your default payment integration is Stripe. It's a great default. It's documented, it's fast to integrate, it has SDKs for every language, and the API surface is among the cleanest in the industry.

It's also the wrong default if your customers live in Turkey.

This is a write-up of what we learned running 200+ production e-commerce sites in Turkey over the last 18 months: why Stripe alone doesn't cut it, what the local payment landscape actually looks like, the unified interface pattern we use to manage 15+ bank gateways from a single PHP codebase, and the cost numbers that justify all the extra engineering work.

1. The Stripe Assumption (And Why It Breaks Here)

Stripe operates in Turkey. You can technically take TRY payments through Stripe. So why isn't that the end of the story?

Three reasons, in order of weight:

Reason 1: Transaction fees compound. Stripe charges around 1.4% + ₺1.40 per successful card transaction in TRY, with currency conversion and cross-border markups stacking on top in some flows. A native bank virtual POS gateway typically charges 0% transaction fee — the bank takes its cut from the merchant agreement at the bank level, not per-transaction. For a store doing ₺2M/year in volume, that's roughly ₺28,000/year that doesn't have to leave the merchant.

Reason 2: Installments (taksit) are a feature, not a payment method. Turkish consumers expect to see "3 ay taksit ile ₺X" alongside every product price. Installment plans are negotiated between the merchant and the issuing bank — each bank has its own installment rules, its own commission tiers, and its own "premium" cards that get extended installments. Stripe has no equivalent surface for this. You can simulate installments with a recurring subscription, but that's not what customers see at checkout, and conversion drops accordingly.

Reason 3: TRY-native ledger. Stripe settles internationally; even when collecting in TRY, the reconciliation layer is built around a multi-currency model that assumes you'll eventually want to convert. Most Turkish merchants want a Turkish lira ledger that matches their e-fatura (e-invoice) records line-for-line, with VAT broken out the way GİB (Turkish tax authority) expects it. Native bank POS does this natively.

The combined effect: Stripe works, but it bleeds money on transaction fees, kills your installment funnel, and adds a reconciliation step that your accountant doesn't want.

2. The Local Payment Landscape

Here's the actual list of payment surfaces a serious Turkish e-commerce store needs to support, at minimum:

Tier-1 bank virtual POS (direct integration with the issuing bank):

  • Garanti BBVA
  • İş Bankası
  • Akbank
  • Ziraat Bankası
  • Halkbank
  • VakıfBank
  • Yapı Kredi
  • TEB
  • DenizBank
  • QNB Finansbank

Tier-2 payment aggregators (one integration, many banks underneath):

  • iyzico (Visa-owned, biggest player)
  • PayTR
  • Param
  • Moka
  • Paycell (Turkcell)
  • Sipay
  • Hepsipay (Hepsiburada-owned)

Tier-3 alternative methods:

  • Papara (Turkish digital wallet)
  • BKM Express (interbank wallet)
  • Apple Pay / Google Pay (over local processors)
  • Cash on delivery (still ~15% of orders in some categories)
  • Bank transfer with auto-matching (havale eşleştirme)

That's 15+ direct gateways and at least 5 alternative payment surfaces. Realistically, a mature Turkish store integrates 5-8 of these — but the engineering problem is that any one of them might be the cheapest path on a given transaction, depending on the buyer's card BIN and the merchant's bank agreement.

3. The Interface That Unifies Them All

The architectural problem looks scary the first time you face it: 15 different APIs, 15 different XML/JSON formats, 15 different 3-D Secure callback patterns, 15 different error code sets, 15 different "test card" lists.

The solution we converged on (and which I'd recommend to anyone hitting this problem) is the classic adapter pattern: one interface, one set of value objects, one error taxonomy. Each gateway gets an adapter class.

interface PaymentGatewayInterface
{
    public function getCode(): string;
    public function getDisplayName(): string;

    public function preparePayment(PaymentRequest $req): PaymentPrepared;

    public function handleThreeDSCallback(array $callback): ThreeDSResult;

    public function captureAuthorized(string $txRef, Money $amount): CaptureResult;

    public function refund(string $txRef, Money $amount, ?string $reason = null): RefundResult;

    public function getInstallmentOptions(string $cardBin, Money $amount): array;
}
Enter fullscreen mode Exit fullscreen mode

The PaymentRequest value object normalizes the input across all gateways: card BIN, amount in TRY minor units, installment count, merchant order reference, return URLs, customer billing address. Same call signature, regardless of which bank is on the other end.

Each adapter implementation translates this normalized request into whatever the bank expects — usually XML over HTTPS for tier-1 banks, JSON for aggregators, sometimes WSDL/SOAP for legacy stacks. The translation layer is the boring part. The interesting part is the next section.

4. 3-D Secure Callback: Handling 15 Different Protocols

3-D Secure is a regulatory requirement on most card transactions in Turkey since 2020. The flow looks the same from the customer side — you redirect to the bank, the customer enters an SMS code, they redirect back — but the integration side varies wildly.

Concrete differences across providers:

  • Callback method: POST vs GET vs both. iyzico does POST. Some legacy banks do GET. Some do POST but expect you to verify a hash on the next page load.
  • HMAC verification: SHA1, SHA256, SHA512, sometimes a custom hash with secret prefix. Order of fields in the hash payload matters and isn't always documented.
  • Status field naming: mdStatus, status, result, tdStatus, auth_result — different vocabulary, sometimes different value sets.
  • Status semantics: "1" can mean "3-D Secure success, proceed to auth" on one gateway and "fully authorized, capture done" on another. Confusing the two will silently capture funds without finishing 3-D, which is a compliance violation.

The pattern that saved us was a per-gateway ThreeDSValidator class that returns a normalized ThreeDSResult enum:

enum ThreeDSOutcome:
  AUTHORIZED            // captured, money on its way
  AUTHENTICATED_ONLY    // 3-D passed, auth still pending
  CHALLENGE_REQUIRED    // friction beyond 3-D (rare)
  REJECTED              // explicit fail
  TIMEOUT               // no response, treat as fail
  TAMPER_DETECTED       // HMAC failed, log + alert
Enter fullscreen mode Exit fullscreen mode

The order routing layer doesn't care which bank just called us back. It cares only about which of those six outcomes happened. That decoupling is the single most valuable thing in the whole stack — it means adding a new gateway is a 200-line adapter and a test fixture, not a redesign.

5. Real Cost Comparison: Stripe vs Native (3-Year Data)

Numbers from a representative mid-sized merchant in our portfolio. Online fashion, ~₺3.2M annual volume, average basket ₺240, ~13,300 orders/year. Card mix: 78% Turkish-issued credit, 18% Turkish debit, 4% international.

Scenario A — Stripe-only:

  • Transaction fee: 1.4% + ₺1.40 on Turkish cards (after volume discount). Roughly ₺44,800 + ₺18,620 = ₺63,420/year in transaction fees.
  • Foreign cards (4%, ~₺128k volume): 2.9% + ₺2 = ₺3,712 + ₺1,064 = ₺4,776
  • Installment funnel: not available natively. Either skipped (conversion drops ~12-18% on baskets over ₺1,000) or hacked via off-platform subscription (compliance grey area).
  • Reconciliation: TRY/USD ledger split, manual e-invoice mapping.

Scenario B — iyzico + 4 direct bank gateways (Garanti, İş, Akbank, Ziraat):

  • Aggregator fee on iyzico-routed transactions (~30% of volume): 2.4% blended ≈ ₺23,040/year
  • Direct bank fees on routed transactions (~70% of volume): 0% transaction, bank takes monthly fixed fee ≈ ₺18,000/year total across 4 banks
  • Installment funnel: full native, all banks expose their installment offers
  • Reconciliation: TRY-native, line-for-line with e-arşiv records
  • Total: ₺41,040/year

Difference: ₺27,156/year in direct fees, plus the conversion lift from installments — which on this volume mix is worth roughly another ₺180-220k in additional revenue.

The catch is the engineering investment. Building and maintaining the gateway layer is real work — call it 60-80 engineering days for the first build, plus 10-15 days/year of maintenance as banks shuffle their APIs. For merchants under ~₺1M annual volume, Stripe is genuinely the right call: the savings don't outrun the engineering cost. Above that line, native wins, every time.

6. When Stripe Still Wins

Honest take: there are still cases where Stripe is the right answer even in Turkey.

  • You sell mostly to non-Turkish customers in TRY (export-heavy stores, language schools selling to expats). Stripe's multi-currency surface is genuinely useful, and you don't need installments.
  • You're pre-product-market-fit. Don't sink 60 days into a payment layer when you're not sure anyone wants to buy. Ship with iyzico (one integration, decent coverage) and migrate later if volume justifies it.
  • You operate B2B with invoice-based settlement. Card transactions are a tiny fraction of revenue; the payment gateway is not your bottleneck.
  • You're on a serverless/SaaS PHP-incompatible stack where the maintenance overhead of bank adapters falls on a team that doesn't have Turkish-language docs comprehension. Hiring local engineers for that is more expensive than the fees.

For everyone else — every Turkish-market store with national reach and >₺1M volume — local POS is not optional. It's table stakes.

7. Conclusion: Local Fintech Isn't Optional

The mistake we made early on was treating Turkish payments as "Stripe with a TRY currency code." That mental model produces a working store and a slowly bleeding P&L. The right mental model is: this is a market with its own payment culture, its own regulatory frame, and its own gateway ecosystem, and the engineering effort to plug into it is one of the highest-ROI investments a Turkish e-commerce engineering team can make.

If you're starting fresh, build the adapter layer from day one even if you only ship with one gateway initially. The interface costs almost nothing to write up front. Retrofitting it later — when your order layer has direct calls to iyzico_client->charge() scattered across 40 files — is the part that's painful.

For more on the broader Turkish e-commerce engineering stack (CMS, bot integrations, AI cost optimization), see my earlier post on building a multi-LLM news CMS and the React-to-HTMX migration writeup. Same production stack, different surface.


Working on Turkish e-commerce or news CMS infrastructure? I run Alesta WEB, an Şanlıurfa-based software shop building this kind of platform for the Turkish market since 2005. Happy to compare notes.

Top comments (0)