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— sluginvum_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= PayTRmerchant_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: b39da91 → 35f5be8 → b735c57 → 330dc31 → > 3bcd83b → 05cfd99 → 1dc2be1 → 0dbcd6b → 9b9398f. > 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 utoken'ı mutlaka 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 kritik — utoken/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
- Sandbox callback gelmeyince utoken/ctoken alternatif yolla alınabilir mi? (durum-sorgu yanıtında var mı?)
- "Tokenize without charge" zero-amount auth desteği var mı?
yeni-kart-eklemecharge ile coupled görünüyor. - Refund flow saved-card-originated
merchant_oidiçin aynı/odeme/iademi yoksa farklı endpoint mi? - Amex 15-digit PAN gerçekten kabul ediliyor mu, yoksa silently 16-only?
recurring_payment=1non-3DS akışı için merchant onayı nasıl alınır (BDDK uyum)?
7.7. Kaynaklar
- iFrame API 1. Adım — saved-card desteklenmediği teyidi
- Yeni Kart Ekleme
- Kayıtlı Karttan Ödeme
- Kayıtlı Kart Listesi
- Kayıtlı Kart Silme
- Kayıtlı Kart Tekrarlayan Ödeme
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_42 → wcrefund42) — 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 |