İçeriğe geç
KURUMSAL Kurumsal teknoloji · İşletmenize özel mühendislik Kurumsal Yazılım · Yönetilen Sunucular · Kurumsal E-posta · LiteSpeed + CloudLinux · Imunify360 Güvenlik · cPanel Yönetim · Uygun pakette 3 Gbps'ye kadar ağ profili
AIOR

iyzico Entegrasyonu: Checkout Form, 3D Secure, İade ve Webhook

  • Konu başlatıcısı Konu başlatıcısı Aior
  • Başlangıç tarihi Başlangıç tarihi

Aior

Administrator

Yetkili üye
Katıldı
2 Nis 2023
Mesajlar
929
Tepki puanı
2
Puan
18
Yaş
41
Konum
Turkey
Web sitesi
aior.com
Konu sahibi

Özel bir siteye, bir WHMCS kurulumuna ya da kendi e-ticaret altyapınıza iyzico eklerken hep aynı sorularla karşılaşırsınız: Barındırılan ödeme formu (Checkout Form) mu, doğrudan API mi? 3D Secure nasıl tamamlanacak? Gelen sonucun gerçekten iyzico'dan geldiğini nasıl kanıtlayacaksınız? İade nasıl işleyecek? API çağrıları işin kolay kısmı; hatalar kenarlarda çıkar: fiyatı tutmayan bir sepet, sondaki bir sıfır yüzünden başarısız olan imza doğrulaması, framework'ün reddettiği bir geri çağırma isteği, sayfa yenilenince iki kez ödendi sayılan bir sipariş. Bu rehberde resmî iyzipay-php SDK'sıyla, gereken yerde Node.js örneğiyle uçtan uca bir iyzico entegrasyonunu anlatıyoruz. Uç noktaları, alan sıralarını ve limitleri Ekim 2026'da docs.iyzico.com ve SDK kaynak kodlarıyla karşılaştırdık; hesabınıza özgü ayrıntıları canlıya geçmeden önce iyzico ile teyit edin. Henüz sağlayıcı seçmediyseniz önce Stripe, iyzico, PayTR ve Adyen karşılaştırmamıza göz atın.

Bir iyzico ödemesi nasıl işler?​

Bütün akışların iskeleti aynıdır: Sunucunuz ödemeyi başlatır (initialize), müşteri ödemeyi sizin denetiminizde olmayan bir sayfada tamamlar (iyzico ödeme formu ya da bankanın 3D Secure sayfası), tarayıcı geri çağırma adresinize (callbackUrl) bir POST isteğiyle döner, sunucunuz da sonucu sunucudan sunucuya bir çağrıyla (retrieve ya da auth) kesinleştirir. Webhook bildirimi bunlardan bağımsız ikinci bir kanal olarak sonradan gelebilir.
  • Kimlik bilgileri: Her ortam için bir API anahtarı ve bir güvenlik anahtarı (secret key). Sandbox anahtarları "sandbox-" ile başlar; sandbox ve canlı çiftleri birbirinden tamamen ayrıdır.
  • Kimlik doğrulama: IYZWSv2 sözcüğü ile bir base64 metninden oluşan Authorization başlığı. Bu metin API anahtarını, rastgele bir anahtarı ve güvenlik anahtarıyla hesaplanan HMAC-SHA256(rastgele anahtar + URI yolu + istek gövdesi) değerini taşır. SDK'lar bu başlığı sizin yerinize üretir; elle yazılmış istemciler 1000 (Invalid signature, geçersiz imza) hatasının olağan nedenidir.
  • conversationId (sizin, en fazla 255 karakter): Yanıtlarda, raporlarda ve paymentConversationId adıyla webhook'larda aynen döner. Sipariş numarasını bir deneme sayacıyla birleştirin, örneğin ord-10482-2.
  • basketId (sizin): Sipariş ya da fatura numarası.
  • token (iyzico; ödeme formu ve iyzico ile Öde): Başlatmada üretilir, callback adresinize POST edilir, sonucu sorgulamak için gerekir.
  • paymentId (iyzico): İptal, tutar bazlı iade, ödeme sorgulama ve destek talepleri için.
  • paymentTransactionId (iyzico; her sepet kalemi, yani her ödeme kırılımı için bir tane): Kalem bazlı iade ve pazaryeri onayı için. Ürün kimliğinizle birlikte saklayın.

Ödeme formu mu, iyzico ile Öde mi, doğrudan API mi?​

SeçenekKart bilgisi nereye girilir?PCI DSS kapsamı (yaklaşık)EforTipik kullanım
Ödeme formu, yönlendirme (paymentPageUrl)iyzico'nun barındırdığı sayfaEn dar: Kart verisi sayfalarınıza ve sunucularınıza hiç ulaşmazDüşükÇoğu e-ticaret sitesi, WHMCS, SaaS faturalama
Ödeme formu, gömülü (responsive ya da popup)iyzico betiğinin sizin sayfanızda oluşturduğu formDar; ancak sayfanız ve üzerindeki betikler artık önemlidirDüşük-ortaMüşteriyi sitede tutmak isteyen mağazalar
iyzico ile Öde (PWI)Müşteri iyzico cüzdanına giriş yapar (kayıtlı kart, bakiye)Barındırılan sayfadaki gibiDüşükCüzdan kullanıcıları; ödeme formunda sekme olarak da var
Doğrudan API, 3DSKendi formunuz; kart numarasını sunucunuz iyzico'ya gönderirTam: Sistemleriniz kart verisi işler (SAQ D bölgesi)YüksekÖzel ödeme ekranı, uygulama backend'i, saklı kartlar
Doğrudan API, Non-3DSAynısı, banka yönlendirmesi olmadanTamOrta-yüksekYalnızca hesabınız izin veriyorsa; saklı kartla tahsilat
PCI sütunu bir değerlendirme değil, başlangıç noktasıdır; öz değerlendirme (SAQ) türünüzü iyzico ya da denetçinizle netleştirin. Pazaryeri ve Abonelik bu yöntemlerin üzerine kurulan hesap ürünleridir, aşağıda ayrıca ele alınıyor. Maliyeti belirleyen kalemler kodun dışındadır: Komisyon ve taksit koşulları üye işyeri anlaşmanızdan gelir; kart saklama, çoklu para birimi ve abonelik paneldeki ayrı eklentilerdir (abonelik ücretlidir); doğrudan API ise sizin tarafınıza PCI DSS yükü ekler.
  • Ödeme formunun yönlendirme modeliyle başlayın. 3D Secure'u, taksitleri, iyzico cüzdanını ve açıksa havale/EFT ile alışveriş kredisini kendisi yönetir; kart verisini de sistemlerinizin dışında tutar.
  • Gömülü model, iyzico paketini yükleyen satır içi (inline) bir betik olarak gelir; bu yüzden katı bir Content-Security-Policy onu bozar. paymentPageUrl sonuna &iframe=true eklenen iframe modeli ya da düz yönlendirme bu sorunu yaşamaz.
  • Doğrudan API'yi yalnızca kendi kart formunuza ya da kendi yöneteceğiniz kart saklamaya ihtiyacınız varsa seçin ve PCI DSS yükünü kabul edin.
  • Mobil uygulamalar: Mobil SDK yoktur. Uygulama sizin backend'inizle, backend de iyzico ile konuşur; API anahtarları asla uygulamaya gömülmez.

Sandbox, anahtarlar ve test kartları​

Test hesabını sandbox-merchant.iyzipay.com adresinden açın. Kayıtta e-posta ya da SMS gönderilmez: iyzico SSS'sine göre SMS şifresi olarak 123456 yazılır, belge adımını da herhangi bir PNG dosyası geçer. Anahtarlar panelde Ayarlar > Firma Ayarları sayfasındadır; onları depoya değil, ortam değişkenlerine ya da bir sır yöneticisine (secret manager) koyun.
INI:
# .env (sandbox). Canlı: https://api.iyzipay.com ve canlı anahtar çifti
IYZICO_API_KEY=sandbox-xxxxxxxxxxxxxxxx
IYZICO_SECRET_KEY=sandbox-xxxxxxxxxxxxxxxx
IYZICO_BASE_URL=https://sandbox-api.iyzipay.com
Bash:
composer require iyzico/iyzipay-php   # PHP 7.4 ve üzeri
npm install iyzipay                   # Node.js
pip install iyzipay                   # Python 3.9 ve üzeri
Java ve .NET istemcileri ile WooCommerce, Magento 2, OpenCart, PrestaShop ve nopCommerce eklentileri de github.com/iyzico üzerinde yer alıyor. Dokümandaki test kartları (ileri bir son kullanma tarihi ve doğru biçimde bir CVC yeterli):
Kart numarasıSonuç
5528790000000008Başarılı: Halkbank, Mastercard kredi kartı
4766620000000001Başarılı: Denizbank, Visa banka kartı
9792030000000000Başarılı: QNB, Troy kredi kartı
5400010000000004Başarılı: yabancı kredi kartı
4111111111111129Yetersiz bakiye
4131111111111117Başarılı ama mdStatus 0 (3DS başarısız)
41511111111111123D Secure başlatma başarısız
5406670000000009Başarılı, ancak iptal, iade ya da provizyon kapama yapılamaz
NOK ve CHF sandbox'ta çalışmaz. Yeni bir anahtar çiftini denemek için BIN sorgulama servisini çağırın: Başarılı yanıt, kimlik doğrulamanızın doğru olduğunu gösterir.
Canlıya geçiş: Canlı anahtarları yalnızca kimliği doğrulanmış yasal temsilci ve yalnızca web panelinde görür ("İşim İçin" uygulamasında görünmez); bireysel hesaplarda API kullanımı yoktur. Base URL'i değiştirin, sitenizde gerçek kartla 5-10 TL'lik bir ödeme alın, panelde İşlemler sekmesinde bulup iade edin; hesap durumunuz ardından güncellenir. Başvurunuzu tamamladıktan sonraki 30 gün içinde başarılı ödeme alınmazsa iyzico, siz destek ekibine yazana kadar API erişimini kısıtlar. Ortamlarınızı SSS'deki iki kurala göre planlayın: Hesap başına tek domain (Ayarlar altındaki Back URL/IP Yönetimi) ve ilk başarılı ödemeden sonra hesap türü değiştirilemez (örneğin pazaryerine geçilemez).

Adım adım ödeme formu (PHP)​

1. Formu başlatın​

İsteği tarayıcının gönderdiği tutarlardan değil, veritabanınızdan kurun. Parayı tam sayı kuruş olarak tutun, yalnızca API'ye giderken biçimlendirin.
PHP:
require __DIR__ . '/vendor/autoload.php';

use Iyzipay\Options;
use Iyzipay\Model\{Address, BasketItem, BasketItemType, Buyer, CheckoutFormInitialize, Currency, Locale, PaymentGroup};
use Iyzipay\Request\CreateCheckoutFormInitializeRequest;

