درگاه پرداخت زرینپال
zarinpal-paymentراهنماپیادهسازی کامل زرینپال از request تا verify: تبدیل تومان به ریال، سند پرداخت pending، کالبک idempotent و چکلیست امنیتی. برای Next.js و هر بکاند Node.
کی به کار میآد
- خرید اشتراک، اعتبار یا محصول
- اتصال درگاه بانکی ایرانی
- کالبک و تأیید پرداخت
- تست در سندباکس زرینپال
برچسبها
راهنما فرانتمتر نداره و خودکار فعال نمیشه؛ از CLAUDE.md یا AGENTS.md به آن لینک بدید تا مدل در هر جلسه بخوندش.
Zarinpal payment gateway (Next.js / Node.js)
Hand this document to an AI or developer to implement Zarinpal in any Next.js or Node.js project from scratch.
Table of contents
Overview
Environment variables
Zarinpal API reference
Payment flow
Database model
API route: create payment request
API route: verify payment callback
Frontend integration
Error handling
Security checklist
Testing and sandbox
Overview
Zarinpal is an Iranian payment gateway. The integration has two steps:
1User clicks "Pay"2 │3 ▼4[Your Server] ──POST──▶ Zarinpal /request ──▶ returns authority token5 │6 ▼7Redirect user to Zarinpal payment page (StartPay URL)8 │9 ▼10User completes payment on Zarinpal11 │12 ▼13Zarinpal redirects back to your callback URL with ?Authority=...&Status=OK14 │15 ▼16[Your Server] ──POST──▶ Zarinpal /verify ──▶ returns ref_id (success)17 │18 ▼19Grant product/service to user, save transaction recordEnvironment variables
1# Required2ZARINPAL_MERCHANT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx34# Optional: set to true for sandbox/test mode (default: production)5ZARINPAL_SANDBOX=true67# Required: your app's public base URL (used to build the callback URL)8NEXTAUTH_URL=https://yourdomain.com9# or10NEXT_PUBLIC_BASE_URL=https://yourdomain.comImportant: ZARINPAL_MERCHANT_ID must never be exposed to the client. Keep it server-side only.
Zarinpal API reference
Base URLs
| Mode | Base URL |
|---|---|
| Sandbox | https://sandbox.zarinpal.com |
| Production | https://payment.zarinpal.com |
Endpoints
| Action | Full URL |
|---|---|
| Create request | {BASE}/pg/v4/payment/request.json |
| Verify payment | {BASE}/pg/v4/payment/verify.json |
| StartPay (sandbox) | https://sandbox.zarinpal.com/pg/StartPay/{authority} |
| StartPay (production) | https://www.zarinpal.com/pg/StartPay/{authority} |
Step 1: create payment request
POST {BASE}/pg/v4/payment/request.json
Request body:
1{2 "merchant_id": "your-merchant-id",3 "amount": 490000,4 "callback_url": "https://yourdomain.com/api/payment/zarinpal/verify",5 "description": "Purchase description",6 "metadata": {7 "mobile": "09120000000",8 "email": "user@example.com"9 }10}Currency: Zarinpal API expects Rial. If your app stores prices in Toman, multiply by 10 before sending. Example: 49,000 Toman × 10 = 490,000 Rial.
Successful response:
1{2 "data": {3 "code": 100,4 "message": "Success",5 "authority": "A000000000000000000000000000000000",6 "fee_type": "Merchant",7 "fee": 08 },9 "errors": []10}Check data.code === 100 and data.authority is present.
Step 2: verify payment
POST {BASE}/pg/v4/payment/verify.json
Request body:
1{2 "merchant_id": "your-merchant-id",3 "amount": 490000,4 "authority": "A000000000000000000000000000000000"5}Send the same amount (in Rial) used in the request step.
Successful response:
1{2 "data": {3 "code": 100,4 "ref_id": 12345678,5 "message": "Paid",6 "card_hash": "...",7 "card_pan": "...",8 "fee_type": "Merchant",9 "fee": 010 },11 "errors": []12}Verification codes:
| Code | Meaning |
|---|---|
| 100 | Payment verified successfully (first time) |
| 101 | Payment already verified (duplicate callback) |
| Other | Failure: do not grant the product |
Payment flow
User initiates payment on your frontend.
Frontend sends
POST /api/payment/zarinpal/requestwith purchase details.Your server validates the user and purchase details.
Your server calls Zarinpal
/request.jsonand getsauthority.Your server saves a
pendingbilling/transaction record with theauthority.Your server returns
{ paymentUrl }to the frontend.Frontend redirects user to
paymentUrl(Zarinpal's payment page).User completes (or cancels) payment on Zarinpal.
Zarinpal calls your
callback_urlwith?Authority=xxx&Status=OK(orStatus=NOK).Your server reads
AuthorityandStatusfrom query params.If
Status !== "OK", mark billing asfailed, redirect user to failure page.Your server finds the pending billing record by
authority.Your server calls Zarinpal
/verify.jsonwithmerchant_id,amount,authority.If
code === 100: mark billingpaid, saveref_id, grant product to user.If
code === 101: already verified; redirect to success page (idempotent).Otherwise: mark billing
failed, redirect to failure page.
Database model
Store one record per payment attempt. Minimum required fields:
1interface BillingRecord {2 id: string; // unique invoice ID3 userId: string; // reference to user4 amount: number; // amount in Toman (your currency)5 status: "pending" | "paid" | "failed";6 authority: string; // Zarinpal authority token7 refId?: string; // Zarinpal ref_id after successful verification8 createdAt: Date;9 updatedAt: Date;1011 // Optional: depends on your product12 type?: string; // e.g. "plan" | "credits"13 period?: string; // e.g. "monthly" | "yearly"14 description?: string;1516 // Optional: discount support17 originalAmount?: number;18 discountAmount?: number;19 discountCode?: string;20}Key rule: Always look up the billing record by authority in the verify step. Never trust the amount from the callback query params; use the amount stored in your database.
API route: request
POST /api/payment/zarinpal/request
1// app/api/payment/zarinpal/request/route.ts2import { NextRequest, NextResponse } from "next/server";34const ZARINPAL_SANDBOX = process.env.ZARINPAL_SANDBOX === "true";5const ZARINPAL_BASE = ZARINPAL_SANDBOX6 ? "https://sandbox.zarinpal.com"7 : "https://payment.zarinpal.com";8const REQUEST_URL = `${ZARINPAL_BASE}/pg/v4/payment/request.json`;910function getBaseUrl(): string {11 return (12 process.env.NEXTAUTH_URL ||13 process.env.NEXT_PUBLIC_BASE_URL ||14 "http://localhost:3000"15 ).replace(/\/$/, "");16}1718export async function POST(request: NextRequest) {19 // 1. Authenticate the user (use your auth system)20 const session = await getYourSession(request);21 if (!session?.user?.id) {22 return NextResponse.json({ error: "Unauthorized" }, { status: 401 });23 }2425 // 2. Validate merchant ID26 const merchantId = process.env.ZARINPAL_MERCHANT_ID;27 if (!merchantId) {28 console.error("ZARINPAL_MERCHANT_ID is not set");29 return NextResponse.json(30 { error: "Payment gateway not configured" },31 { status: 500 }32 );33 }3435 // 3. Parse and validate request body36 const body = await request.json();37 // Example: body = { type: "plan", period: "monthly", discountCode: "SAVE10" }38 // Validate body here based on your product logic...3940 // 4. Compute amount in Toman (server-side: NEVER trust client amount)41 const amountInToman = computeAmount(body); // your pricing logic42 const amountInRial = amountInToman * 10;4344 // 5. Handle zero-amount / fully-discounted flow45 if (amountInToman <= 0) {46 const billing = await saveBillingRecord({47 userId: session.user.id,48 amount: 0,49 status: "paid",50 ...body,51 });52 await grantProductToUser(session.user.id, body); // your business logic53 return NextResponse.json({54 success: true,55 free: true,56 billingId: billing.id,57 message: "Purchase activated for free",58 });59 }6061 // 6. Call Zarinpal62 const callbackUrl = `${getBaseUrl()}/api/payment/zarinpal/verify`;63 const zarinpalRes = await fetch(REQUEST_URL, {64 method: "POST",65 headers: { "Content-Type": "application/json" },66 body: JSON.stringify({67 merchant_id: merchantId,68 amount: amountInRial,69 callback_url: callbackUrl,70 description: `Purchase for user ${session.user.id}`,71 metadata: {72 mobile: session.user.phone || "",73 email: session.user.email || "",74 },75 }),76 });7778 const zarinpalData = await zarinpalRes.json();7980 if (zarinpalData.data?.code !== 100 || !zarinpalData.data?.authority) {81 console.error("Zarinpal request failed:", zarinpalData);82 return NextResponse.json(83 { error: "Payment gateway error. Try again." },84 { status: 500 }85 );86 }8788 const authority = zarinpalData.data.authority;89 const paymentUrl = ZARINPAL_SANDBOX90 ? `https://sandbox.zarinpal.com/pg/StartPay/${authority}`91 : `https://www.zarinpal.com/pg/StartPay/${authority}`;9293 // 7. Save pending billing record94 const billing = await saveBillingRecord({95 userId: session.user.id,96 amount: amountInToman,97 status: "pending",98 authority,99 ...body,100 });101102 return NextResponse.json({103 success: true,104 authority,105 paymentUrl,106 billingId: billing.id,107 });108}API route: verify
GET /api/payment/zarinpal/verify
1// app/api/payment/zarinpal/verify/route.ts2import { NextRequest } from "next/server";3import { redirect } from "next/navigation";45const ZARINPAL_SANDBOX = process.env.ZARINPAL_SANDBOX === "true";6const ZARINPAL_BASE = ZARINPAL_SANDBOX7 ? "https://sandbox.zarinpal.com"8 : "https://payment.zarinpal.com";9const VERIFY_URL = `${ZARINPAL_BASE}/pg/v4/payment/verify.json`;1011const RESULT_PAGE = "/payment/result"; // adapt to your routing1213export async function GET(request: NextRequest) {14 const authority = request.nextUrl.searchParams.get("Authority");15 const status = request.nextUrl.searchParams.get("Status");1617 // 1. Validate callback params18 if (!authority || status !== "OK") {19 redirect(`${RESULT_PAGE}?payment=failed`);20 }2122 const merchantId = process.env.ZARINPAL_MERCHANT_ID;23 if (!merchantId) {24 redirect(`${RESULT_PAGE}?payment=error`);25 }2627 // 2. Find pending billing record28 const billing = await findBillingByAuthority(authority, "pending");2930 if (!billing) {31 // Check if already verified (idempotent)32 const existing = await findBillingByAuthority(authority, "paid");33 if (existing) {34 redirect(`${RESULT_PAGE}?payment=success`);35 }36 redirect(`${RESULT_PAGE}?payment=notfound`);37 }3839 // 3. Call Zarinpal verify: use amount from DB, not from callback40 const amountInRial = billing.amount * 10;41 const verifyRes = await fetch(VERIFY_URL, {42 method: "POST",43 headers: { "Content-Type": "application/json" },44 body: JSON.stringify({45 merchant_id: merchantId,46 amount: amountInRial,47 authority,48 }),49 });5051 const verifyData = await verifyRes.json();52 const code = verifyData.data?.code;5354 // 4. Handle already-verified (idempotent)55 if (code === 101) {56 redirect(`${RESULT_PAGE}?payment=success`);57 }5859 // 5. Handle failure60 if (code !== 100) {61 console.error("Zarinpal verify failed:", verifyData);62 await updateBillingStatus(billing.id, "failed");63 redirect(`${RESULT_PAGE}?payment=verify_failed`);64 }6566 // 6. Success: save ref_id, grant product67 const refId = verifyData.data?.ref_id;68 await updateBillingRecord(billing.id, {69 status: "paid",70 refId: refId?.toString(),71 });72 await grantProductToUser(billing.userId, billing); // your business logic7374 redirect(`${RESULT_PAGE}?payment=success`);75}Frontend integration
Minimal client-side flow
1async function handlePayment(purchaseDetails: object) {2 const res = await fetch("/api/payment/zarinpal/request", {3 method: "POST",4 headers: { "Content-Type": "application/json" },5 body: JSON.stringify(purchaseDetails),6 });78 const data = await res.json();910 if (!res.ok || !data.success) {11 showError(data.error || "Payment failed. Try again.");12 return;13 }1415 if (data.free) {16 showSuccess(data.message);17 return;18 }1920 window.location.href = data.paymentUrl;21}Reading payment result on return
1const searchParams = new URLSearchParams(window.location.search);2const paymentStatus = searchParams.get("payment");34// paymentStatus values:5// "success" - payment verified and product granted6// "failed" - user cancelled or Status !== OK7// "verify_failed" - verification failed at Zarinpal8// "notfound" - authority not found in DB9// "error" - server configuration errorError handling
| Scenario | Cause | What to do |
|---|---|---|
data.code !== 100 on request | Invalid merchant ID, gateway issue | Return error to user, log details |
Status=NOK on callback | User cancelled payment | Redirect to ?payment=failed |
code === 101 on verify | Duplicate callback (already verified) | Treat as success (idempotent) |
| Billing record not found on verify | Invalid or expired authority | Redirect to ?payment=notfound |
ZARINPAL_MERCHANT_ID not set | Missing env var | Redirect to ?payment=error, log server-side |
| Network timeout calling Zarinpal | Intermittent issue | Return 500 to user, do NOT grant product |
Zero-amount / fully-discounted purchases
If a discount brings the total to 0 or below, skip the Zarinpal gateway entirely:
Save billing as
paidimmediately.Grant the product to the user.
Return
{ success: true, free: true }with nopaymentUrl.
Security checklist
[ ]
ZARINPAL_MERCHANT_IDis server-side only, never sent to client.[ ] Amount is always computed server-side from your own pricing logic, never from client input.
[ ] In verify step, amount comes from the stored billing record, never from callback query params.
[ ] Product is only granted after
code === 100(or the free flow).[ ] Duplicate callbacks (
code === 101) are handled idempotently without double-granting.[ ] Billing record is looked up by
authoritybefore verifying (prevents forged callbacks).[ ] Use HTTPS in production for the callback URL.
[ ] Never log
ZARINPAL_MERCHANT_IDor full user payment details.
Testing and sandbox
Set
ZARINPAL_SANDBOX=truein your.env.local.Register at Zarinpal Sandbox to get a sandbox merchant ID.
Use test card numbers provided by Zarinpal in the sandbox environment.
After a successful sandbox payment, confirm:
Billing record status changes to
paid.refIdis saved.Product/subscription is granted to the user.
Cancel a payment and confirm
status=failedand no product is granted.Test duplicate callback by calling the verify URL twice with the same authority; the second call should be handled gracefully (idempotent,
code === 101).
Key constants summary
1// Sandbox2const REQUEST_URL = "https://sandbox.zarinpal.com/pg/v4/payment/request.json";3const VERIFY_URL = "https://sandbox.zarinpal.com/pg/v4/payment/verify.json";4const START_PAY = "https://sandbox.zarinpal.com/pg/StartPay/";56// Production7const REQUEST_URL = "https://payment.zarinpal.com/pg/v4/payment/request.json";8const VERIFY_URL = "https://payment.zarinpal.com/pg/v4/payment/verify.json";9const START_PAY = "https://www.zarinpal.com/pg/StartPay/";1011// Currency conversion12const amountInRial = amountInToman * 10;1314// Response codes15// request: code 100 = authority created successfully16// verify: code 100 = first successful verification17// code 101 = already verified (idempotent)18// other = failureOfficial resources
نمونه
پرامپت: «پرداخت زرینپال را پیاده کن»
بدون مهارت
مبلغ را از کلاینت بگیر و بعد از Status=OK محصول را فعال کن
با مهارت
مبلغ سرورساید، authority در DB، verify با code ۱۰۰/۱۰۱، بعد grant
نمونه برای نشان دادن جهت تغییره؛ خروجی واقعی به مدل و پرامپت شما بستگی داره.
نصب
با CLI
این دستور فایل را در
docs/zarinpal-payment.mdمینویسه. CLI به init نیاز نداره؛ فقط باید داخل پوشهی پروژه باشید.$npx vibefarsi add zarinpal-paymentدستی
محتوای تب zarinpal-payment.md را کپی کنید و در مسیر ابزار خودتون بگذارید:
- Claude Codeفایل را در
docs/zarinpal-payment.mdبگذارید و این خط را بهCLAUDE.mdاضافه کنید:@docs/zarinpal-payment.md - Cursorدر
.cursor/rules/zarinpal-payment.mdcباalwaysApply: trueبالای فایل - Codex و بقیهمتن را در
AGENTS.mdبگذارید یا از همانجا به فایل لینک بدید
کپی کل فایلregistry json- Claude Codeفایل را در
میخواید همهی قوانین را یکجا داشته باشید؟ قوانین فارسی برای CLAUDE.md خلاصهی همهی مهارتها در یک صفحهست و قوانین کرافت رابط طرف طراحی را پوشش میده. npx vibefarsi init هر دو را داخل پروژه مینویسه.