Esc

اتصال به درگاه پرداخت Iran Payment Gateway

پیاده‌سازی امن پرداخت با زرین‌پال و درگاه‌های ایرانی: درخواست، بازگشت، تأیید یک‌باره و تفاوت ریال و تومان

مهارتپرداخت و بانک

پیش از نصب بدانید

  • منبعساخت بازارچهنوشته و نگهداری‌شده در همین مخزن
  • کد منبعمتن‌باز، در همین مخزنآخرین بررسی: ۱۱ مهر ۱۴۰۵

نصب اتصال به درگاه پرداخت

Claude Code با افزونه

یک بار بازارچه را اضافه کنید، بعد هر مهارت را جدا نصب کنید.

Claude Code
/plugin marketplace add mcp-farsi/mcp-farsi
/plugin install iran-payment-gateway@mcp-farsi

نصب دستی در پوشه مهارت‌ها

برای یک پروژه خاص، به جای ~/.claude از .claude در ریشه پروژه استفاده کنید. در PowerShell به جای curl بنویسید curl.exe و به جای ~ بنویسید $HOME.

Terminalshell
curl -fsSL --create-dirs -o ~/.claude/skills/iran-payment-gateway/SKILL.md {ORIGIN}/skills/iran-payment-gateway/SKILL.md

Claude.ai و اپ دسکتاپ

فایل‌های مهارت را در پوشه‌ای به نام iran-payment-gateway بگذارید، آن را zip کنید و در تنظیمات Claude، بخش Capabilities، بارگذاری کنید.

دریافت SKILL.md
سازنده
بازارچه MCP
مجوز
MIT
آخرین بررسی
۱۱ مهر ۱۴۰۵

درباره

اشتباه در پیاده‌سازی درگاه پرداخت گران تمام می‌شود: سفارشی که دو بار تأیید می‌شود، مبلغی که ده برابر کمتر پرداخت می‌شود چون ریال و تومان قاطی شده، یا callbackی که بدون تأیید سمت سرور «پرداخت‌شده» علامت می‌خورد. این مهارت جریان درست پرداخت و نکته‌های امنیتی آن را به Claude می‌دهد.

کجا به کار می‌آید

  • افزودن پرداخت زرین‌پال به یک فروشگاه یا سرویس اشتراکی
  • بازبینی کد پرداخت موجود از نظر امنیت و تکرار نشدن تأیید
  • نوشتن نمونه کد درخواست و تأیید پرداخت در Node.js و Python

متن کامل مهارت

نمایش محتوای SKILL.md

درگاه پرداخت (Iranian payment gateways)

Flow (server-side only)

  1. Create the order in your DB: status = 'pending', amount as an integer in Rial, computed on the server.
  2. Request a payment session from the gateway; store the returned authority (Zarinpal) / trackId (Zibal) on the order under a UNIQUE index.
  3. Redirect the user (HTTP 302) to the gateway’s payment page.
  4. The user comes back to your callback URL with query parameters.
  5. Look up the order by the stored authority/trackId. Never take the amount or the order state from the query string.
  6. Verify server-to-server with the amount from your DB.
  7. Mark paid with one conditional update, and fulfil only if that update changed a row:
UPDATE orders SET status = 'paid', ref_id = $1, paid_at = now()
WHERE id = $2 AND status = 'pending';
-- 1 row: this request won; fulfil now. 0 rows: already handled; just show the result.

Two concurrent callbacks (double click, refresh) may both call verify: Zarinpal answers the first with 100 and the second with 101. Both count as success, but the conditional update lets only one of them fulfil.

Amount units: Rial vs Toman

1 Toman = 10 Rial. Store and compute Rial integers (never floats); convert to Toman only for display.

Gateway Unit
Zarinpal Optional currency field: "IRR" (Rial) or "IRT" (Toman). The verify docs describe amount in Rial. Send "IRR" explicitly and use the same Rial amount in verify
Zibal Rial only. amount must be greater than 1,000 Rial (result 105)

Zarinpal v4

Step Production Sandbox
Request POST https://payment.zarinpal.com/pg/v4/payment/request.json https://sandbox.zarinpal.com/pg/v4/payment/request.json
Redirect https://payment.zarinpal.com/pg/StartPay/{authority} https://sandbox.zarinpal.com/pg/StartPay/{authority}
Verify POST https://payment.zarinpal.com/pg/v4/payment/verify.json https://sandbox.zarinpal.com/pg/v4/payment/verify.json
Inquiry POST https://payment.zarinpal.com/pg/v4/payment/inquiry.json
Unverified list POST https://payment.zarinpal.com/pg/v4/payment/unVerified.json
  • Headers: Content-Type: application/json, Accept: application/json.
  • Sandbox: same paths on sandbox.zarinpal.com; merchant_id can be any UUID string; sandbox authorities start with S.

Request body: merchant_id (36 characters, required), amount (integer, required), callback_url (required), description (required; over 500 characters gives -9), currency (IRR / IRT), metadata (mobile, email, order_id), optional referrer_id. metadata.auto_verify (boolean) overrides the panel’s automatic-verification setting for that payment.

Request response: {"data": {"code": 100, "message": "Success", "authority": "A000...", "fee_type": "Merchant", "fee": 100}, "errors": []}. On failure data is empty and errors is an object: {"data": {}, "errors": {"code": -9, "message": "...", "validations": []}}.

Callback: {callback_url}?Authority=...&Status=OK or Status=NOK. NOK means failed or cancelled by the user; call verify only when Status=OK.

Verify body: merchant_id, amount, authority. Verify response: code 100 = verified now (first time), 101 = already verified (still a success), plus ref_id (the transaction reference to show the user), card_pan (masked), card_hash (SHA-256), fee_type, fee.

Verify promptly: when verification is not automatic and you do not verify within the allowed window, Zarinpal returns the money to the buyer.

Code Meaning Action
100 Success / verified Mark paid
101 Already verified Treat as paid (idempotent)
-9 Validation error (missing field, bad callback URL, description too long, amount out of range) Fix the request
-10 Invalid merchant_id or IP Check credentials and allowed IPs
-11 Terminal not active Contact Zarinpal support
-12 Too many attempts Back off and retry later
-14 Callback URL domain does not match the registered domain Use the registered domain
-50 Paid amount differs from the amount sent to verify Do not mark paid; investigate (tampering or bug)
-51 Payment not successful Show failure; order stays unpaid
-53 Payment does not belong to this merchant_id Reject
-54 Invalid authority Reject

Full list: errorList page (see Sources).

Node.js (fetch, Node 18+)

const ZP = process.env.ZARINPAL_SANDBOX === '1' ? 'https://sandbox.zarinpal.com' : 'https://payment.zarinpal.com';
const MERCHANT_ID = process.env.ZARINPAL_MERCHANT_ID;

async function zp(method, body) {
  const res = await fetch(`${ZP}/pg/v4/payment/${method}.json`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
    body: JSON.stringify({ merchant_id: MERCHANT_ID, ...body }),
    signal: AbortSignal.timeout(15_000),
  });
  const json = await res.json();
  return { code: json.data?.code ?? json.errors?.code, data: json.data, errors: json.errors };
}

// 1) Start: returns the URL to 302-redirect the user to
export async function startPayment(order, db) {
  const r = await zp('request', {
    amount: order.amountRial,
    currency: 'IRR',
    callback_url: 'https://shop.example.ir/pay/callback',
    description: `Order ${order.id}`,
    metadata: { order_id: String(order.id) },
  });
  if (r.code !== 100) throw new Error(`Zarinpal request failed: ${JSON.stringify(r.errors)}`);
  await db.saveAuthority(order.id, r.data.authority); // UNIQUE(authority)
  return `${ZP}/pg/StartPay/${r.data.authority}`;
}

// 2) Callback: GET /pay/callback?Authority=...&Status=OK|NOK
export async function handleCallback(query, db) {
  const order = await db.findByAuthority(String(query.Authority ?? ''));
  if (!order) return { ok: false, reason: 'unknown authority' };
  if (order.status === 'paid') return { ok: true, refId: order.refId };
  if (query.Status !== 'OK') return { ok: false, reason: 'cancelled or failed' }; // stays pending

  const r = await zp('verify', { amount: order.amountRial, authority: order.authority }); // amount from DB
  if (r.code === 100 || r.code === 101) {
    const won = await db.markPaidIfPending(order.id, r.data.ref_id); // the conditional UPDATE above
    if (won) await db.fulfil(order.id);
    return { ok: true, refId: r.data.ref_id };
  }
  return { ok: false, reason: `verify failed: ${r.code}` };
}

Python (requests)

import os
import requests

ZP = "https://sandbox.zarinpal.com" if os.getenv("ZARINPAL_SANDBOX") == "1" else "https://payment.zarinpal.com"
MERCHANT_ID = os.environ["ZARINPAL_MERCHANT_ID"]

def zp(method: str, body: dict):
    r = requests.post(f"{ZP}/pg/v4/payment/{method}.json",
                      json={"merchant_id": MERCHANT_ID, **body},
                      headers={"Accept": "application/json"}, timeout=15)
    j = r.json()
    data = j.get("data") or {}      # {} on failure
    errors = j.get("errors") or {}  # [] on success, {"code": ..., "message": ...} on failure
    code = data.get("code", errors.get("code") if isinstance(errors, dict) else None)
    return code, data, errors

