`paytr` — PayTR Ödeme ve Elektronik Para Hizmetleri A.Ş.

paytr — PayTR Ödeme ve Elektronik Para Hizmetleri A.Ş.

> Tip: Ödeme kuruluşu (PSP) — BDDK lisanslı, iframe checkout odaklı > Adapter'lar: InvumPosGatewayPayTR* > Son senkron: 2026-05-04 (Hafta 14 maratonu — 3PG decrypt fix; sandbox akışı henüz tam doğrulanmadı, order #64 pending) > Last-known-good API: PayTR iframe API (2026-Q2)

PayTR'nin iki ana ürünü var: iframe checkout ve direct API. Biz iframe kullanıyoruz — kart bilgileri PayTR tarafında toplanıyor, PCI yükü PayTR'da. Direct API biraz farklı endpoint, biz yokuz.

PayTR'nin production ve sandbox aynı endpoint üzerinden çalışıyor — sandbox/production ayrımı merchant kimlik bilgileriyle yapılıyor (test_mode field'ı 1 veya 0 request body'sinde gönderiliyor). Bu iyzico/Param'dan farklı; sandbox URL yok.


1. Bağlantı bilgileri

| Ortam | Base URL | Notlar | |————|—————————————|—————————————————| | Sandbox | https://www.paytr.com/... | Test mode aynı host'ta, request'te test_mode=1 | | Production | https://www.paytr.com/... | test_mode=0 veya alanı omit |

Merchant panel: https://www.paytr.com/magaza/giris (sandbox + prod aynı panel, hesap moduna göre) Dev portal: https://dev.paytr.com (resmi dokümantasyon — Türkçe + EN) Mailing list: Yok (resmi); duyurular merchant panele ve hesap email'ine düşer. Yetkili temsilci: TODO (production sözleşmesi imzalandığında destek hattı + key account) Destek email: destek@paytr.com


2. Auth şeması — paytr_token HMAC-SHA256

PayTR her endpoint için farklı bir hash string formülü istiyor. Yanlış sırayla concatenate edersen sessizce reddediyor; hata mesajı status:error, reason:hash_error veya generic "geçersiz istek". Spec'i harfiyen takip et.

Charge (iframe token al) hash

hash_str    = merchant_id + user_ip + merchant_oid + email + payment_amount
              + user_basket + no_installment + max_installment + currency
              + test_mode
paytr_token = base64( hmac_sha256(hash_str + merchant_salt, merchant_key, raw=true) )

Refund hash (iyade)

hash_str    = merchant_id + merchant_oid + return_amount_kurus + merchant_salt
paytr_token = base64( hmac_sha256(hash_str, merchant_key, raw=true) )

Status query hash (durum sorgu)

hash_str    = merchant_id + merchant_oid + merchant_salt
paytr_token = base64( hmac_sha256(hash_str, merchant_key, raw=true) )

Callback hash verify (gelen)

hash_str    = merchant_oid + merchant_salt + status + total_amount
expected    = base64( hmac_sha256(hash_str, merchant_key, raw=true) )

Callback'te gelen hash alanını expected ile hash_equals() karşılaştır (timing attack koruması).

Önemli detay: Tüm hash'lerde hmac_sha256 çağrısı raw=true (binary output). Sonra base64_encode() ile ASCII'ye çevriliyor. Hex output kullanırsan reddediyor.

Sırlar (encrypted store): merchant_id, merchant_key, merchant_salt. WCGateway encrypted; RefundService::bootstrap decrypt ediyor.


3. Endpoint tablosu

| İşlem | Method | URL | İstek field'ları (kritik) | Yanıt field'ları (kritik) | |———————|——–|———————————————–|——————————————————————————————–|———————————–| | Charge (iframe) | POST | https://www.paytr.com/odeme/api/get-token | merchant_id, user_ip, merchant_oid, email, payment_amount (kuruş), user_basket (b64 JSON), paytr_token, test_mode | status, token (iframe token) | | iframe display | GET | https://www.paytr.com/odeme/guvenli/<token> | (URL içinde token) | (HTML iframe) | | Callback (notify) | POST | <merchant_callback_url> (bizim endpoint) | merchant_oid, status (success/failed), total_amount, hash, failed_reason_code, failed_reason_msg, test_mode | (response: bizim "OK" stringimiz) | | Refund | POST | https://www.paytr.com/odeme/iade | merchant_id, merchant_oid, return_amount (kuruş), paytr_token | status (success/error), err_msg | | Status query | POST | https://www.paytr.com/odeme/durum-sorgu | merchant_id, merchant_oid, paytr_token | status (waiting_payment/…), payment_amount |

