درگاه پرداخت زرین‌پال

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

  1. Overview

  2. Environment variables

  3. Zarinpal API reference

  4. Payment flow

  5. Database model

  6. API route: create payment request

  7. API route: verify payment callback

  8. Frontend integration

  9. Error handling

  10. Security checklist

  11. Testing and sandbox


Overview

Zarinpal is an Iranian payment gateway. The integration has two steps:

1User clicks "Pay"234[Your Server] ──POST──▶ Zarinpal /request  ──▶ returns authority token567Redirect user to Zarinpal payment page (StartPay URL)8910User completes payment on Zarinpal111213Zarinpal redirects back to your callback URL with ?Authority=...&Status=OK141516[Your Server] ──POST──▶ Zarinpal /verify  ──▶ returns ref_id (success)171819Grant product/service to user, save transaction record

Environment 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.com

Important: ZARINPAL_MERCHANT_ID must never be exposed to the client. Keep it server-side only.


Zarinpal API reference

Base URLs

ModeBase URL
Sandboxhttps://sandbox.zarinpal.com
Productionhttps://payment.zarinpal.com

Endpoints

ActionFull 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:

CodeMeaning
100Payment verified successfully (first time)
101Payment already verified (duplicate callback)
OtherFailure: do not grant the product

Payment flow

  1. User initiates payment on your frontend.

  2. Frontend sends POST /api/payment/zarinpal/request with purchase details.

  3. Your server validates the user and purchase details.

  4. Your server calls Zarinpal /request.json and gets authority.

  5. Your server saves a pending billing/transaction record with the authority.

  6. Your server returns { paymentUrl } to the frontend.

  7. Frontend redirects user to paymentUrl (Zarinpal's payment page).

  8. User completes (or cancels) payment on Zarinpal.

  9. Zarinpal calls your callback_url with ?Authority=xxx&Status=OK (or Status=NOK).

  10. Your server reads Authority and Status from query params.

  11. If Status !== "OK", mark billing as failed, redirect user to failure page.

  12. Your server finds the pending billing record by authority.

  13. Your server calls Zarinpal /verify.json with merchant_id, amount, authority.

  14. If code === 100: mark billing paid, save ref_id, grant product to user.

  15. If code === 101: already verified; redirect to success page (idempotent).

  16. 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 error

Error handling

ScenarioCauseWhat to do
data.code !== 100 on requestInvalid merchant ID, gateway issueReturn error to user, log details
Status=NOK on callbackUser cancelled paymentRedirect to ?payment=failed
code === 101 on verifyDuplicate callback (already verified)Treat as success (idempotent)
Billing record not found on verifyInvalid or expired authorityRedirect to ?payment=notfound
ZARINPAL_MERCHANT_ID not setMissing env varRedirect to ?payment=error, log server-side
Network timeout calling ZarinpalIntermittent issueReturn 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 paid immediately.

  • Grant the product to the user.

  • Return { success: true, free: true } with no paymentUrl.


Security checklist

  • [ ] ZARINPAL_MERCHANT_ID is 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 authority before verifying (prevents forged callbacks).

  • [ ] Use HTTPS in production for the callback URL.

  • [ ] Never log ZARINPAL_MERCHANT_ID or full user payment details.


Testing and sandbox

  1. Set ZARINPAL_SANDBOX=true in your .env.local.

  2. Register at Zarinpal Sandbox to get a sandbox merchant ID.

  3. Use test card numbers provided by Zarinpal in the sandbox environment.

  4. After a successful sandbox payment, confirm:

    • Billing record status changes to paid.

    • refId is saved.

    • Product/subscription is granted to the user.

  5. Cancel a payment and confirm status=failed and no product is granted.

  6. 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    = failure

Official resources

نمونه

پرامپت: «پرداخت زرین‌پال را پیاده کن»

بدون مهارت

مبلغ را از کلاینت بگیر و بعد از Status=OK محصول را فعال کن

با مهارت

مبلغ سرورساید، authority در DB، verify با code ۱۰۰/۱۰۱، بعد grant

نمونه برای نشان دادن جهت تغییره؛ خروجی واقعی به مدل و پرامپت شما بستگی داره.

نصب

  1. با CLI

    این دستور فایل را در docs/zarinpal-payment.md می‌نویسه. CLI به init نیاز نداره؛ فقط باید داخل پوشه‌ی پروژه باشید.

    $npx vibefarsi add zarinpal-payment
  2. دستی

    محتوای تب 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.md خلاصه‌ی همه‌ی مهارت‌ها در یک صفحه‌ست و قوانین کرافت رابط طرف طراحی را پوشش میده. npx vibefarsi init هر دو را داخل پروژه می‌نویسه.