def start_payment(order, db) -> str:
    code, data, errors = zp("request", {
        "amount": order.amount_rial, "currency": "IRR",
        "callback_url": "https://shop.example.ir/pay/callback",
        "description": f"Order {order.id}",
        "metadata": {"order_id": str(order.id)},
    })
    if code != 100:
        raise RuntimeError(f"Zarinpal request failed: {errors}")
    db.save_authority(order.id, data["authority"])  # UNIQUE(authority)
    return f"{ZP}/pg/StartPay/{data['authority']}"

def handle_callback(authority: str, status: str, db) -> dict:
    order = db.find_by_authority(authority)
    if order is None:
        return {"ok": False, "reason": "unknown authority"}
    if order.status == "paid":
        return {"ok": True, "ref_id": order.ref_id}
    if status != "OK":
        return {"ok": False, "reason": "cancelled or failed"}  # stays pending
    code, data, _ = zp("verify", {"amount": order.amount_rial, "authority": order.authority})
    if code in (100, 101):
        if db.mark_paid_if_pending(order.id, data.get("ref_id")):  # conditional UPDATE
            db.fulfil(order.id)
        return {"ok": True, "ref_id": data.get("ref_id")}
    return {"ok": False, "reason": f"verify failed: {code}"}

Zibal (alternative)

Base URL https://gateway.zibal.ir; test merchant: zibal.

Step Call
Request POST /v1/request with {merchant, amount (Rial), callbackUrl, description?, orderId?, mobile?} returns {trackId, result: 100, message}
Redirect GET https://gateway.zibal.ir/start/{trackId}. A Referer header matching the site registered for the gateway is required; browsers send it when you redirect from your site, mobile apps and bots must set it themselves
Callback GET {callbackUrl}?success=1|0&trackId=...&orderId=...&status=...
Verify POST /v1/verify with {merchant, trackId}: result 100 = verified, 201 = already verified, 202 = not paid or failed, 203 = invalid trackId. Returns amount (Rial), refNumber, cardNumber (masked), paidAt, status
Inquiry POST /v1/inquiry with {merchant, trackId}; status -1 = waiting for payment, 1 = paid and verified, 2 = paid but not verified, 3 = cancelled by user

Zibal’s verify does not take an amount, so compare the returned amount with your order’s amount yourself before marking paid. Zibal documents a refund to the payer when a payment is not verified within 20 minutes (Lazy method section), so verify in the callback.

Security checklist

  • Never mark an order paid from callback parameters (Status=OK, success=1); anyone can open that URL. Only a successful server-side verify counts.
  • merchant_id lives in server-side config or secrets, never in frontend code or the repo.
  • The amount comes from your DB order, never from the client or the callback. Zarinpal rejects a mismatch with -50; for Zibal, compare the verify response amount yourself.
  • Look orders up by the stored authority/trackId and reject unknown ones. UNIQUE constraints on authority/trackId and on ref_id.
  • State change via conditional update; fulfilment (shipping, credit, license) runs exactly once.
  • Callback URL is HTTPS on the domain registered with the gateway; the callback handler is idempotent (refresh-safe GET).
  • Timeouts on every gateway call. On a network error during verify, leave the order pending and retry later: a repeated verify is safe (Zarinpal returns 101, Zibal 201).
  • Log authority/trackId, code and ref_id for every attempt; do not log full card numbers (gateways return masked ones).

Reconciliation

  • Scheduled job (every few minutes) over orders still pending after the user should have returned:
    • Zarinpal: inquiry.json returns status VERIFIED, PAID (paid, not verified), IN_BANK, FAILED or REVERSED. The docs say inquiry is informational only, so for PAID or VERIFIED call verify with your stored amount and mark paid on 100/101; expire FAILED ones.
    • Zarinpal unVerified.json lists the last 100 successful but unverified payments; verify each one that matches an order.
    • Zibal: /v1/inquiry; status 2 (paid, not verified) means call verify now.
  • Daily: compare the gateway panel’s settlement report with your paid orders; investigate every difference.
  • Zarinpal can reverse a verified transaction only within 30 minutes and only with a terminal IP configured (errors -62/-63); see the reverse page in the docs.

Test checklist

  • Sandbox happy path: request, redirect, pay, callback, verify returns 100, order paid, fulfilled once.
  • Refresh the callback page: verify returns 101, no second fulfilment.
  • Cancel on the gateway page: Status=NOK, order stays unpaid.
  • Forged callback (Status=OK with a random or someone else’s authority): rejected.
  • Amount tampering: after a successful sandbox payment, verify with a different amount; expect -50 and the order stays unpaid.
  • Gateway timeout during verify: order stays pending, and the reconciliation job later marks it paid.
  • Toman/Rial: a 10,000 Toman order is sent as 100,000 Rial.

Sources