function iyzicoOptions(): Options
{
    $options = new Options();
    $options->setApiKey(getenv('IYZICO_API_KEY'));
    $options->setSecretKey(getenv('IYZICO_SECRET_KEY'));
    $options->setBaseUrl(getenv('IYZICO_BASE_URL'));
    return $options;
}

function money(int $kurus): string                 // 150050 -> "1500.50"
{
    return number_format($kurus / 100, 2, '.', '');
}

$order   = $orders->find($orderId);                // veritabanınızdan; tarayıcıdan gelen tutardan değil
$attempt = $payments->newAttempt($order);          // conversation_id örn. "ord-10482-2"

$request = new CreateCheckoutFormInitializeRequest();
$request->setLocale(Locale::TR);
$request->setConversationId($attempt->conversation_id);
$request->setBasketId((string) $order->id);
$request->setPrice(money($order->items_total));        // aşağıdaki kalemlerin toplamına eşit olmalı
$request->setPaidPrice(money($order->grand_total));    // tahsil edilecek tutar
$request->setCurrency(Currency::TL);                   // "TRY"
$request->setPaymentGroup(PaymentGroup::PRODUCT);
$request->setCallbackUrl('https://shop.example.com/payment/iyzico/callback');
$request->setEnabledInstallments([2, 3, 6, 9]);

$buyer = new Buyer();
$buyer->setId((string) $order->customer_id);
$buyer->setName($order->first_name);
$buyer->setSurname($order->last_name);
$buyer->setEmail($order->email);
$buyer->setGsmNumber($order->phone);                     // +905XXXXXXXXX
$buyer->setIdentityNumber($order->tckn ?: '11111111111'); // TCKN almıyorsanız örnek değer
$buyer->setRegistrationAddress($order->address);
$buyer->setCity($order->city);
$buyer->setCountry('Turkey');
$buyer->setIp($clientIp);
$request->setBuyer($buyer);

$address = new Address();
$address->setContactName($order->first_name . ' ' . $order->last_name);
$address->setAddress($order->address);
$address->setCity($order->city);
$address->setCountry('Turkey');
$request->setBillingAddress($address);
$request->setShippingAddress($address);                // fiziksel (PHYSICAL) kalem varsa zorunlu

$items = [];
foreach ($order->lines as $line) {                     // ürünler ve ayrı bir satır olarak kargo
    $item = new BasketItem();
    $item->setId($line->sku);
    $item->setName($line->name);
    $item->setCategory1($line->category);
    $item->setItemType($line->digital ? BasketItemType::VIRTUAL : BasketItemType::PHYSICAL);
    $item->setPrice(money($line->total));              // adet x birim fiyat, sıfırdan büyük
    $items[] = $item;
}
$request->setBasketItems($items);

$init = CheckoutFormInitialize::create($request, iyzicoOptions());
if ($init->getStatus() !== 'success') {
    throw new RuntimeException($init->getErrorCode() . ': ' . $init->getErrorMessage());
}
$payments->saveToken($attempt->id, $init->getToken()); // callback, denemeyi token ile bulur
header('Location: ' . $init->getPaymentPageUrl(), true, 303);
Yönlendirme modelinde başka bir şey gerekmez. Gömülü form için yönlendirmek yerine kapsayıcıyı ve dönen betiği sayfaya basın:
PHP:
// Gömülü model: yönlendirme yerine kapsayıcıyı ve iyzico'nun satır içi betiğini basın
echo '<div id="iyzipay-checkout-form" class="responsive"></div>';   // ya da class="popup"
echo $init->getCheckoutFormContent();
Token kısa ömürlüdür (dokümandaki örnekte tokenExpireTime 1800); müşteri sonradan dönerse yeni bir form başlatın.

2. Callback: sorgula, doğrula, bir kez işaretle​

Ödemeden sonra iyzico, sonuç başarılı da olsa başarısız da olsa, tarayıcıyı token içeren bir POST isteğiyle callbackUrl adresinize gönderir. Token tek başına hiçbir şey kanıtlamaz: Sonucu sunucudan sunucuya sorgulayın, imzayı doğrulayın, siparişle karşılaştırın ve sipariş durumunu yalnızca bir kez değiştirin.
PHP:
// POST /payment/iyzico/callback: CSRF kontrolü dışında, oturum gerektirmez
use Iyzipay\Model\{CheckoutForm, Locale};
use Iyzipay\Request\RetrieveCheckoutFormRequest;

function iyzNum($v): string                    // 5.2 / "5.20000000" -> "5.2", 10.0 -> "10"
{
    $s = is_float($v) ? sprintf('%.8F', $v) : (string) $v;
    return str_contains($s, '.') ? rtrim(rtrim($s, '0'), '.') : $s;
}

function kurus($v): int                        // 1500.5 -> 150050
{
    return (int) round(((float) $v) * 100);
}

$token   = (string) ($_POST['token'] ?? '');
$attempt = $token !== '' ? $payments->findByToken($token) : null;
if (!$attempt) {
    http_response_code(400);
    exit;
}
$order = $orders->find($attempt->order_id);

$request = new RetrieveCheckoutFormRequest();
$request->setLocale(Locale::TR);
$request->setConversationId($attempt->conversation_id);
$request->setToken($token);
$result = CheckoutForm::retrieve($request, iyzicoOptions());

$expected = hash_hmac('sha256', implode(':', [
    $result->getPaymentStatus(), $result->getPaymentId(), $result->getCurrency(),
    $result->getBasketId(), $result->getConversationId(),
    iyzNum($result->getPaidPrice()), iyzNum($result->getPrice()), $result->getToken(),
]), getenv('IYZICO_SECRET_KEY'));

$succeeded = $result->getStatus() === 'success'
    && hash_equals($expected, (string) $result->getSignature())
    && $result->getPaymentStatus() === 'SUCCESS';
$matches = $succeeded
    && $result->getBasketId() === (string) $order->id
    && $result->getCurrency() === 'TRY'
    && kurus($result->getPrice()) === $order->items_total
    && kurus($result->getPaidPrice()) >= $order->grand_total;   // vade farkı tutarı artırabilir

