Ö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çenek | Kart bilgisi nereye girilir? | PCI DSS kapsamı (yaklaşık) | Efor | Tipik kullanım |
|---|---|---|---|---|
| Ödeme formu, yönlendirme (paymentPageUrl) | iyzico'nun barındırdığı sayfa | En dar: Kart verisi sayfalarınıza ve sunucularınıza hiç ulaşmaz | Düşü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 form | Dar; ancak sayfanız ve üzerindeki betikler artık önemlidir | Düşük-orta | Müş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 gibi | Düşük | Cüzdan kullanıcıları; ödeme formunda sekme olarak da var |
| Doğrudan API, 3DS | Kendi formunuz; kart numarasını sunucunuz iyzico'ya gönderir | Tam: Sistemleriniz kart verisi işler (SAQ D bölgesi) | Yüksek | Özel ödeme ekranı, uygulama backend'i, saklı kartlar |
| Doğrudan API, Non-3DS | Aynısı, banka yönlendirmesi olmadan | Tam | Orta-yüksek | Yalnızca hesabınız izin veriyorsa; saklı kartla tahsilat |
- Ö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
| Kart numarası | Sonuç |
|---|---|
| 5528790000000008 | Başarılı: Halkbank, Mastercard kredi kartı |
| 4766620000000001 | Başarılı: Denizbank, Visa banka kartı |
| 9792030000000000 | Başarılı: QNB, Troy kredi kartı |
| 5400010000000004 | Başarılı: yabancı kredi kartı |
| 4111111111111129 | Yetersiz bakiye |
| 4131111111111117 | Başarılı ama mdStatus 0 (3DS başarısız) |
| 4151111111111112 | 3D Secure başlatma başarısız |
| 5406670000000009 | Başarılı, ancak iptal, iade ya da provizyon kapama yapılamaz |
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);
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();
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
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);
}
});
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:- İ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.
- 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.
- Bu HTML'i tarayıcıya gönderin; müşteri bankanın gönderdiği tek kullanımlık şifreyle onay verir.
- Tarayıcı callbackUrl adresinize status, paymentId, conversationId, conversationData, mdStatus ve signature alanlarını POST eder.
- 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ış)
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());
}
}
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);
İptal mi, iade mi?
| İptal | İade | İade V2 (tutar bazlı) | |
|---|---|---|---|
| Uç nokta | /payment/cancel | /payment/refund | /v2/payment/refund |
| Kimlik | paymentId | paymentTransactionId (tek sepet kalemi) | paymentId |
| Kısmi tutar | Hayır, ödemenin tamamı | Evet; kalemin paidPrice tutarına kadar tekrarlanabilir | Evet |
| Ne zaman | Ödemeyle aynı gün | 365 gün içinde | 365 gün içinde; iyzico çok kalemli sepetlerde önermiyor |
| Müşterinin ekstresi | Çoğu bankada hiç görünmez | Ayrı 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());
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
}
Sorun giderme
| Belirti | Olası 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ız | Yanlış alan sırası, yanıt yerine istekteki değerler, ardıl sıfırlar | Servise 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üyor | Rotada CSRF ara katmanı; SameSite=Lax oturum çerezi siteler arası POST ile gönderilmez | Rotayı 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â beklemede | Sekme 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 callback | Koşullu UPDATE, paymentId üzerinde benzersiz indeks |
| Tek siparişe iki çekim | API akışında Öde düğmesine çift tıklama | Sipariş 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ığı yok | Bildirimler kapalı, adres HTTPS değil, imza açılmamış, uç noktanız 2xx dışında yanıt veriyor | Panelden 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ımlamaz | Sonucu bilinmiyor sayın, tekrar denemeden önce ödeme detayını sorgulayın |
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.- Bir sandbox hesabı açın, anahtarları ortam değişkenlerine koyun ve bir BIN sorgusuyla doğrulayın.
- Ödeme formunu yönlendirme modeliyle kurun: başlatma, callback, sorgulama, imza kontrolü, koşullu güncelleme.
- Webhook uç noktasını ekleyin ve iyzico'dan X-IYZ-SIGNATURE-V3'ü açmasını isteyin.
- Olumsuz senaryoları test edin: hata kartları, kapanan sekme, yenilenen callback, çift tıklama, kısmi iadeler ve bir iptal.
- 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?
| Option | Where the card is entered | PCI DSS scope (indicative) | Effort | Typical use |
|---|---|---|---|---|
| Checkout Form, redirect (paymentPageUrl) | iyzico's hosted page | Smallest: card data never reaches your pages or servers | Low | Most web shops, WHMCS, SaaS billing |
| Checkout Form, embedded (responsive or popup) | A form that iyzico's script renders inside your page | Small, but your page and its scripts now matter | Low to medium | Shops 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 page | Low | Wallet users; also a tab inside the Checkout Form |
| Direct API with 3DS | Your own form; your server sends the card number to iyzico | Full: your systems handle card data (SAQ D territory) | High | Custom checkout, app backends, stored cards |
| Direct API, Non-3DS | Same, without the bank redirect | Full | Medium to high | Only if your account allows it; stored-card charges |
- 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
| Card number | Result |
|---|---|
| 5528790000000008 | Success: Halkbank, Mastercard credit |
| 4766620000000001 | Success: Denizbank, Visa debit |
| 9792030000000000 | Success: QNB, Troy credit |
| 5400010000000004 | Success: non-Turkish credit card |
| 4111111111111129 | Not sufficient funds |
| 4131111111111117 | Success but mdStatus 0 (failed 3DS) |
| 4151111111111112 | 3D Secure initialize failed |
| 5406670000000009 | Success, but cannot be cancelled, refunded or post-authorized |
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);
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();
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
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);
}
});
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:- Optionally query BIN and installments (next section) to show options and to learn whether the card is forced to 3DS (force3ds = 1).
- 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.
- Send that HTML to the browser; the customer confirms with the bank's one-time password.
- The browser posts status, paymentId, conversationId, conversationData, mdStatus and signature to your callbackUrl.
- 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)
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());
}
}
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);
Cancel or refund?
| Cancel | Refund | Refund V2 (amount based) | |
|---|---|---|---|
| Endpoint | /payment/cancel | /payment/refund | /v2/payment/refund |
| Identifier | paymentId | paymentTransactionId (one basket item) | paymentId |
| Partial amount | No, the whole payment | Yes, repeatable up to the item's paidPrice | Yes |
| When | Same day as the payment | Within 365 days | Within 365 days; iyzico advises against it for multi-item baskets |
| Customer's statement | No entry at most banks | A separate credit; timing depends on the bank, typically a few days | As 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());
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
}
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| errorCode 1000 "Invalid signature" on every call | Whitespace around the secret key, mixed key pairs, a hand-built Authorization header | Use 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 sandbox | Keys 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 other | Integer kuruş; shipping as an item; discount in paidPrice or spread over items |
| Your response signature check fails on a good payment | Wrong field order, request values instead of response values, trailing zeros | Documented order per endpoint, response values, zeros stripped |
| Callback answers 419 or 403, or the customer looks logged out after paying | CSRF middleware on the route; a SameSite=Lax session cookie is not sent with a cross-site POST | Exclude the route from CSRF, find the attempt by token, 303-redirect to a GET page |
| Charged at iyzico, order still pending | Tab closed before the callback, or the callback crashed | Webhook plus a daily reconciliation job |
| Order confirmed twice, stock reduced twice | Refreshed callback; webhook and callback racing | Conditional UPDATE, unique index on paymentId |
| Two charges for one order | Double click on Pay in the API flow | One active attempt per order, a disabled button, same-day cancel of the duplicate |
| 5122 when retrieving | The customer opened the form and left without paying | Not an error: keep the order pending, expire it later |
| 5010 (the payment must use 3D Secure) | Your account requires 3DS, the request was Non-3DS | Use the 3DS flow |
| No webhooks, or no X-IYZ-SIGNATURE-V3 header | Notifications off, URL not HTTPS, signatures not enabled, your endpoint answers non-2xx | Enable in the panel, ask [email protected], log your own status codes |
| Timeout on retrieve or 3DS auth | Network or upstream latency; the PHP SDK's default client sets no cURL timeout | Treat the result as unknown and query payment detail before any retry |
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.- Open a sandbox account, store the keys in environment variables and confirm them with a BIN check.
- Build the Checkout Form in redirect mode: initialize, callback, retrieve, signature check, conditional update.
- Add the webhook endpoint and ask iyzico to enable X-IYZ-SIGNATURE-V3.
- Test the failure paths: error cards, a closed tab, a refreshed callback, a double click, partial refunds and a cancel.
- Schedule the daily reconciliation, then go live with live keys and one small real-card payment and its refund.
Son düzenleyen: