Payments (iyzico, PayTR)
@vamos-ui/react/payments talks to iyzico's hosted Checkout Form and PayTR's iFrame API from your server: it signs requests, checks response and notification signatures, and never handles card data. Sandbox / test mode unless you switch it off.
Ground rules
- Server only: route handlers, server actions, a Node or edge function. Web Crypto and
fetch, no SDK dependency. - Keys come only from the config you pass — read them from environment variables. They are never logged and never appear in error messages.
- iyzico defaults to
mode: "sandbox"(sandbox-api.iyzipay.com); PayTR defaults totestMode: true(test_mode=1). Real payments need an explicitmode: "live"/testMode: false. - Card details are entered on the provider's hosted page or iframe, never in your form.
- A redirect back to your site is not proof of payment. Only a verified result (
paid: true) is.
iyzico Checkout Form
initializeCheckoutForm starts a session and returns paymentPageUrl (redirect) and checkoutFormContent (inline form). The basket total must equal the sum of the items; prices are decimal strings. After payment iyzico posts a token to your callbackUrl; retrieveCheckoutForm(token) asks iyzico for the result and checks its signature.
// lib/iyzico.ts
import { createIyzicoClient } from "@vamos-ui/react/payments";
export const iyzico = createIyzicoClient({
apiKey: process.env.IYZICO_API_KEY!,
secretKey: process.env.IYZICO_SECRET_KEY!,
mode: process.env.IYZICO_MODE === "live" ? "live" : "sandbox",
});
// app/odeme/actions.ts
"use server";
export async function startCheckout(orderId: string) {
const order = await orders.get(orderId);
const { paymentPageUrl } = await iyzico.initializeCheckoutForm({
price: order.total, // "349.90" = sum of basketItems[].price
basketId: order.id,
conversationId: order.id,
callbackUrl: "https://ornek.com.tr/odeme/sonuc",
buyer: { id: order.customerId, name: "Deniz", surname: "Kaya", identityNumber: "10000000146", email: "deniz@ornek.com.tr",
gsmNumber: "+902160000000", registrationAddress: "Rasimpaşa Mah. Rıhtım Cad. No: 14", city: "İstanbul", country: "Turkey" },
billingAddress: { contactName: "Deniz Kaya", city: "İstanbul", country: "Turkey", address: "Rasimpaşa Mah. Rıhtım Cad. No: 14" },
basketItems: [{ id: "W1", name: "Seramik atölyesi", category1: "Atölye", itemType: "VIRTUAL", price: "349.90" }],
});
redirect(paymentPageUrl!);
}
// app/odeme/sonuc/route.ts — iyzico posts the token here
export async function POST(req: Request) {
const token = (await req.formData()).get("token");
const result = await iyzico.retrieveCheckoutForm(token);
if (result.paid) await orders.markPaid(result.basketId!, { paymentId: result.paymentId, amount: result.paidPrice });
return Response.redirect(new URL(result.paid ? "/odeme/tamam" : "/odeme/hata", req.url), 303);
}| Result field | Meaning |
|---|---|
paid | status success, signature verified and paymentStatus SUCCESS — the only field to act on. |
verified | The response signature (paymentStatus, paymentId, currency, basketId, conversationId, paidPrice, price, token) matched. |
paidPrice / price | As iyzico returned them; compare with your order before fulfilling it. |
errorCode / errorMessage | iyzico's own error, for failures. |
raw | The full response (keep it out of client responses). |
iyzico notifications
If merchant notifications are enabled for your account, iyzico posts JSON to your notification URL with an X-IYZ-SIGNATURE-V3 header. verifyIyzicoWebhook supports the direct and the HPP (checkout form) formats; answer with a 2xx once handled.
import { verifyIyzicoWebhook } from "@vamos-ui/react/payments";
export async function POST(req: Request) {
const body = await req.json();
if (!(await verifyIyzicoWebhook(process.env.IYZICO_SECRET_KEY!, req.headers.get("x-iyz-signature-v3"), body))) {
return new Response("bad signature", { status: 400 });
}
// body.status, body.paymentConversationId … — re-check with retrieveCheckoutForm(body.token) before fulfilling
return new Response(null, { status: 204 });
}PayTR iFrame API
Step 1: createPaytrIframeToken computes paytr_token, asks PayTR for an iframe token and returns the iframe URL. Amounts are decimal strings, sent as kuruş. Step 2: PayTR posts the result to the notification URL set in the merchant panel; verifyPaytrCallback checks its hash, and the route answers with the plain text OK — also for repeated notifications of an order already handled.
import { createPaytrIframeToken, verifyPaytrCallback, PAYTR_CALLBACK_OK, PAYTR_IFRAME_RESIZER } from "@vamos-ui/react/payments";
const paytr = {
merchantId: process.env.PAYTR_MERCHANT_ID!,
merchantKey: process.env.PAYTR_MERCHANT_KEY!,
merchantSalt: process.env.PAYTR_MERCHANT_SALT!,
testMode: process.env.PAYTR_TEST_MODE !== "0",
};
// Step 1 (server component or action)
const { iframeUrl } = await createPaytrIframeToken(paytr, {
merchantOid: "YA20261115001", // letters and digits, unique per order
email: "deniz@ornek.com.tr",
amount: "349.90",
userIp, // the customer's public IP
basket: [{ name: "Seramik atölyesi", price: "349.90", quantity: 1 }],
userName: "Deniz Kaya", userAddress: "Rasimpaşa Mah. Rıhtım Cad. No: 14, Kadıköy/İstanbul", userPhone: "+902160000000",
okUrl: "https://ornek.com.tr/odeme/tamam", failUrl: "https://ornek.com.tr/odeme/hata",
});
// <iframe src={iframeUrl} id="paytriframe" className="w-full" /> + <Script src={PAYTR_IFRAME_RESIZER} />
// Step 2: app/api/paytr/bildirim/route.ts
export async function POST(req: Request) {
const result = await verifyPaytrCallback(paytr, await req.formData());
if (!result.verified) return new Response("bad hash", { status: 400 });
if (result.paid) await orders.markPaidOnce(result.merchantOid, result.totalAmount); // idempotent
return new Response(PAYTR_CALLBACK_OK, { headers: { "Content-Type": "text/plain" } });
}OK. Customers never visit it; session data is not available there — find the order by merchantOid.Reference
| Export | Does |
|---|---|
createIyzicoClient(config) | { mode, initializeCheckoutForm(req), retrieveCheckoutForm(token, opts?) }. config: apiKey, secretKey, mode?, baseUrl?, fetch?. |
verifyIyzicoWebhook(secretKey, signature, body) | X-IYZ-SIGNATURE-V3 check (direct and HPP formats). |
iyzicoSignature · iyzicoAuthorization · iyzicoTrimPrice · iyzicoCheckoutBody | The building blocks: response signature, IYZWSv2 header, trailing-zero rule, validated request body. |
createPaytrIframeToken(config, req) | Step 1: { token, iframeUrl }. config: merchantId, merchantKey, merchantSalt, testMode? (default true), fetch?. |
paytrTokenFields(config, req) | The form fields of the token request, paytr_token included, without sending them. |
verifyPaytrCallback(config, form) | Step 2: { verified, paid, merchantOid, status, totalAmount, paymentAmount, testMode, failedReasonCode? … }. |
Tests cover iyzico's published response-signature sample and its trailing-zero table; the other signatures are checked against an independent node:crypto implementation of the documented formulas.