Currency: Default TL. PayTR diğer currency'leri sözleşmeye bağlı destekliyor. Tutar formatı: Tüm tutarlar kuruş (integer, 100 ile çarp). 12.50 TL = 1250. Bu iyzico'dan FARKLI — iyzico decimal string istiyor. merchant_oid formatı: Bizim üretim — INVUM<order_id><timestamp> (32 char limit, alfanümerik). PayTR aynı merchant_oid'i ikinci kez kabul etmiyor; retry için yeni timestamp ile yeni oid üretmek lazım.

Test kartları (sandbox):

  • 4355084355084358 — başarılı (test mode)
  • 5526080000000006 — başarısız (test mode)
  • CVV: 000, exp: gelecekte herhangi bir tarih
  • Test mode'da herhangi bir 3DS şifresi geçerli

4. Bilinen quirk'ler

#1 — status:error, reason:hash_error (sessiz reddedme)

Davranış: Charge token isteği veya refund 200 OK + JSON {"status":"error"} ile dönüyor; hata mesajı genelde "Hash hatalı" veya boş.

Kök neden: Hash string'inde alan sırası, alan eksikliği, raw vs hex output, kuruş vs decimal yanlış. PayTR çok katı, errorMessage detaylı vermiyor.

Çözüm: Hash test'lerini PHPUnit'te yazıyoruz (PayTRGatewayTest, PayTRRefundTest); spec'in alan sırasına byte-level uyduğumuzu doğruluyoruz. Yeni endpoint eklerken: önce dev portal'dan hash formülünü kopyala, birebir aynı sırada concatenate et.

#2 — Aynı merchant_oid ikinci kez reddedilir

Davranış: Müşteri 3DS'i yarım bırakıp checkout'a dönüp tekrar dener — ikinci charge token "merchant_oid daha önce kullanılmış" diye reddedilir.

Kök neden: PayTR merchant_oid'i charge başına unique tutuyor (order_id ≠ merchant_oid; bizim üretim formülü INVUM<order_id><timestamp> ile timestamp her seferinde farklı oluyor → çakışma olmuyor).

Çözüm: PayTRGateway::generate_merchant_oid() her çağrıda taze timestamp koyuyor. WC order ile mapping için extract_order_id() regex var.

#3 — Order #64 pending kalmış (Hafta 14 sandbox testi)

Davranış: Cenk sandbox'ta charge başlattı, 3DS sayfasına gitti ama callback gelmedi; WC order "pending payment" durumunda kaldı.

Olası kök nedenler:

  • 3DS doğrulamasını tamamlamadı (test kart şifresi yanlış)
  • PayTR callback URL'imize ulaşamadı (sandbox PayTR sunucu → bizim VPS, DNS/SSL yok henüz)
  • Callback geldi ama hash mismatch (test_mode farklı olabilir)

Çözüm (Faz 1.5 backlog):

  • Pending Order Cron (zaten var, Cron/PendingOrderQueryCron) status query ile pending order'ları reconcile ediyor — 30 dk'da bir koşar, #64'ü çekmesi gerek
  • DNS gelince callback URL HTTPS olur, sandbox'tan gelen istekler temiz çalışır

#4 — Callback "OK" yanıtı zorunlu

Davranış: PayTR callback URL'imize POST atar; biz plain-text "OK" string'i ile 200 dönmezsek PayTR 5 dk'da bir tekrar dener (24 saate kadar).

Çözüm: PayTRCallbackController callback handler'ı header Content-Type: text/plain, body OK ile döndürür.

#5 — test_mode request body'de tek char

PayTR test_mode=1 (string "1") veya test_mode=0 bekliyor; boolean true/false olarak gönderirsen reddediyor. Hash'e de string olarak girer.

#6 — user_basket base64-JSON

basket field'ı: [[name, unit_price, quantity], ...] array → JSON encode → base64 encode. Hash'e base64 string'i girer (JSON değil).