if ($matches) {
    $state = $result->getFraudStatus() === 1 ? 'paid' : 'on_hold';   // 0 = fraud incelemesinde
    $pdo->beginTransaction();
    $stmt = $pdo->prepare("UPDATE orders SET status = ?, iyzico_payment_id = ?, paid_total = ?
                           WHERE id = ? AND status = 'pending'");
    $stmt->execute([$state, $result->getPaymentId(), kurus($result->getPaidPrice()), $order->id]);
    if ($stmt->rowCount() === 1) {                              // yalnızca ilk seferde: iade için kimlikleri sakla
        foreach ($result->getPaymentItems() as $item) {
            $payments->saveItem($order->id, $item->getItemId(),
                $item->getPaymentTransactionId(), kurus($item->getPaidPrice()));
        }
    }
    $pdo->commit();
} elseif ($succeeded) {
    $payments->flagForReview($attempt->id, 'basket or amount mismatch');   // para çekildi: karar bir insanda
} else {
    $payments->markFailed($attempt->id, $result->getErrorCode(), $result->getErrorMessage());
}
header('Location: /orders/' . $order->id . '/result', true, 303);         // kendi alan adınızda bir GET
Üç ayrıntı, canlı ortamdaki hataların çoğunu önler. İmza bu servis için dokümanda verilen alan sırasını izler (paymentStatus, paymentId, currency, basketId, conversationId, paidPrice, price, token); alanlar iki nokta üst üste ile birleştirilir, tutarlardaki ardıl sıfırlar atılır ("10.50", "10.5" olur). Koşullu UPDATE, yenilenen bir callback'in, iki kez gelen bir POST'un ya da aynı anda düşen bir webhook'un siparişi iki kez ödenmiş saymasını engeller. Sondaki 303 yönlendirmesi de siteler arası POST'u kendi alan adınızda bir GET isteğine çevirir; tarayıcı oturum çerezini bu istekte yeniden gönderir.
fraudStatus 0, iyzico'nun dolandırıcılık (fraud) ekibinin ödemeyi incelediği anlamına gelir: Sonuç e-postayla ve panelde tanımlayabileceğiniz fraud bildirim adresine gelene kadar siparişi bekletin. Havale/EFT ya da alışveriş kredisi açıksa callback, webhook'un daha sonra netleştireceği ara bir durumla da gelebilir.

Aynı callback Node.js ile​

iyzipay npm paketi geri çağırma fonksiyonlarıyla (callback) çalışır, bu yüzden çağrıyı bir Promise ile sarın. JavaScript sayıları ardıl sıfırları zaten atar (5.20000000 değil, 5.2).
Kod:
const crypto = require('crypto');
const express = require('express');
const Iyzipay = require('iyzipay');

const iyzipay = new Iyzipay({
  apiKey: process.env.IYZICO_API_KEY,
  secretKey: process.env.IYZICO_SECRET_KEY,
  uri: process.env.IYZICO_BASE_URL,
});

// SDK callback tabanlı: çağrıyı bir kez sarın
const retrieveCheckoutForm = (request) => new Promise((resolve, reject) =>
  iyzipay.checkoutForm.retrieve(request, (err, result) => (err ? reject(err) : resolve(result))));

const num = (v) => String(Number(v));   // 5.2 -> "5.2", 10 -> "10"

function signatureOk(r) {
  const data = [r.paymentStatus, r.paymentId, r.currency, r.basketId,
    r.conversationId, num(r.paidPrice), num(r.price), r.token].join(':');
  const expected = Buffer.from(crypto.createHmac('sha256', process.env.IYZICO_SECRET_KEY)
    .update(data).digest('hex'));
  const given = Buffer.from(String(r.signature || ''));
  return expected.length === given.length && crypto.timingSafeEqual(expected, given);
}

const app = express();
app.post('/payment/iyzico/callback', express.urlencoded({ extended: false }), async (req, res, next) => {
  try {
    const attempt = await payments.findByToken(String(req.body.token || ''));
    if (!attempt) return res.sendStatus(400);
    const r = await retrieveCheckoutForm({
      locale: Iyzipay.LOCALE.TR,
      conversationId: attempt.conversationId,
      token: attempt.token,
    });
    const ok = r.status === 'success' && signatureOk(r) && r.paymentStatus === 'SUCCESS'
      && r.basketId === String(attempt.orderId);         // ayrıca PHP sürümündeki tutar kontrolleri
    if (ok) await orders.markPaidOnce(attempt.orderId, r); // aynı koşullu UPDATE
    res.redirect(303, `/orders/${attempt.orderId}/result`);
  } catch (err) {
    next(err);
  }
});
Python'da resmî istemci ham HTTP yanıtını döndürür (json.load ile okursunuz) ve str(10.0) "10.0" verir; iyzico'nun Python örneğinin imzayı kontrol etmeden önce ".0" kısmını silmesinin nedeni budur.

Tutarları doğru kurmak​

  • price sepet toplamıdır ve kalem fiyatlarının toplamına tam olarak eşit olmalıdır; değilse 5062 hatası gelir ("Gönderilen tutar tüm kırılımların toplam tutarına eşit olmalıdır").
  • paidPrice müşteriden tahsil edilen tutardır. iyzico SSS'si indirimi ve kargoyu buraya yansıtır; resmî WooCommerce eklentisi ise kargoyu ayrı bir sepet kalemi olarak gönderir, bu da satır bazlı iadeyi kolaylaştırır. price kalem toplamına eşit kaldığı sürece ikisi de çalışır.
  • Her kalem sıfırdan büyük olmalıdır (5050 hatası), dolayısıyla indirim eksi tutarlı bir satır olamaz: paidPrice'ı düşürün ya da indirimi kalemlere dağıtıp kuruş artığını son kaleme yazın.
  • Ondalık ayırıcı olarak nokta kullanın. Türkçe biçimli "1,5" gibi bir değer, "Request message is not readable" hatasının en yaygın nedenidir; PHP'de number_format($x, 2, '.', '') güvenlidir.
  • Vade farkı: Taksit faizini müşteri ödüyorsa sorgulanan paidPrice gönderdiğiniz değerden yüksek gelir. WooCommerce eklentisi farkı taksit komisyonu satırı olarak siparişe ekler; muhasebenizin bunu nasıl kaydedeceğine siz karar verin.
  • Para birimi: Varsayılan TRY'dir. Diğer para birimleri için Eklentiler sayfasında Ödeme Hizmeti ve Multicurrency eklentisi gerekir; iyzico SSS'sine göre mevzuat gereği dövizli ödeme yalnızca yabancı kartlardan alınabilir.
  • Her hesabın işlem başına bir üst tutar limiti vardır (5008 hatası); destek ekibi bu limiti yükseltebilir.

Doğrudan API ile 3D Secure​

Kendi kart formunuzla 3D Secure, iki çağrı ve tarayıcı üzerinden bir tur gerektirir:
  1. İsterseniz önce BIN ve taksit sorgulayın (sonraki bölüm); seçenekleri gösterir, kartın 3DS'ye zorunlu olup olmadığını (force3ds = 1) öğrenirsiniz.
  2. Kart, alıcı, adres, sepet ve callbackUrl ile /payment/3dsecure/initialize servisini çağırın. Yanıttaki threeDSHtmlContent, bankaya kendiliğinden form gönderen, base64 ile kodlanmış bir HTML sayfasıdır. PHP SDK'sı bunu çözer (getHtmlContent()); Node.js ya da Python'da kendiniz çözersiniz.
  3. Bu HTML'i tarayıcıya gönderin; müşteri bankanın gönderdiği tek kullanımlık şifreyle onay verir.
  4. Tarayıcı callbackUrl adresinize status, paymentId, conversationId, conversationData, mdStatus ve signature alanlarını POST eder.
  5. Callback imzasını doğrulayın, status için success ve mdStatus için 1 şartı arayın, ardından ödemeyi tamamlayın. Doküman v2 uç noktasını (/payment/v2/3dsecure/auth) öneriyor; burada paidPrice, basketId ve currency başlatma isteğindekilerle aynı olmalıdır.
PHP:
use Iyzipay\Model\{Currency, Locale, PaymentCard, PaymentChannel, ThreedsInitialize, ThreedsV2Payment};
use Iyzipay\Request\{CreatePaymentRequest, CreateThreedsV2PaymentRequest};

// 1. adım: başlatma
$request = new CreatePaymentRequest();
// locale, conversationId, basketId, price, paidPrice, currency, alıcı, adresler, sepet: ödeme formundaki gibi
$request->setInstallment($installment);                  // 1 ya da müşterinin seçtiği taksit sayısı
$request->setPaymentChannel(PaymentChannel::WEB);
$request->setCallbackUrl('https://shop.example.com/payment/iyzico/3ds-callback');
$request->setPaymentCard($card);   // PaymentCard: ad, numara, son kullanma, CVC; $request asla loglanmaz

$init = ThreedsInitialize::create($request, iyzicoOptions());
if ($init->getStatus() === 'success') {
    echo $init->getHtmlContent();                        // PHP SDK base64 kodunu zaten çözdü
}

// 2. adım: POST /payment/iyzico/3ds-callback
$p = fn(string $k): string => (string) ($_POST[$k] ?? '');
$expected = hash_hmac('sha256', implode(':', [
    $p('conversationData'), $p('conversationId'), $p('mdStatus'), $p('paymentId'), $p('status'),
]), getenv('IYZICO_SECRET_KEY'));

if (!hash_equals($expected, $p('signature')) || $p('status') !== 'success' || $p('mdStatus') !== '1') {
    exit;                                                // doğrulanamadı ya da 3DS başarısız: hata gösterip başka kart önerin
}
$attempt = $payments->findByConversationId($p('conversationId'));

$auth = new CreateThreedsV2PaymentRequest();
$auth->setLocale(Locale::TR);
$auth->setConversationId($attempt->conversation_id);
$auth->setPaymentId($p('paymentId'));
$auth->setPaidPrice($attempt->paid_price);               // başlatmada gönderilen değerin aynısı
$auth->setBasketId($attempt->basket_id);
$auth->setCurrency(Currency::TL);
$payment = ThreedsV2Payment::create($auth, iyzicoOptions());
// ardından imza: paymentId, currency, basketId, conversationId, paidPrice, price (ardıl sıfırlar atılmış)
Başarı anlamına gelen tek değer mdStatus 1'dir. 0, 3D Secure imzasının ya da doğrulamanın geçersiz olduğunu gösterir; çoğunlukla kartı veren banka yanıt vermemiştir (bazı yabancı kartlarda 3DS yoktur). 2 ile 8 arasındaki değerler kayıtlı olmama, doğrulamanın mümkün olmaması, 3D Secure hatası, sistem hatası ve bilinmeyen kart durumlarını kapsar. Müşteriye "kart doğrulaması başarısız" deyip başka bir kart önerin. INIT_THREEDS durumunda kalan bir deneme, müşterinin banka sayfasından ayrıldığını gösterir. Başlatma isteğini asla loglamayın: Kart numarasını ve CVC'yi içerir; PCI DSS de CVC'nin yetkilendirmeden sonra saklanmasını yasaklar.

Taksit seçenekleri: BIN ve taksit sorgulama​

Tutar ve kartın ilk sekiz hanesiyle (binNumber) yapılan POST /payment/iyzipos/installment çağrısı kartı veren bankayı, kart ailesini (Bonus, World, Maximum, Axess, CardFinans, Paraf, Advantage ve diğerleri), kart tipini, force3ds değerini ve installmentPrices listesini, yani her taksit sayısı için installmentNumber, installmentPrice ve totalPrice değerlerini döndürür. BIN göndermezseniz tutara uygun bütün seçenekler gelir.
PHP:
use Iyzipay\Model\{InstallmentInfo, Locale};
use Iyzipay\Request\RetrieveInstallmentInfoRequest;

$request = new RetrieveInstallmentInfoRequest();
$request->setLocale(Locale::TR);
$request->setConversationId('inst-' . $order->id);
$request->setBinNumber(substr($cardDigits, 0, 8));       // yalnızca ilk sekiz hane
$request->setPrice(money($order->grand_total));

$info = InstallmentInfo::retrieve($request, iyzicoOptions());
foreach ($info->getInstallmentDetails() ?? [] as $detail) {
    // $detail->getForce3ds() === 1: bu kart 3D Secure ile ödenmeli
    foreach ($detail->getInstallmentPrices() as $option) {
        printf("%d x %s TL = %s TL\n", $option->getInstallmentNumber(),
            $option->getInstallmentPrice(), $option->getTotalPrice());
    }
}
Oranlar paneldeki Taksit ve Komisyon Ayarları bölümünden gelir; oradaki değişiklikler servise kendiliğinden yansır. Vade farkını kimin taşıyacağı hesabınızın ticari bir ayarıdır. Müşteri taşıyorsa totalPrice düz tutarın üstündedir: Seçilen taksit sayısını, paidPrice'ı o seçeneğin totalPrice değerine eşitleyerek gönderin; price sepet toplamı olarak kalır. Farkı siz üstlenirseniz müşteri düz tutarı öder, maliyet iyzico ile anlaşmanıza göre sizde kalır. Ödeme formunda enabledInstallments sunulan taksit sayılarını sınırlar; resmî WooCommerce eklentisi bu parametreyi hiç göndermez. Arayüzde sorguyu kısa bir beklemeyle (debounce) yapın ve sonuçları BIN ile tutara göre önbelleğe alın: Bu yazı hazırlanırken BIN sorgulama dakikada 50 istekle sınırlıydı ve aşılan her limit errorCode 50000 (Request Limit Exceeded) döndürür.

Webhook: kurulum ve V3 imza doğrulama​

Callback müşterinin tarayıcısına bağlıdır. Ödemeden sonra sekme kapanırsa kart çekildiği hâlde sipariş beklemede kalır. Webhook bu boşluğu kapatır; havale/EFT gibi eşzamansız yöntemlerin nihai sonucunu da yoklama (polling) yapmadan size ulaştırır.
  • Panelde Ayarlar > Firma Ayarları sayfasındaki İşyeri Bildirimleri alanına HTTPS bir adres girin ve "Ödeme bildirimlerini gönder" seçeneğini açın. Abonelik yenilemelerinin bildirimleri ayrı bir adrese, Abonelik Bildirimleri alanına gider.
  • X-IYZ-SIGNATURE-V3 başlığı, iyzico hesabınızda imza özelliğini açtıktan sonra gelir; bunu sandbox hesabınız için de [email protected] adresinden isteyin. Eski X-Iyz-Signature ve X-Iyz-Signature-V2 başlıkları artık desteklenmiyor.
  • Webhook dokümanına göre ilk bildirim denemeden 10-15 saniye sonra gelir; siz 2xx dönene kadar 15 dakikada bir, en fazla üç kez tekrarlanır. Ödeme formu örnek sayfası 10 dakika diyor; mantığınızı bu aralığa dayandırmayın.
  • İki ödeme biçimi vardır: direkt (API Non-3DS ve 3DS; paymentId içerir) ve HPP (ödeme formu ve iyzico ile Öde; token ve iyziPaymentId içerir). Abonelik bildirimlerinin üçüncü bir biçimi ve kendi birleştirme sırası vardır; bunu gerçek bir sandbox bildirimiyle sınayın.
PHP:
// POST /payment/iyzico/webhook: JSON, sunucudan sunucuya
$e = json_decode(file_get_contents('php://input'), true) ?: [];
$f = fn(string $k): string => (string) ($e[$k] ?? '');
$secret = getenv('IYZICO_SECRET_KEY');

$data = isset($e['token'])
    // HPP: ödeme formu, iyzico ile Öde
    ? $secret . $f('iyziEventType') . $f('iyziPaymentId') . $f('token') . $f('paymentConversationId') . $f('status')
    // direkt: API Non-3DS ve 3DS
    : $secret . $f('iyziEventType') . $f('paymentId') . $f('paymentConversationId') . $f('status');

$given = strtolower($_SERVER['HTTP_X_IYZ_SIGNATURE_V3'] ?? '');
if (!hash_equals(hash_hmac('sha256', $data, $secret), $given)) {
    http_response_code(401);                              // logla, hiçbir şey işleme
    exit;
}
$queue->push('iyzico.payment', $e);                       // bir worker ödemeyi yeniden sorgular, siparişi idempotent günceller
http_response_code(200);
Python'da aynı metni kurup hmac.compare_digest ile karşılaştırın. Webhook'u doğruluğun kaynağı değil, tetikleyici olarak görün: İmza kontrolünden sonra ödemeyi yeniden sorgulayın (token ile ödeme formu sorgusu ya da paymentId ile ödeme detayı) ve callback'teki idempotent durum değişikliğini uygulayın; resmî WooCommerce eklentisi de tam böyle çalışır. 2xx yanıtını hızlı dönün ve asıl işi, güvenli webhook alıcısı tasarımı rehberimizde anlattığımız gibi bir kuyrukta yapın. Fraud inceleme bildiriminin (paymentId ile 2 ya da -1 değerinde fraudStatus) dokümanda tanımlı bir imzası yoktur; o ödemeyi de yeniden sorgulayın.

İptal mi, iade mi?​

İptalİadeİade V2 (tutar bazlı)
Uç nokta/payment/cancel/payment/refund/v2/payment/refund
KimlikpaymentIdpaymentTransactionId (tek sepet kalemi)paymentId
Kısmi tutarHayır, ödemenin tamamıEvet; kalemin paidPrice tutarına kadar tekrarlanabilirEvet
Ne zamanÖdemeyle aynı gün365 gün içinde365 gün içinde; iyzico çok kalemli sepetlerde önermiyor
Müşterinin ekstresiÇoğu bankada hiç görünmezAyrı bir alacak kaydı; süre bankaya bağlı, genellikle birkaç günİadedeki gibi
PHP:
use Iyzipay\Model\{Cancel, Currency, Locale, Refund};
use Iyzipay\Request\{CreateCancelRequest, CreateRefundRequest};

// Tek bir sepet kaleminin kısmi iadesi (kalemin paidPrice tutarına kadar tekrarlanabilir)
$request = new CreateRefundRequest();
$request->setLocale(Locale::TR);
$request->setConversationId('refund-' . $refundId);
$request->setPaymentTransactionId($item->payment_transaction_id);
$request->setPrice(money($amountKurus));
$request->setCurrency(Currency::TL);
$request->setIp($staffIp);
$refund = Refund::create($request, iyzicoOptions());
if ($refund->getStatus() !== 'success') {
    $log->warning('iyzico refund failed', ['code' => $refund->getErrorCode(), 'message' => $refund->getErrorMessage()]);
}

// Ödemenin tamamının aynı gün iptali
$cancel = new CreateCancelRequest();
$cancel->setLocale(Locale::TR);
$cancel->setConversationId('cancel-' . $order->id);
$cancel->setPaymentId($order->iyzico_payment_id);
$cancel->setIp($staffIp);
$result = Cancel::create($cancel, iyzicoOptions());
İade yanıtı paymentId, price, currency ve conversationId üzerinden imzalanır ve tekrar denemeden önce bakmanız gereken bir retryable alanı taşır. Toplu işlerde dakika başına limitlere dikkat edin (bu yazı hazırlanırken /payment/refund için 400, İade V2 ve iptal için 150). WooCommerce sipariş ekranında "iyzico ile iade et" API'yi çağırır, "El ile İade Et" ise yalnızca siparişi günceller. İade yetkisini birkaç kişiyle sınırlayın ve kimin neyi iade ettiğini kaydedin.

Pazaryeri: alt üye işyerleri ve onay​

Pazaryeri modelinde ödemeler tek bir iyzico hesabında toplanır ve satıcılara (alt üye işyerlerine) ancak sizin onayınızla aktarılır; satıcıların iyzico'ya ayrıca kaydolması gerekmez.
  • Her satıcıyı bir kez POST /onboarding/submerchant ile PERSONAL, PRIVATE_COMPANY ya da LIMITED_OR_JOINT_STOCK_COMPANY tipinde kaydedin ve dönen subMerchantKey değerini saklayın. IBAN, yetkilinin adı soyadıyla ya da şirket unvanıyla uyumlu olmalı ve hak ediş onayından önce sistemde bulunmalıdır.
  • Her sepet kalemi subMerchantKey ile satıcının payını gösteren subMerchantPrice değerini taşır. iyzico SSS'sindeki örnek: 100 TL'lik üründe 4 TL komisyon ve 80 TL subMerchantPrice varsa satıcıya 80 TL, pazaryerine 16 TL aktarılır.
  • Teslimattan sonra her kalemi paymentTransactionId ile POST /payment/iyzipos/item/approve çağrısıyla onaylayın; disapprove onayı geri çeker. Onay yalnızca API'de vardır, panelde yoktur. transactionStatus 1 onay bekliyor, 2 onaylandı demektir.
  • Kendi ürünlerinizi satmak için kendinize bir alt üye işyeri açın ya da paymentGroup LISTING kullanın. Alt üye işyerleri silinemez, pazaryeri hesaplarında abonelik kullanılamaz; withholdingTax ise isteğe bağlı ve yalnızca raporlara yansıyan bir alandır. E-ticaret stopajının sizi kapsayıp kapsamadığını mali müşavirinize sorun.

Akışı WHMCS'e uyarlamak​

WHMCS'te bir ödeme geçidi modülü callback isteğini modules/gateways/callback/ altında karşılar. Akış aynı kalır: basketId fatura numarasıdır, conversationId buna bir deneme sayacı ekler; sonucu sorgulayıp doğruladıktan sonra mükerrer kayıt kontrolünü ve ödeme geçidi günlüğünü WHMCS'in yardımcı fonksiyonları üstlenir.
PHP:
// modules/gateways/callback/iyzicocheckout.php (taslak)
require_once __DIR__ . '/../../../init.php';
require_once __DIR__ . '/../../../includes/gatewayfunctions.php';
require_once __DIR__ . '/../../../includes/invoicefunctions.php';

$gatewayModuleName = basename(__FILE__, '.php');
$gatewayParams = getGatewayVariables($gatewayModuleName);
if (!$gatewayParams['type']) {
    die('Module Not Activated');
}

$result = iyzicoRetrieveAndVerify($_POST['token'] ?? '', $gatewayParams);       // sorgulama + imza kontrolü, yukarıdaki gibi
$invoiceId = checkCbInvoiceID($result->getBasketId(), $gatewayParams['name']);  // basketId = fatura numarası
checkCbTransID($result->getPaymentId());                                        // bu paymentId daha önce kaydedildiyse burada durur
logTransaction($gatewayParams['name'], [
    'paymentId' => $result->getPaymentId(),
    'status' => $result->getPaymentStatus(),
    'fraudStatus' => $result->getFraudStatus(),
], $result->getPaymentStatus());

if ($result->getPaymentStatus() === 'SUCCESS' && $result->getFraudStatus() === 1) {
    addInvoicePayment($invoiceId, $result->getPaymentId(), 0, 0.00, $gatewayModuleName); // 0 = fatura bakiyesi
}
Tutar 0 verilirse addInvoicePayment fatura bakiyesinin tamamını uygular. Müşteri vade farkı ödediyse farkı açıklanamayan bir fazla ödeme olarak bırakmak yerine nasıl kaydedeceğinize karar verin; fraudStatus 0 olan faturaları da inceleme bitene kadar ödenmemiş tutun. Bu kalıbı 3D Secure, taksit, iade ve webhook ile fatura senkronizasyonunu kapsayan bir WHMCS iyzico entegrasyonunda kullandık.

Sorun giderme​

BelirtiOlası nedenÇözüm
Her çağrıda errorCode 1000 "Invalid signature"Güvenlik anahtarında baş ya da son boşluk, karışmış anahtar çiftleri, elle kurulmuş Authorization başlığıSDK kullanın, anahtarları kırpın, BIN sorgusuyla deneyin
1001 "API bilgileri bulunamadı"Sandbox anahtarları api.iyzipay.com'a ya da canlı anahtarlar sandbox'a gönderilmişAnahtar ve base URL aynı ortamdan
5062 "Gönderilen tutar tüm kırılımların toplam tutarına eşit olmalıdır"Kayan nokta yuvarlaması; kargo ya da indirimin bir alanda olup diğerinde olmamasıTam sayı kuruş; ayrı kalem olarak kargo; paidPrice'ta ya da kalemlere dağıtılmış indirim
Ödeme sorunsuz ama sizin imza kontrolünüz başarısızYanlış alan sırası, yanıt yerine istekteki değerler, ardıl sıfırlarServise göre dokümandaki sıra, yanıttaki değerler, atılmış sıfırlar
Callback 419 ya da 403 dönüyor veya müşteri ödemeden sonra oturumu kapanmış görünüyorRotada CSRF ara katmanı; SameSite=Lax oturum çerezi siteler arası POST ile gönderilmezRotayı CSRF dışında bırakın, denemeyi token ile bulun, 303 ile bir GET sayfasına yönlendirin
iyzico'da para çekilmiş, sipariş hâlâ beklemedeSekme callback'ten önce kapanmış ya da callback hata vermişWebhook ve günlük mutabakat işi
Sipariş iki kez onaylanmış, stok iki kez düşmüşYenilenen callback; yarışan webhook ve callbackKoşullu UPDATE, paymentId üzerinde benzersiz indeks
Tek siparişe iki çekimAPI akışında Öde düğmesine çift tıklamaSipariş başına tek etkin deneme, devre dışı düğme, mükerrer çekimin aynı gün iptali
Sorgulamada 5122 "Gönderilen token'a ait ödeme bilgisi bulunamadı"Müşteri formu açıp ödemeden ayrılmışHata değil: Siparişi beklemede tutun, sonra süresini doldurun
5010 "İşlemi 3dsecure olarak gerçekleştirmeniz gerekmektedir"Hesabınızda 3DS zorunlu, istek Non-3DS gönderilmiş3DS akışını kullanın
Webhook gelmiyor ya da X-IYZ-SIGNATURE-V3 başlığı yokBildirimler kapalı, adres HTTPS değil, imza açılmamış, uç noktanız 2xx dışında yanıt veriyorPanelden açın, [email protected]'a yazın, kendi durum kodlarınızı loglayın
Sorgulamada ya da 3DS tamamlamada zaman aşımıAğ ya da karşı taraf gecikmesi; PHP SDK'nın varsayılan istemcisi cURL zaman aşımı tanımlamazSonucu bilinmiyor sayın, tekrar denemeden önce ödeme detayını sorgulayın
Laravel'de callback rotasını bootstrap/app.php içindeki validateCsrfTokens(except: [...]) listesine ekleyin ya da web ara katman grubunun dışında tanımlayın; Laravel'in oturum çerezi varsayılan olarak SameSite=Lax'tir ve tarayıcılar bu çerezi siteler arası POST ile göndermez. Hata zaman zaman çıkıyormuş gibi görünebilir, çünkü SameSite özniteliği olmayan ve bazı tarayıcıların varsayılan olarak Lax saydığı bir çerez, oluşturulduktan sonraki iki dakika içinde POST ile yine de gönderilir. Açık zaman aşımı limitleri için PHP SDK, \Iyzipay\ApiResource::setHttpClient() ile kendi HTTP istemcinizi kabul eder. Mutabakatta GET /v2/reporting/payment/details paymentId ya da paymentConversationId ile yanıt verir, GET /v2/reporting/payment/transactions ise bir günün ödeme, iptal ve iadelerini listeler.

Güvenlik kontrol listesi​

  • Anahtarlar ortam değişkenlerinde ya da bir sır yöneticisinde durur, sandbox ve canlı için ayrıdır; ön yüz kodunda, uygulamada ya da depoda asla bulunmaz.
  • Sepet, tutarlar ve para birimi sunucuda kurulur; ödemeden sonra basketId, price, paidPrice (en az sipariş toplamı kadar) ve currency siparişle karşılaştırılır.
  • Her imza (yanıt, 3DS callback, webhook) sabit zamanlı karşılaştırmayla doğrulanır: hash_equals, crypto.timingSafeEqual ya da hmac.compare_digest.
  • Kart numarası ve CVC loglara, hata izleme araçlarına ya da analitiğe girmez; iyzico yanıtlarında karttan yalnızca BIN ve son dört hane bulunur.
  • buyer.ip müşterinin gerçek IP adresidir; proxy arkasındaysanız güvendiğiniz proxy'nin başlığından okunur.
  • Callback ve webhook rotaları yalnızca HTTPS üzerinden POST kabul eder, CSRF ara katmanının dışında durur, doğrulamadan önce iş mantığı çalıştırmaz.
  • Sipariş yalnızca fraudStatus 1 olduğunda kargoya verilir; iade ve iptal personel yetkisi ister ve denetim izi bırakır; günlük mutabakat farklarda uyarı üretir.

Sık sorulan sorular​

Ödeme formu mu kullanmalıyım, API mi?
Somut bir nedeniniz yoksa ödeme formu. 3D Secure'u, taksitleri, cüzdanı ve alternatif yöntemleri iki API çağrısıyla çözer, kart verisini sistemlerinizin dışında tutar. Kendi kart formunuz, kendi yöneteceğiniz kart saklama ya da yerel bir uygulama akışı gerekiyorsa API'yi seçin ve PCI DSS işine bütçe ayırın.

Tek tıkla ödeme için kart saklayabilir miyim?
Evet, kart saklama eklentisiyle (Ödeme Hizmeti ve Kart Saklama): 3DS ya da Non-3DS bir ödemede registerCard = 1 gönderin veya kartı önceden oluşturun; kart numarasını değil, dönen cardUserKey ve cardToken değerlerini saklayın. Ödeme formunda kaydedilen kartlar müşterinin iyzico cüzdanına gider, size token dönmez. iyzico ilk çekimi 3DS, sonrakileri Non-3DS denemeyi önerir; onay kartı veren bankaya bağlıdır.

3D Secure'u nasıl test ederim?
Sandbox'ta 3DS başlatma, banka sayfası yerine iyzico'nun sahte (mock) 3D Secure sayfasını döndürür; böylece başarılı kartlarla bütün tur çalışır. Hata dalları için 4131111111111117 (mdStatus 0), 4141111111111115 (mdStatus 4) ve 4151111111111112 (başlatma başarısız) kartlarını kullanın. Webhook için herkese açık bir HTTPS uç noktası gerekir; geliştirmede bir tünel iş görür.

İade müşteriye nasıl görünür?
Aynı gün yapılan iptal genellikle ekstrede satır bırakmaz. İade ayrı bir alacak kaydı olarak görünür; ne zaman yansıyacağı kartı veren bankaya bağlıdır, genellikle birkaç gün sürer. İade 365 gün boyunca yapılabilir.

iyzico abonelik destekliyor mu?
Evet, ücretli bir eklenti olarak: Ürünleri ve ödeme planlarını tanımlar, aboneliği bir ödeme formu ya da API ile başlatırsınız; iyzico plana göre tahsilat yapar ve abonelik bildirim adresinize haber verir. Sınırları şunlar: Yalnızca kredi kartıyla çalışır, ilki dahil bütün çekimler 3D Secure olmadan yapılır, müşterinin +90 ile başlayan bir cep telefonu numarası olmalıdır, plan fiyatı değiştirilemez (aboneliği yeni bir plana yükseltirsiniz) ve pazaryeri hesaplarında kullanılamaz. Bu sınırlara takılırsanız kart saklama ile kendi zamanlayıcınız bir alternatiftir.

Sonuç ve sonraki adımlar​

Güvenilir bir iyzico entegrasyonu, API çağrılarından çok onların çevresine bağlıdır: sunucuda kurulan tutarlar, imzayla doğrulanan her sonuç, callback ile webhook'un paylaştığı tek ve idempotent bir durum değişikliği ve ikisi de başarısız olduğunda devreye giren bir mutabakat işi.
  1. Bir sandbox hesabı açın, anahtarları ortam değişkenlerine koyun ve bir BIN sorgusuyla doğrulayın.
  2. Ödeme formunu yönlendirme modeliyle kurun: başlatma, callback, sorgulama, imza kontrolü, koşullu güncelleme.
  3. Webhook uç noktasını ekleyin ve iyzico'dan X-IYZ-SIGNATURE-V3'ü açmasını isteyin.
  4. Olumsuz senaryoları test edin: hata kartları, kapanan sekme, yenilenen callback, çift tıklama, kısmi iadeler ve bir iptal.
  5. Günlük mutabakatı zamanlayın; ardından canlı anahtarlarla, gerçek kartla alınan küçük bir ödeme ve iadesiyle canlıya geçin.

Adding iyzico to a custom site, a WHMCS installation or your own e-commerce backend raises the same questions every time: hosted Checkout Form or direct API, how 3D Secure completes, how to prove a result really came from iyzico, and how refunds work. The API calls are the easy part. The bugs sit at the edges: a basket that does not add up to the price, a signature check that fails on a trailing zero, a callback your framework rejects, an order paid twice after a refresh. This guide covers the whole path with the official iyzipay-php SDK, plus Node.js where it helps. Endpoints, field orders and limits were checked against docs.iyzico.com and the SDK sources in October 2026; confirm account-specific details with iyzico before going live. Still choosing a provider? Start with our comparison of Stripe, iyzico, PayTR and Adyen.

How an iyzico payment works​

Every flow has the same shape: your server initializes the payment, the customer completes it on a page you do not control (iyzico's form or the bank's 3D Secure page), the browser posts back to your callback URL, and your server confirms the result with a server-to-server call (retrieve or auth). A webhook may follow as a second, independent channel.
  • Credentials: an API key and a secret key per environment. Sandbox keys start with "sandbox-", and sandbox and live pairs are entirely separate.
  • Authentication: an Authorization header made of IYZWSv2 and a base64 string carrying the API key, a random key and an HMAC-SHA256 of random key + URI path + request body, keyed with the secret key. The SDKs build it; hand-written clients are the usual cause of error 1000 (Invalid signature).
  • conversationId (yours, up to 255 characters): echoed in responses, reports and webhooks, where it is called paymentConversationId. Use the order number plus an attempt counter, e.g. ord-10482-2.
  • basketId (yours): the order or invoice number.
  • token (iyzico; Checkout Form and Pay with iyzico): issued at initialize, posted to your callback, needed to retrieve the result.
  • paymentId (iyzico): for cancels, amount-based refunds, payment queries and support tickets.
  • paymentTransactionId (iyzico, one per basket item): for item refunds and marketplace approval. Store it with your item ID.

Checkout Form, Pay with iyzico or direct API?​

OptionWhere the card is enteredPCI DSS scope (indicative)EffortTypical use
Checkout Form, redirect (paymentPageUrl)iyzico's hosted pageSmallest: card data never reaches your pages or serversLowMost web shops, WHMCS, SaaS billing
Checkout Form, embedded (responsive or popup)A form that iyzico's script renders inside your pageSmall, but your page and its scripts now matterLow to mediumShops that keep the customer on the site
Pay with iyzico (PWI)The customer logs in to the iyzico wallet (saved cards, balance)As for the hosted pageLowWallet users; also a tab inside the Checkout Form
Direct API with 3DSYour own form; your server sends the card number to iyzicoFull: your systems handle card data (SAQ D territory)HighCustom checkout, app backends, stored cards
Direct API, Non-3DSSame, without the bank redirectFullMedium to highOnly if your account allows it; stored-card charges
The PCI column is a starting point, not an assessment; confirm your SAQ type with iyzico or your assessor. Marketplace and Subscription are account products layered on these methods and are covered below. Cost drivers sit outside the code: commission and installment terms come from your merchant agreement; card storage, multicurrency and subscription are separate panel add-ons (subscription is a paid one); and the direct API adds PCI DSS work on your side.
  • Start with the Checkout Form in redirect mode. It handles 3D Secure, installments, the iyzico wallet and, if enabled, bank transfer and shopping credit, and it keeps card data off your systems.
  • Embedded mode arrives as an inline script that loads iyzico's bundle, so a strict Content-Security-Policy breaks it. The iframe variant (&iframe=true appended to paymentPageUrl) or plain redirect avoids that.
  • Choose the direct API only when you need your own card form or card storage you manage, and accept the PCI DSS work.
  • Mobile apps: there is no mobile SDK. The app talks to your backend, the backend to iyzico, and API keys never ship inside the app.

Sandbox, keys and test cards​

Register at sandbox-merchant.iyzipay.com. No e-mail or SMS is sent at sign-up: iyzico's FAQ says to type 123456 as the SMS code, and any PNG file passes the document step. The keys are under Settings > Merchant Settings; keep them in environment variables or a secret manager, never in the repository.
INI:
# .env (sandbox). Live: https://api.iyzipay.com with the live key pair
IYZICO_API_KEY=sandbox-xxxxxxxxxxxxxxxx
IYZICO_SECRET_KEY=sandbox-xxxxxxxxxxxxxxxx
IYZICO_BASE_URL=https://sandbox-api.iyzipay.com
Bash:
composer require iyzico/iyzipay-php   # PHP 7.4 or later
npm install iyzipay                   # Node.js
pip install iyzipay                   # Python 3.9 or later
Java and .NET clients, and plugins for WooCommerce, Magento 2, OpenCart, PrestaShop and nopCommerce, are also on github.com/iyzico. Documented test cards (any future expiry date and well-formed CVC):
Card numberResult
5528790000000008Success: Halkbank, Mastercard credit
4766620000000001Success: Denizbank, Visa debit
9792030000000000Success: QNB, Troy credit
5400010000000004Success: non-Turkish credit card
4111111111111129Not sufficient funds
4131111111111117Success but mdStatus 0 (failed 3DS)
41511111111111123D Secure initialize failed
5406670000000009Success, but cannot be cancelled, refunded or post-authorized
NOK and CHF do not work in the sandbox. To test a new key pair, call the BIN check endpoint: success means your authentication is right.
Going live: live keys are shown only to the verified legal representative, only in the web panel (not in the İşim İçin app), and individual (bireysel) accounts get no API access. Switch the base URL, take a 5-10 TL real-card payment on your site, find it under Transactions (İşlemler) and refund it; the account status then updates. If no successful payment arrives within 30 days of completing the application, iyzico restricts API access until you contact support. Plan your environments around two FAQ rules: one domain per account (Back URL/IP Yönetimi under Ayarlar), and no account-type change, for example to marketplace, after the first successful payment.

Checkout Form step by step (PHP)​

1. Initialize the form​

Build the request from your database, never from totals posted by the browser. Keep money in integer kuruş and format it only for the API.
PHP:
require __DIR__ . '/vendor/autoload.php';

use Iyzipay\Options;
use Iyzipay\Model\{Address, BasketItem, BasketItemType, Buyer, CheckoutFormInitialize, Currency, Locale, PaymentGroup};
use Iyzipay\Request\CreateCheckoutFormInitializeRequest;

function iyzicoOptions(): Options
{
    $options = new Options();
    $options->setApiKey(getenv('IYZICO_API_KEY'));
    $options->setSecretKey(getenv('IYZICO_SECRET_KEY'));
    $options->setBaseUrl(getenv('IYZICO_BASE_URL'));
    return $options;
}

function money(int $kurus): string                 // 150050 -> "1500.50"
{
    return number_format($kurus / 100, 2, '.', '');
}

$order   = $orders->find($orderId);                // your data, never totals from the browser
$attempt = $payments->newAttempt($order);          // conversation_id such as "ord-10482-2"

$request = new CreateCheckoutFormInitializeRequest();
$request->setLocale(Locale::TR);
$request->setConversationId($attempt->conversation_id);
$request->setBasketId((string) $order->id);
$request->setPrice(money($order->items_total));        // must equal the sum of the items below
$request->setPaidPrice(money($order->grand_total));    // the amount to charge
$request->setCurrency(Currency::TL);                   // "TRY"
$request->setPaymentGroup(PaymentGroup::PRODUCT);
$request->setCallbackUrl('https://shop.example.com/payment/iyzico/callback');
$request->setEnabledInstallments([2, 3, 6, 9]);

$buyer = new Buyer();
$buyer->setId((string) $order->customer_id);
$buyer->setName($order->first_name);
$buyer->setSurname($order->last_name);
$buyer->setEmail($order->email);
$buyer->setGsmNumber($order->phone);                     // +905XXXXXXXXX
$buyer->setIdentityNumber($order->tckn ?: '11111111111'); // placeholder if you do not collect it
$buyer->setRegistrationAddress($order->address);
$buyer->setCity($order->city);
$buyer->setCountry('Turkey');
$buyer->setIp($clientIp);
$request->setBuyer($buyer);

$address = new Address();
$address->setContactName($order->first_name . ' ' . $order->last_name);
$address->setAddress($order->address);
$address->setCity($order->city);
$address->setCountry('Turkey');
$request->setBillingAddress($address);
$request->setShippingAddress($address);                // required when any item is PHYSICAL

$items = [];
foreach ($order->lines as $line) {                     // products, plus shipping as its own line
    $item = new BasketItem();
    $item->setId($line->sku);
    $item->setName($line->name);
    $item->setCategory1($line->category);
    $item->setItemType($line->digital ? BasketItemType::VIRTUAL : BasketItemType::PHYSICAL);
    $item->setPrice(money($line->total));              // quantity x unit price, above zero
    $items[] = $item;
}
$request->setBasketItems($items);

$init = CheckoutFormInitialize::create($request, iyzicoOptions());
if ($init->getStatus() !== 'success') {
    throw new RuntimeException($init->getErrorCode() . ': ' . $init->getErrorMessage());
}
$payments->saveToken($attempt->id, $init->getToken()); // the callback finds the attempt by token
header('Location: ' . $init->getPaymentPageUrl(), true, 303);
Redirect mode needs nothing more. For the embedded form, print the container and the returned script instead of redirecting:
PHP:
// Embedded mode: instead of the redirect, print the container and iyzico's inline script
echo '<div id="iyzipay-checkout-form" class="responsive"></div>';   // or class="popup"
echo $init->getCheckoutFormContent();
The token is short-lived (the documented sample shows tokenExpireTime 1800), so start a new form when a customer returns later.

2. Callback: retrieve, verify, mark paid once​

After payment, iyzico sends the browser to your callbackUrl with a POST that contains the token, whether the payment succeeded or not. The token proves nothing by itself: retrieve the result server-to-server, verify the signature, compare it with the order and change the order state exactly once.
PHP:
// POST /payment/iyzico/callback: excluded from CSRF checks, needs no session
use Iyzipay\Model\{CheckoutForm, Locale};
use Iyzipay\Request\RetrieveCheckoutFormRequest;

function iyzNum($v): string                    // 5.2 / "5.20000000" -> "5.2", 10.0 -> "10"
{
    $s = is_float($v) ? sprintf('%.8F', $v) : (string) $v;
    return str_contains($s, '.') ? rtrim(rtrim($s, '0'), '.') : $s;
}

function kurus($v): int                        // 1500.5 -> 150050
{
    return (int) round(((float) $v) * 100);
}

$token   = (string) ($_POST['token'] ?? '');
$attempt = $token !== '' ? $payments->findByToken($token) : null;
if (!$attempt) {
    http_response_code(400);
    exit;
}
$order = $orders->find($attempt->order_id);

$request = new RetrieveCheckoutFormRequest();
$request->setLocale(Locale::TR);
$request->setConversationId($attempt->conversation_id);
$request->setToken($token);
$result = CheckoutForm::retrieve($request, iyzicoOptions());

$expected = hash_hmac('sha256', implode(':', [
    $result->getPaymentStatus(), $result->getPaymentId(), $result->getCurrency(),
    $result->getBasketId(), $result->getConversationId(),
    iyzNum($result->getPaidPrice()), iyzNum($result->getPrice()), $result->getToken(),
]), getenv('IYZICO_SECRET_KEY'));

$succeeded = $result->getStatus() === 'success'
    && hash_equals($expected, (string) $result->getSignature())
    && $result->getPaymentStatus() === 'SUCCESS';
$matches = $succeeded
    && $result->getBasketId() === (string) $order->id
    && $result->getCurrency() === 'TRY'
    && kurus($result->getPrice()) === $order->items_total
    && kurus($result->getPaidPrice()) >= $order->grand_total;   // installment interest can raise it

if ($matches) {
    $state = $result->getFraudStatus() === 1 ? 'paid' : 'on_hold';   // 0 = under fraud review
    $pdo->beginTransaction();
    $stmt = $pdo->prepare("UPDATE orders SET status = ?, iyzico_payment_id = ?, paid_total = ?
                           WHERE id = ? AND status = 'pending'");
    $stmt->execute([$state, $result->getPaymentId(), kurus($result->getPaidPrice()), $order->id]);
    if ($stmt->rowCount() === 1) {                              // first time only: keep IDs for refunds
        foreach ($result->getPaymentItems() as $item) {
            $payments->saveItem($order->id, $item->getItemId(),
                $item->getPaymentTransactionId(), kurus($item->getPaidPrice()));
        }
    }
    $pdo->commit();
} elseif ($succeeded) {
    $payments->flagForReview($attempt->id, 'basket or amount mismatch');   // money was taken: a person decides
} else {
    $payments->markFailed($attempt->id, $result->getErrorCode(), $result->getErrorMessage());
}
header('Location: /orders/' . $order->id . '/result', true, 303);         // a GET on your own domain
Three details prevent most production incidents. The signature follows the documented field order for this endpoint (paymentStatus, paymentId, currency, basketId, conversationId, paidPrice, price, token), joined with colons, with trailing zeros removed from amounts ("10.50" becomes "10.5"). The conditional UPDATE stops a refreshed callback, a double POST or a simultaneous webhook from paying the order twice. The final 303 redirect turns the cross-site POST into a GET on your own domain, where the browser sends the session cookie again.
fraudStatus 0 means iyzico's fraud team is reviewing the payment: hold the order until the result arrives by e-mail and at the fraud notification URL you can set in the panel. With bank transfer or shopping credit enabled, the callback can also bring an intermediate state that the webhook resolves later.

The same callback in Node.js​

The iyzipay npm package is callback-based, so wrap it in a Promise. JavaScript numbers already drop trailing zeros (5.2, not 5.20000000).
Kod:
const crypto = require('crypto');
const express = require('express');
const Iyzipay = require('iyzipay');

const iyzipay = new Iyzipay({
  apiKey: process.env.IYZICO_API_KEY,
  secretKey: process.env.IYZICO_SECRET_KEY,
  uri: process.env.IYZICO_BASE_URL,
});

// The SDK is callback-based: wrap the call once
const retrieveCheckoutForm = (request) => new Promise((resolve, reject) =>
  iyzipay.checkoutForm.retrieve(request, (err, result) => (err ? reject(err) : resolve(result))));

const num = (v) => String(Number(v));   // 5.2 -> "5.2", 10 -> "10"

function signatureOk(r) {
  const data = [r.paymentStatus, r.paymentId, r.currency, r.basketId,
    r.conversationId, num(r.paidPrice), num(r.price), r.token].join(':');
  const expected = Buffer.from(crypto.createHmac('sha256', process.env.IYZICO_SECRET_KEY)
    .update(data).digest('hex'));
  const given = Buffer.from(String(r.signature || ''));
  return expected.length === given.length && crypto.timingSafeEqual(expected, given);
}

const app = express();
app.post('/payment/iyzico/callback', express.urlencoded({ extended: false }), async (req, res, next) => {
  try {
    const attempt = await payments.findByToken(String(req.body.token || ''));
    if (!attempt) return res.sendStatus(400);
    const r = await retrieveCheckoutForm({
      locale: Iyzipay.LOCALE.TR,
      conversationId: attempt.conversationId,
      token: attempt.token,
    });
    const ok = r.status === 'success' && signatureOk(r) && r.paymentStatus === 'SUCCESS'
      && r.basketId === String(attempt.orderId);         // plus the amount checks from the PHP version
    if (ok) await orders.markPaidOnce(attempt.orderId, r); // the same conditional UPDATE
    res.redirect(303, `/orders/${attempt.orderId}/result`);
  } catch (err) {
    next(err);
  }
});
In Python, the official client returns the raw HTTP response (parse it with json.load), and str(10.0) yields "10.0"; that is why iyzico's Python sample strips ".0" before it checks a signature.

Getting the amounts right​

  • price is the basket total and must equal the sum of the item prices exactly; otherwise you get error 5062 ("Total item price must be equal to price").
  • paidPrice is what the customer is charged. iyzico's FAQ puts discounts and shipping here; the official WooCommerce plugin sends shipping as its own basket item instead, which keeps refunds per line simple. Both work as long as price equals the item sum.
  • Every item must be above zero (error 5050), so a discount cannot be a negative line: lower paidPrice, or spread the discount over the items and give the rounding remainder to the last one.
  • Use a dot as the decimal separator. A Turkish-formatted "1,5" is the most common cause of "Request message is not readable"; in PHP, number_format($x, 2, '.', '') is safe.
  • Installment interest (vade farkı): if the customer bears it, the retrieved paidPrice is higher than the value you sent. The WooCommerce plugin books the difference as an installment fee line; decide how your accounting records it.
  • Currency: TRY by default. Other currencies need the multicurrency add-on (Ödeme Hizmeti ve Multicurrency, under Eklentiler), and per iyzico's FAQ, foreign-currency payments can only come from foreign-issued cards.
  • Each account has a per-transaction maximum (error 5008) that support can raise.

Direct API with 3D Secure​

With your own card form, 3D Secure takes two calls and a round trip through the browser:
  1. Optionally query BIN and installments (next section) to show options and to learn whether the card is forced to 3DS (force3ds = 1).
  2. Call /payment/3dsecure/initialize with the card, buyer, addresses, basket and callbackUrl. The response carries threeDSHtmlContent, a base64-encoded HTML page that auto-submits to the bank. The PHP SDK decodes it (getHtmlContent()); in Node.js or Python, decode it yourself.
  3. Send that HTML to the browser; the customer confirms with the bank's one-time password.
  4. The browser posts status, paymentId, conversationId, conversationData, mdStatus and signature to your callbackUrl.
  5. Verify the callback signature, require status success and mdStatus 1, then complete the payment. The docs recommend the v2 endpoint (/payment/v2/3dsecure/auth), where paidPrice, basketId and currency must match the initialize request.
PHP:
use Iyzipay\Model\{Currency, Locale, PaymentCard, PaymentChannel, ThreedsInitialize, ThreedsV2Payment};
use Iyzipay\Request\{CreatePaymentRequest, CreateThreedsV2PaymentRequest};

// Step 1: initialize
$request = new CreatePaymentRequest();
// locale, conversationId, basketId, price, paidPrice, currency, buyer, addresses, basket: as for the Checkout Form
$request->setInstallment($installment);                  // 1, or the count the customer chose
$request->setPaymentChannel(PaymentChannel::WEB);
$request->setCallbackUrl('https://shop.example.com/payment/iyzico/3ds-callback');
$request->setPaymentCard($card);   // PaymentCard: holder, number, expiry, CVC; never log $request

$init = ThreedsInitialize::create($request, iyzicoOptions());
if ($init->getStatus() === 'success') {
    echo $init->getHtmlContent();                        // the PHP SDK has already base64-decoded it
}

// Step 2: POST /payment/iyzico/3ds-callback
$p = fn(string $k): string => (string) ($_POST[$k] ?? '');
$expected = hash_hmac('sha256', implode(':', [
    $p('conversationData'), $p('conversationId'), $p('mdStatus'), $p('paymentId'), $p('status'),
]), getenv('IYZICO_SECRET_KEY'));

if (!hash_equals($expected, $p('signature')) || $p('status') !== 'success' || $p('mdStatus') !== '1') {
    exit;                                                // not authentic or 3DS failed: show an error, offer another card
}
$attempt = $payments->findByConversationId($p('conversationId'));

$auth = new CreateThreedsV2PaymentRequest();
$auth->setLocale(Locale::TR);
$auth->setConversationId($attempt->conversation_id);
$auth->setPaymentId($p('paymentId'));
$auth->setPaidPrice($attempt->paid_price);               // exactly the value sent at initialize
$auth->setBasketId($attempt->basket_id);
$auth->setCurrency(Currency::TL);
$payment = ThreedsV2Payment::create($auth, iyzicoOptions());
// then verify: paymentId, currency, basketId, conversationId, paidPrice, price (trailing zeros removed)
mdStatus 1 is the only success. 0 means an invalid 3D Secure signature or verification, often an issuer that did not answer (some foreign cards lack 3DS); 2 to 8 cover not enrolled, verification impossible, 3D Secure error, system error and unknown card. Show "card verification failed" and offer another card. An attempt stuck in INIT_THREEDS means the customer left the bank page. Never log the initialize request: it holds the card number and CVC, and PCI DSS forbids storing the CVC after authorization.

Installments: BIN and installment queries​

POST /payment/iyzipos/installment with the amount and the card's first eight digits (binNumber) returns the issuing bank, card family (Bonus, World, Maximum, Axess, CardFinans, Paraf, Advantage and others), card type, force3ds and installmentPrices: installmentNumber, installmentPrice and totalPrice for each available count. Without a BIN you get all options for the amount.
PHP:
use Iyzipay\Model\{InstallmentInfo, Locale};
use Iyzipay\Request\RetrieveInstallmentInfoRequest;

$request = new RetrieveInstallmentInfoRequest();
$request->setLocale(Locale::TR);
$request->setConversationId('inst-' . $order->id);
$request->setBinNumber(substr($cardDigits, 0, 8));       // first eight digits only
$request->setPrice(money($order->grand_total));

$info = InstallmentInfo::retrieve($request, iyzicoOptions());
foreach ($info->getInstallmentDetails() ?? [] as $detail) {
    // $detail->getForce3ds() === 1: this card must go through 3D Secure
    foreach ($detail->getInstallmentPrices() as $option) {
        printf("%d x %s TL = %s TL\n", $option->getInstallmentNumber(),
            $option->getInstallmentPrice(), $option->getTotalPrice());
    }
}
The rates come from Installment and Commission Management in your merchant panel, and changes there reach the service automatically. Who carries the interest is a commercial setting of your account. When the customer does, totalPrice exceeds the plain amount: send the chosen installment count with paidPrice set to that option's totalPrice, and keep price as the basket total. When you absorb it, the customer pays the plain amount and the cost is settled with iyzico under your agreement. In the Checkout Form, enabledInstallments limits the counts on offer; the official WooCommerce plugin does not send it at all. Debounce lookups in the UI and cache them per BIN and amount: at the time of writing the BIN check is limited to 50 requests a minute, and any exceeded limit returns errorCode 50000 (Request Limit Exceeded).

Webhooks: setup and V3 signature verification​

The callback depends on the customer's browser. If the tab closes after payment, the order stays pending although the card was charged. Webhooks close that gap, and they deliver the final result of asynchronous methods such as bank transfer without polling.
  • Set an HTTPS URL under Settings > Merchant Settings > Merchant Notifications and enable payment notifications. Subscription renewals go to a separate Merchant Subscription Notifications URL.
  • The X-IYZ-SIGNATURE-V3 header is sent only after iyzico enables the signature feature on your account; ask [email protected], for the sandbox account too. The older X-Iyz-Signature and X-Iyz-Signature-V2 headers are no longer supported.
  • Per the webhook docs, the first notification comes 10-15 seconds after the attempt, then every 15 minutes until you answer 2xx, for at most three attempts. The Checkout Form sample page says 10 minutes, so do not build logic on the interval.
  • Two payment formats: direct (API Non-3DS and 3DS, with paymentId) and HPP (Checkout Form and Pay with iyzico, with token and iyziPaymentId). Subscription notifications have a third format and their own concatenation order; check it against a real sandbox notification.
PHP:
// POST /payment/iyzico/webhook: JSON, server to server
$e = json_decode(file_get_contents('php://input'), true) ?: [];
$f = fn(string $k): string => (string) ($e[$k] ?? '');
$secret = getenv('IYZICO_SECRET_KEY');

$data = isset($e['token'])
    // HPP: Checkout Form, Pay with iyzico
    ? $secret . $f('iyziEventType') . $f('iyziPaymentId') . $f('token') . $f('paymentConversationId') . $f('status')
    // direct: API Non-3DS and 3DS
    : $secret . $f('iyziEventType') . $f('paymentId') . $f('paymentConversationId') . $f('status');

$given = strtolower($_SERVER['HTTP_X_IYZ_SIGNATURE_V3'] ?? '');
if (!hash_equals(hash_hmac('sha256', $data, $secret), $given)) {
    http_response_code(401);                              // log it, process nothing
    exit;
}
$queue->push('iyzico.payment', $e);                       // a worker re-queries the payment and updates the order idempotently
http_response_code(200);
In Python, build the same string and compare with hmac.compare_digest. Treat the webhook as a trigger, not as the source of truth: after the signature check, re-query the payment (Checkout Form retrieve by token, or payment detail by paymentId) and apply the callback's idempotent state change; the official WooCommerce plugin works exactly this way. Answer 2xx fast and do the work in a queue, as described in our guide to secure webhook receivers. The fraud review notification (paymentId plus fraudStatus 2 or -1) has no documented signature, so re-query that payment too.

Cancel or refund?​

CancelRefundRefund V2 (amount based)
Endpoint/payment/cancel/payment/refund/v2/payment/refund
IdentifierpaymentIdpaymentTransactionId (one basket item)paymentId
Partial amountNo, the whole paymentYes, repeatable up to the item's paidPriceYes
WhenSame day as the paymentWithin 365 daysWithin 365 days; iyzico advises against it for multi-item baskets
Customer's statementNo entry at most banksA separate credit; timing depends on the bank, typically a few daysAs for a refund
PHP:
use Iyzipay\Model\{Cancel, Currency, Locale, Refund};
use Iyzipay\Request\{CreateCancelRequest, CreateRefundRequest};

// Partial refund of one basket item (repeatable up to that item's paidPrice)
$request = new CreateRefundRequest();
$request->setLocale(Locale::TR);
$request->setConversationId('refund-' . $refundId);
$request->setPaymentTransactionId($item->payment_transaction_id);
$request->setPrice(money($amountKurus));
$request->setCurrency(Currency::TL);
$request->setIp($staffIp);
$refund = Refund::create($request, iyzicoOptions());
if ($refund->getStatus() !== 'success') {
    $log->warning('iyzico refund failed', ['code' => $refund->getErrorCode(), 'message' => $refund->getErrorMessage()]);
}

// Same-day cancel of the whole payment
$cancel = new CreateCancelRequest();
$cancel->setLocale(Locale::TR);
$cancel->setConversationId('cancel-' . $order->id);
$cancel->setPaymentId($order->iyzico_payment_id);
$cancel->setIp($staffIp);
$result = Cancel::create($cancel, iyzicoOptions());
The refund response is signed over paymentId, price, currency and conversationId, and carries a retryable flag to check before any retry. Mind the per-minute limits in bulk jobs (at the time of writing 400 for /payment/refund, 150 for Refund V2 and cancel). In WooCommerce, the "via iyzico" refund calls the API, while a manual refund only updates the order. Keep refund rights with a few staff members and log who refunded what.

Marketplace basics: sub-merchants and approval​

In the marketplace model, payments collect in one iyzico account and reach sellers (sub-merchants) only after you approve them; sellers do not sign up with iyzico themselves.
  • Register each seller once with POST /onboarding/submerchant as PERSONAL, PRIVATE_COMPANY or LIMITED_OR_JOINT_STOCK_COMPANY and store the returned subMerchantKey. The IBAN must match the contact name or legal company title and be on file before you approve a payout.
  • Each basket item carries subMerchantKey and subMerchantPrice, the seller's share. iyzico's FAQ example: a 100 TL item with 4 TL commission and a subMerchantPrice of 80 TL sends 80 TL to the seller and 16 TL to the marketplace.
  • After delivery, approve each item with POST /payment/iyzipos/item/approve and its paymentTransactionId; disapprove withdraws an approval. Approvals exist only in the API, not in the panel. transactionStatus 1 means awaiting approval, 2 approved.
  • To sell your own products, create a sub-merchant for yourself or use paymentGroup LISTING. Sub-merchants cannot be deleted, subscriptions are unavailable on marketplace accounts, and withholdingTax is an optional, report-only field; ask your accountant whether the e-commerce withholding (stopaj) applies to you.

Adapting the flow to WHMCS​

A WHMCS gateway module receives its callback in modules/gateways/callback/. The flow stays the same: basketId is the invoice ID, conversationId adds an attempt counter, and after retrieving and verifying the result, WHMCS's helpers handle duplicate detection and the gateway log.
PHP:
// modules/gateways/callback/iyzicocheckout.php (sketch)
require_once __DIR__ . '/../../../init.php';
require_once __DIR__ . '/../../../includes/gatewayfunctions.php';
require_once __DIR__ . '/../../../includes/invoicefunctions.php';

$gatewayModuleName = basename(__FILE__, '.php');
$gatewayParams = getGatewayVariables($gatewayModuleName);
if (!$gatewayParams['type']) {
    die('Module Not Activated');
}

$result = iyzicoRetrieveAndVerify($_POST['token'] ?? '', $gatewayParams);       // retrieve + signature check, as above
$invoiceId = checkCbInvoiceID($result->getBasketId(), $gatewayParams['name']);  // basketId = invoice ID
checkCbTransID($result->getPaymentId());                                        // stops here if this paymentId is already recorded
logTransaction($gatewayParams['name'], [
    'paymentId' => $result->getPaymentId(),
    'status' => $result->getPaymentStatus(),
    'fraudStatus' => $result->getFraudStatus(),
], $result->getPaymentStatus());

if ($result->getPaymentStatus() === 'SUCCESS' && $result->getFraudStatus() === 1) {
    addInvoicePayment($invoiceId, $result->getPaymentId(), 0, 0.00, $gatewayModuleName); // 0 = invoice balance
}
An amount of 0 makes addInvoicePayment apply the invoice balance. If the customer paid installment interest, decide how to record the difference instead of leaving an unexplained overpayment, and keep fraudStatus 0 invoices unpaid until the review ends. We used this pattern in a WHMCS iyzico integration covering 3D Secure, installments, refunds and webhook-driven invoice sync.

Troubleshooting​

SymptomLikely causeFix
errorCode 1000 "Invalid signature" on every callWhitespace around the secret key, mixed key pairs, a hand-built Authorization headerUse the SDK, trim the keys, test with a BIN check
1001 "API details not found"Sandbox keys sent to api.iyzipay.com, or live keys to the sandboxKeys and base URL from the same environment
5062 "Total item price must be equal to price"Float rounding; shipping or discount in one field but not the otherInteger kuruş; shipping as an item; discount in paidPrice or spread over items
Your response signature check fails on a good paymentWrong field order, request values instead of response values, trailing zerosDocumented order per endpoint, response values, zeros stripped
Callback answers 419 or 403, or the customer looks logged out after payingCSRF middleware on the route; a SameSite=Lax session cookie is not sent with a cross-site POSTExclude the route from CSRF, find the attempt by token, 303-redirect to a GET page
Charged at iyzico, order still pendingTab closed before the callback, or the callback crashedWebhook plus a daily reconciliation job
Order confirmed twice, stock reduced twiceRefreshed callback; webhook and callback racingConditional UPDATE, unique index on paymentId
Two charges for one orderDouble click on Pay in the API flowOne active attempt per order, a disabled button, same-day cancel of the duplicate
5122 when retrievingThe customer opened the form and left without payingNot an error: keep the order pending, expire it later
5010 (the payment must use 3D Secure)Your account requires 3DS, the request was Non-3DSUse the 3DS flow
No webhooks, or no X-IYZ-SIGNATURE-V3 headerNotifications off, URL not HTTPS, signatures not enabled, your endpoint answers non-2xxEnable in the panel, ask [email protected], log your own status codes
Timeout on retrieve or 3DS authNetwork or upstream latency; the PHP SDK's default client sets no cURL timeoutTreat the result as unknown and query payment detail before any retry
In Laravel, list the callback in validateCsrfTokens(except: [...]) in bootstrap/app.php or register it outside the web middleware group; its session cookie defaults to SameSite=Lax, which browsers do not send with a cross-site POST. The bug can look intermittent because a cookie without a SameSite attribute, which some browsers treat as Lax by default, is still sent on a POST within two minutes of being set. For explicit timeouts, the PHP SDK accepts your own HTTP client through \Iyzipay\ApiResource::setHttpClient(). For reconciliation, GET /v2/reporting/payment/details answers by paymentId or paymentConversationId, and GET /v2/reporting/payment/transactions lists a day's payments, cancels and refunds.

Security checklist​

  • Keys in environment variables or a secret manager, separate for sandbox and live; never in front-end code, apps or the repository.
  • Basket, amounts and currency built on the server; after payment, basketId, price, paidPrice (at least the order total) and currency compared with the order.
  • Every signature (response, 3DS callback, webhook) checked in constant time: hash_equals, crypto.timingSafeEqual or hmac.compare_digest.
  • No card numbers or CVCs in logs, error trackers or analytics; iyzico's responses carry only the BIN and the last four digits.
  • buyer.ip set to the customer's real IP, read from your trusted proxy's header when behind one.
  • Callback and webhook routes: POST over HTTPS only, outside CSRF middleware, no business logic before verification.
  • Shipping only when fraudStatus is 1; refunds and cancels behind staff permissions with an audit trail; a daily reconciliation that alerts on differences.

FAQ​

Should I use the Checkout Form or the API?
The Checkout Form, unless you have a concrete reason not to. It covers 3D Secure, installments, the wallet and alternative methods with two API calls and keeps card data off your systems. Pick the API for your own card form, self-managed card storage or a native app flow, and budget for the PCI DSS work.

Can I store cards for one-click payments?
Yes, with the card storage add-on (Ödeme Hizmeti ve Kart Saklama): send registerCard = 1 in a 3DS or Non-3DS payment, or create the card beforehand, and keep the returned cardUserKey and cardToken, never the card number. Cards saved in the Checkout Form go to the customer's iyzico wallet and return no tokens. iyzico suggests 3DS for the first charge and Non-3DS later; the issuer decides whether those pass.

How do I test 3D Secure?
In the sandbox, 3DS initialize returns iyzico's mock 3D Secure page instead of a bank page, so the full round trip works with the success cards. Use 4131111111111117 (mdStatus 0), 4141111111111115 (mdStatus 4) and 4151111111111112 (initialize fails) for the failure branches. Webhooks need a public HTTPS endpoint; a tunnel works during development.

How does a refund look to the customer?
A same-day cancel usually leaves no line on the statement. A refund shows as a separate credit, and how fast depends on the issuing bank, typically a few days. Refunds are possible for 365 days.

Does iyzico support subscriptions?
Yes, as a paid add-on: you define products and pricing plans, start a subscription through a checkout form or the API, and iyzico charges on schedule and notifies your subscription URL. Limits: credit cards only, every charge runs without 3D Secure (the first one too), the customer needs a +90 mobile number, plan prices cannot be edited (you upgrade to a new plan), and marketplace accounts cannot use it. Otherwise, card storage plus your own scheduler is the alternative.

Conclusion and next steps​

A reliable iyzico integration depends less on the API calls than on what surrounds them: amounts built on the server, every result verified by signature, one idempotent state change shared by callback and webhook, and a reconciliation job for when both fail.
  1. Open a sandbox account, store the keys in environment variables and confirm them with a BIN check.
  2. Build the Checkout Form in redirect mode: initialize, callback, retrieve, signature check, conditional update.
  3. Add the webhook endpoint and ask iyzico to enable X-IYZ-SIGNATURE-V3.
  4. Test the failure paths: error cards, a closed tab, a refreshed callback, a double click, partial refunds and a cancel.
  5. Schedule the daily reconciliation, then go live with live keys and one small real-card payment and its refund.
 
Son düzenleyen:

Forum istatistikleri

Konular
925
Mesajlar
932
Üyeler
26
En son üye
Jessicawatson

Çevrimiçi üyeler

Şu anda çevrimiçi üye yok.

Öne çıkan içerik

AIOR
AIOR TEKNOLOJİ

Tüm ihtiyaçlarınız için Teklif alın

Hosting · Domain · Sunucu · Tasarım · Yazılım · Mühendislik · Sektörel Çözümler

Teklif al

7/24 Destek · Anında yanıt

Geri
Üst