Why this list
Payment integration is where the gap between "it works" and "it works correctly" is most expensive. When a test card payment goes through, the job looks done. But the real problems show up in production: the day the same notification arrives twice, the user closes the tab on the 3D Secure screen, or someone tries to change the amount in their browser.
While connecting iyzico, PayTR, Stripe and Nestpay to the same dashboard in PlusTurk, I found that every provider speaks a different language but the rules of a correct integration stay almost the same. The five points below are the rules I follow whichever provider I'm working with.
1. Card details should never reach your server
The most common mistake in virtual POS integrations is collecting the card number in your own form and passing it to the provider through your own server. Technically it works, but from that moment your system is processing card data and your PCI DSS scope grows dramatically. Your servers, your logs and your backups all fall within it.
Instead, I use the payment page or embedded form the provider hosts: iyzico's Checkout Form, PayTR's iFrame API, Stripe Checkout or Elements. The card details go straight to the provider; your server only opens a payment session and learns the result. For PCI DSS, this brings your self-assessment down to the lightest level (SAQ A).
There's a side benefit too: the provider handles the 3D Secure flow, the bank screens and features such as card tokenisation. Rebuilding those in your own form is both a waste of time and a risk.
2. Trust the server notification, not the browser redirect
When the payment finishes, the user is redirected to a "success" page on your site. Treating that redirect as proof of payment is the second most common mistake. The user can open that address by hand, lose their connection during the redirect, or close the tab.
The only thing that should change an order's status is the server-to-server notification (callback or webhook) the provider sends. And I don't accept that notification as is either: every provider sends a signature proving the notification came from them, and nothing happens until that signature is verified. Here's a simplified version of the PayTR notification handler I wrote in Next.js:
import { createHmac, timingSafeEqual } from "node:crypto";
export async function POST(req: Request) {
const form = await req.formData();
const oid = String(form.get("merchant_oid"));
const status = String(form.get("status"));
const total = String(form.get("total_amount"));
const received = String(form.get("hash"));
const expected = createHmac("sha256", process.env.PAYTR_MERCHANT_KEY!)
.update(oid + process.env.PAYTR_MERCHANT_SALT! + status + total)
.digest("base64");
const a = Buffer.from(received);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return new Response("invalid hash", { status: 400 });
}
await markOrderPaidOnce(oid, status, total);
return new Response("OK");
}The order in which the fields are concatenated varies between providers and can change over time, so always check the formula against the provider's current documentation. In Stripe, stripe.webhooks.constructEvent does the same job and needs the raw (unparsed) request body. If you read the body as JSON and then try to verify the signature, verification fails.
I use timingSafeEqual instead of === for the comparison so that the time it takes doesn't leak information about the signature.
3. The same notification will arrive twice: make operations idempotent
Providers keep resending a notification until they get the expected response. PayTR expects the plain text "OK"; if your server is slow or errors at that moment, the same notification arrives several more times. Stripe also retries failed webhooks at intervals.
So the "paid" operation has to give the same result no matter how many times it runs. Here's how I do it:
- Every payment attempt has a unique order number, and that column is
UNIQUEin the database. - The status change is a single conditional update: "mark this order as paid if its status is pending". If the order is already paid, the update affects no rows and side effects such as SMS, invoices and emails aren't triggered.
- The side effects (a receipt SMS to the debtor, closing the file, generating documents) are queued once, only when that update succeeds.
The same rule applies in the other direction: when a request you send to the provider fails with a network error and you retry, you mustn't charge twice. Stripe supports an Idempotency-Key header for this; with other providers, a unique order number does the same job.
4. Calculate the amount on the server, never take it from the browser
Every value that comes from the browser is in the user's hands. If you take the amount from a form or a client-side field when opening the payment session, someone with developer tools open can pay 1.50 TL for a 1,500 TL order. And the provider will happily approve it, because that's the amount it was asked for.
The rule is simple: the client only sends "what" is being bought (product ID, quantity, debt file number), and the server works out the amount from the database. When the notification arrives, I also compare the amount paid with the order's expected amount. If they don't match, the order isn't approved automatically and goes to a review list.
Two more details about money:
- I store amounts as integers in the smallest unit (kuruş, like cents). Money calculations with floating-point numbers sooner or later produce a one-cent difference and unexplained lines in reconciliation.
- With instalment payments, the provider may add an instalment charge. The amount in the notification can differ from the order amount, so keep a field on the record that lets you tell whether the difference is an expected instalment charge or a problem.
5. Treat API keys as seriously as card data
In a multi-tenant platform, every customer connects to the payment provider with their own account. That means your database holds your customers' merchant keys. If those keys leak, card data might not leak, but someone could start refunds in your customer's name or read their transaction history.
Here's how I handled it in PlusTurk:
- Keys are encrypted in the database with AES-256-GCM; the encryption key isn't in the database but in the server's environment variables.
- Once a key is saved in the dashboard, it's never shown in full again; only the last few characters are visible.
- Changing a key is recorded in the activity history and requires two-factor authentication.
- API keys and signature fields are stripped from request and response logs; only the masked card number the provider returns is kept. Logs are usually the least protected part of a system.
I described the broader version of this approach in Zero Trust in SaaS applications.
The final check: reconciliation
Even if you follow all five rules, one day a notification will get lost: your server is down for that minute, or there's a problem on the provider's side. So I set up a small reconciliation job that runs every night. It asks the provider's query API about orders that have stayed "pending" for the last 24 hours, marks the ones the provider shows as paid, and puts any mismatches on a list. It's the surest way to never have a user say "I was charged but my order isn't showing".
If you're building a payment system from scratch or want your existing integration reviewed, you can see how I work on the payment integration page.
Cantürk Çelikok
Freelance full stack developer based in Ankara, Türkiye. He built live products such as PlusTurk, Nerdesin, Ayda POS and Songnaria Coffee end to end, and shares the notes from those projects here.
