Zarinpal Payment Gateway in Next.js: Webhooks, Verification, and Duplicate-Payment Safety
By Arash Latifi
Wire a payment gateway into Next.js App Router with Route Handlers: order creation, gateway redirect, webhook verification, and idempotent payment handling.
TL;DR — Take payments through a Route Handler, finalize order state from the webhook, and guard against a single order being paid twice.
Connecting a payment gateway looks simple: the user clicks Pay, lands on the gateway, comes back, order is recorded. That's exactly where most Next.js stores break, because the redirect back is not a trustworthy request. Get it wrong and you end up with duplicate charges and orphaned orders.
Let's build the whole path: order creation, redirect to the gateway, webhook, verification, and duplicate protection.
Correct architecture: two separate paths
The gateway talks to your site twice, and the two paths do different jobs:
- Redirect / Callback (GET): the user returns from the gateway and sees a "checking payment" page. This path is untrusted and only for display.
- Webhook (POST): the gateway's server tells your server the payment actually succeeded. This path is the source of truth and finalizes the order state.
If you set the payment status only from the browser return, any user who closes the tab leaves an order stuck in "pending" forever.
The full path: the user hits POST /api/checkout, the order is saved as pending and the browser is redirected to the gateway. The gateway returns the user to /pay/callback (display only) and separately POSTs to /api/webhook/zarinpal, which finalizes the order.
Step 1: create the order with a unique attempt ID
Every payment attempt needs a unique identifier. The database is where disasters get prevented.
Take the amount from your own database, never from the request body. Client-supplied prices are attacker input.
Step 2: Route Handler for the gateway redirect
The metadata.attempt_id field saves you from guessing later. You already stored the amount and the ID somewhere trustworthy.
Step 3: the webhook, where payment is actually verified
That if (order.status === "paid") is the simplest and most important line in the whole flow. Gateways commonly retry webhooks; without this guard you decrement stock twice for one payment.
Final verification also happens server-side, separately: verifyGateway sends a POST to https://api.zarinpal.com/pg/v4/payment/verify.json and returns the ref_id when data.code === 100.
Note that amount comes from your database, not from webhook data. Read it from input and anyone can forge a webhook to confirm a fake payment.
Order statuses worth wiring up:
| Status | Meaning | Next action |
|---|---|---|
| pending | Created, no payment confirmed | Wait for webhook, expire after 30 min |
| paid | Verified with a transaction reference | Final, never process again |
| failed | User cancelled or verification failed | Release the stock reservation |
| gateway_error | Gateway rejected the request | Allow a retry with a new attemptId |
Step 4: the browser return page
Display only, never verify:
Using useSearchParams means the page must sit inside a Suspense boundary or the build will complain — the same pattern covered in the loading and error states guide.
Checklist: correctness and safety
- [ ] Order amount is read from the database, not the request body
- [ ] Final verification always runs server-side
- [ ] Orders with status
paidare never processed again - [ ] Stock decrement happens inside a database transaction
- [ ] API key lives only in server-side env vars
- [ ] The callback page leaks no sensitive data
- [ ] A periodic job cleans up stale
pendingorders - [ ]
attemptId,refId, and final status are logged
Making it reliable
- User returned but the webhook is late? Add a polling endpoint that checks order status from your own API every 3 seconds and gives up after 30. This reassures the user their payment went through.
- Gateway outage? The Route Handler should fail loudly and mark the order
gateway_errorinstead of silently rejecting. - Double-clicked button? Disable it in the UI and keep a unique
attemptIdplus a unique index on the server as a second layer.
Cart UX belongs in the optimistic cart with Reactreact-optimistic-cart-tech pattern, and checkout form validation fits neatly into Zod form validation in Next.js. To stop losing sales when payment succeeded but the user bounced, read the failed payment recovery strategypayment-failed-recovery.
Q: Why can't I trust the browser return?
Because the client sends that request and anyone can edit it. And if they close the tab, no request arrives at all. The webhook is the only trustworthy way to finalize status.
Q: How do I prevent duplicate payments?
Two layers: a unique attemptId with a unique index on the request side, and the status === "paid" guard in the webhook. The second layer is essential because gateways may deliver the webhook more than once.
Q: What if stock runs out mid-payment?
Refund the captured amount and mark the order canceled. This usually needs the gateway's refund API, and you should simulate this case in testing.