5. Bizim implementasyon notları

  • WCGateway: PayTRWCGateway — slug invum_pos_paytr. Encrypted:

merchant_id, merchant_key, merchant_salt.

  • Charge + callback verify + status: PayTRGateway

charge() → iframe token al → iframe URL döndürür – validate3DCallback($_POST) → hash verify, success/failed – getStatus() → durum-sorgu endpoint

  • Refund: PayTRRefund (RefundInterface) — /odeme/iade çağrısı
  • StatusQuery: PayTRStatusQuery — pending order cron için

(/odeme/durum-sorgu)

  • Callback REST endpoint: POST /wp-json/invum-pos/v1/paytr/callback,

PayTRCallbackController. PayTR sunucudan-sunucuya POST yapıyor (browser yönlendirme YOK — iyzico'dan farklı). Response "OK" string'i.

Persist edilen field'lar (wp_invum_pos_transactions):

  • transaction_id = PayTR merchant_oid (bizim ürettiğimiz: INVUM<order_id><ts>)
  • payment_transaction_id = boş (PayTR'da iyzico gibi per-item ID yok)
  • raw_response = callback POST body, masked

Faz 1.5+ backlog:

  • Direct API mode (iframe yerine) — bazı müşteriler kendi formlarını istiyor
  • Tekrarlayan ödemeler (recurring) — PayTR API'sinde var, biz desteklemiyoruz
  • Wallet ödemeleri (Papara, Maxipara) — PayTR çoklu method açıyor

6. Test connection (Pro modülü)

CredentialPresenceTest ne kontrol ediyor:

  • merchant_id + merchant_key + merchant_salt boş mu

Eksik (Faz 2): Gerçek /odeme/durum-sorgu ile dummy oid sorgusu → "merchant bulunamadı" yerine "oid bulunamadı" cevabı geliyorsa credentials doğru.


7. Saved Cards / Vault (Hafta 15 — uygulandı)

> Durum (2026-05-05): Direct API + vault adapter ailesi shipped. > F5 commit zinciri: b39da9135f5be8b735c57330dc31 → > 3bcd83b05cfd991dc2be10dbcd6b9b9398f. > Kritik (hâlâ geçerli): PayTR'nin iframe API'sı saved-card desteklemiyor — > iframe-api-1-adim payload'unda store_card/save_card field'ı YOK. > Vault için Direct API'a (Direkt Kart Saklama API) geçildi, paralel adapter > ailesi olarak. Mevcut iframe akışı (PayTRGateway / PayTRWCGateway) bozulmadı; > Direct akışı opsiyonel (admin'den enable edilir). > Shipped adapter'lar: PayTRSignature (vault hash), PayTRCardVault > (list/delete/charge_with_saved_card/charge_fresh_card), PayTRDirectGateway > (GatewayInterface wiring), PayTRVaultFinalizer (utoken→ctoken cron worker), > PayTRDirectWCGateway (WC checkout integration). > PCI implikasyonu: PAN/CVV bizim formumuzda toplanır → SAQ A-EP scope > (bkz. iyzico.md §7.5). Iframe gateway'i kullanmaya devam eden merchant'lar SAQ A > kalır; sadece Direct'e geçenler scope büyütür.

7.1. Token modeli

PayTR iki opaque token üretir; ikisi de PayTR tarafında. Bizde sadece referans tutulur:

  • utoken — per-merchant per-user token. İlk "Add card" başarılı

callback'inde döner. WC user_id (veya guest email) ile eşleyip saklarız.

  • ctoken — per-card token. List endpoint'inden döner; ödeme ve

silme çağrılarında kullanılır.

7.2. Endpoint tablosu

| İşlem | Method | URL | Hash payload (concat → Base64(HMAC-SHA256(payload+salt, key, raw=true))) | |——————————–|——–|————————————————|—————————————————————————–| | Yeni kart ekle (charge + store)| POST | https://www.paytr.com/odeme | merchant_id + user_ip + merchant_oid + email + payment_amount + payment_type + installment_count + currency + test_mode + non_3d | | Saklı kartla ödeme | POST | https://www.paytr.com/odeme | aynı (yukarıdaki) — payload'da utoken+ctoken | | Tekrarlayan (recurring) ödeme | POST | https://www.paytr.com/odeme | aynı + recurring_payment=1, non_3d=1 | | Saklı kartları listele | POST | https://www.paytr.com/odeme/capi/list | utoken + merchant_salt | | Saklı kart sil | POST | https://www.paytr.com/odeme/capi/delete | ctoken + utoken + merchant_salt |

Önemli: Hash formülü endpoint başına değişiyor. PayTRSignature::charge() mevcut metodu reuse edilemez — yeni vaultList(), vaultDelete(), vaultCharge() metotları eklenecek.

7.3. Akış — Tokenize during first card payment

1. Checkout: kullanıcı kart bilgilerini bizim formda girer (PAN/expiry/CVV)
   ↳ Direct API; iframe atlanır
   ↳ "Bu kartı kaydet" checkbox işaretli
2. POST https://www.paytr.com/odeme
   body: merchant_id, user_ip, merchant_oid, email, payment_amount,
         payment_type=card, cc_owner, card_number, expiry_month, expiry_year, cvv,
         installment_count (0 veya 2-12), currency, test_mode,
         user_basket, merchant_ok_url, merchant_fail_url,
         store_card=1,
         utoken (varsa — quirk #V2 önemli)
   3DS by default; non_3d=1 ile bypass (merchant'ın 3DS-bypass yetkisi varsa)
3. PayTR → 3DS HTML challenge → tarayıcıda render
4. 3DS başarı → tarayıcı merchant_ok_url'e redirect (sadece informational)
5. Asıl onay: PayTR S2S callback → bizim notify URL'imize POST:
     merchant_oid, status, total_amount, hash, **utoken** (callback'te döner)
   ↳ ⚠️ **`ctoken` callback'te YOK** — sadece /capi/list yanıtında gelir
6. Callback handler: utoken'ı geçici cache'e yaz, **30 saniye sonra**
   wp_schedule_single_event ile /capi/list çağrısı yapan worker tetikle
   (PayTR token propagation gecikmesi olabiliyor; instant call boş list döndürebilir)
7. Worker /capi/list yanıtından ctoken alır → wp_invum_pos_saved_cards INSERT
   (user_id, gateway='paytr', gateway_user_token=utoken,
   gateway_card_token=ctoken, masked_pan, brand, expiry_month, expiry_year)

Not: Bu 2-step flow (callback'te utoken topla, scheduled cron'da ctoken al) zorunlu — PayTR saved-card metadata'sı tek istekle dolmuyor. Gurmepos-pro v6 (3-parti referans) tam bu pattern'i kullanıyor. Bizim implementasyon wp_schedule_single_event('invum_pos_paytr_finalize_save_card', ...) ile aynı yolu takip edecek.

7.4. Akış — Charge with saved card

1. Server-side: POST /odeme/capi/list { merchant_id, utoken, paytr_token (utoken+salt hash) }
   ↳ Yanıt: cards[] — { ctoken, last_4, month, year, c_bank, c_brand, c_type, schema, require_cvv }
2. Render kart seçici. Eğer require_cvv=1 ise CVV input zorunlu (PayTR tarafından
   per-card belirleniyor; override edilemez — quirk #V4)
3. POST https://www.paytr.com/odeme
   body: merchant_id, user_ip, merchant_oid, email, payment_amount,
         utoken, ctoken, [cvv,]  ← raw PAN YOK
         + standart charge payload
4. 3DS varsa challenge → ok_url redirect; recurring ise (non_3d=1, recurring_payment=1)
   server-to-server JSON döner: status ∈ {success, failed, wait_callback}
5. Aynı callback semantics — utoken/ctoken zaten persist edildi

7.5. Vault-specific quirk'ler

#V1 — iframe API tokenize DESTEKLEMİYOR

PayTR'nin iframe API'sı (mevcut akışımız) saved-card için tasarlanmamış. iframe-api-1-adim payload'unda store_card/save_card field'ı yok. Vault için Direct API'a geçmek tek yol.

Mimari etki: Vault feature'ı PayTR için iframe akışıyla coexist edecek — kullanıcı saklı kartı yoksa eski iframe akışına düşer; varsa Direct API ile ödeme yapar. İki ayrı charge yolu, iki ayrı UX.

#V2 — utoken grouping bug

İkinci kart kaydında mevcut utokenmutlaka payload'a ekle. Aksi takdirde PayTR yeni bir utoken üretir → ilk kart yeni utoken'la eşleşmez, kullanıcı için kart "kayıp" görünür.

Çözüm: Vault tablosunda gateway_user_token (UNIQUE per user_id+gateway). Kart eklemeden önce read et, payload'a ekle.

#V3 — Sandbox callback gelmiyor (mevcut sorun)

docs/integrations/paytr.md §4 quirk #3'te dokümante; sandbox PayTR S2S callback'lerini güvenilmez gönderiyor. Vault için bu kritikutoken/ctoken sadece callback'te geliyor, redirect URL'de YOK. Sandbox'ta vault testi için status query veya manuel reconcile gerekecek.

#V4 — require_cvv per-card, override edilemez

List response'unda her kart için require_cvv flag'i var. Bazı kartlarda CVV gerekli (özellikle yüksek tutar / yeni kart), bazılarında değil. Override edilemez. UX her zaman CVV input'una hazır olmalı.

#V5 — Hash formülü endpoint başına değişiyor

| Endpoint | Hash payload | |———————————|———————————————| | /odeme (charge/save/recurring)| 10 field concat (mevcut formül) | | /odeme/capi/list | utoken + salt (kısa) | | /odeme/capi/delete | ctoken + utoken + salt |

Çözüm: PayTRSignature sınıfına 3 yeni method ekle. Mevcut charge()'ı reuse etme — sessiz hash_error ile reddeder.

#V6 — Amex (15-digit PAN) belirsiz

Spec'te card_number 16 chars deniyor ama list response'unda c_brand=AMEX örneği var. Amex desteği belirsiz — sandbox'ta test edilmeli, gerekirse brand whitelist'inden çıkarılır.

#V7 — utoken lookup endpoint'i YOK

PayTR merchant_oid veya email ile utoken sorgulama endpoint'i sunmuyor. Vault tablosu kaybolursa utoken kaybolur — kullanıcı bütün saklı kartlarını yeniden kayıt etmek zorunda kalır.

Çözüm: Vault tablosu için DB backup stratejisi (Faz 2; production deploy öncesi mutlaka).

7.6. Sandbox testi açık sorular

  1. Sandbox callback gelmeyince utoken/ctoken alternatif yolla alınabilir mi? (durum-sorgu yanıtında var mı?)
  2. "Tokenize without charge" zero-amount auth desteği var mı? yeni-kart-ekleme charge ile coupled görünüyor.
  3. Refund flow saved-card-originated merchant_oid için aynı /odeme/iade mi yoksa farklı endpoint mi?
  4. Amex 15-digit PAN gerçekten kabul ediliyor mu, yoksa silently 16-only?
  5. recurring_payment=1 non-3DS akışı için merchant onayı nasıl alınır (BDDK uyum)?

7.7. Kaynaklar


8. Değişiklik geçmişi

| Tarih | Sürüm | Değişiklik | Senkron eden | |————|—————-|—————————————————————————|————–| | 2026-05-05 | iframe + Direct| §7 Saved Cards / Vault spec araştırması — Direct API'a geçiş gerekecek | Claude+Cenk | | 2026-05-04 | iframe API | Pre-insert pending row mimarisi: WCGateway::process_payment iframe token alır almaz tx satırı insert eder (status=pending, transaction_id=merchant_oid). Callback insert yerine update_status_by_transaction_id kullanır + missing-row fallback'i korur. Sandbox callback gelmezse PendingOrderQueryCron PayTRStatusQuery ile reconcile edebiliyor. | Claude+Cenk | | 2026-05-04 | iframe API | reference_no underscore sanitization (wc_refund_42wcrefund42) — PayTR alfanumerik dışı karakter reddediyor | Claude+Cenk | | 2026-05-04 | iframe API | İlk doldurma — Hafta 14 sonrası tüm hash şemaları + quirk'ler dokümante | Claude+Cenk | | 2026-05-03 | iframe API | RefundService::bootstrap encrypted credentials decrypt fix | Claude+Cenk | | 2026-04-22 | iframe API | İlk PayTR adapter (charge + callback + refund + iade) — Hafta 6 | Claude+Cenk |

Alışveriş Sepeti