اتصال به درگاه پرداخت Iran Payment Gateway
پیادهسازی امن پرداخت با زرینپال و درگاههای ایرانی: درخواست، بازگشت، تأیید یکباره و تفاوت ریال و تومان
مهارتپرداخت و بانک
پیش از نصب بدانید
- منبعساخت بازارچهنوشته و نگهداریشده در همین مخزن
- کد منبعمتنباز، در همین مخزنآخرین بررسی: ۱۱ مهر ۱۴۰۵
نصب اتصال به درگاه پرداخت
Claude Code با افزونه
یک بار بازارچه را اضافه کنید، بعد هر مهارت را جدا نصب کنید.
/plugin marketplace add mcp-farsi/mcp-farsi
/plugin install iran-payment-gateway@mcp-farsiنصب دستی در پوشه مهارتها
برای یک پروژه خاص، به جای ~/.claude از .claude در ریشه پروژه استفاده کنید. در PowerShell به جای curl بنویسید curl.exe و به جای ~ بنویسید $HOME.
curl -fsSL --create-dirs -o ~/.claude/skills/iran-payment-gateway/SKILL.md {ORIGIN}/skills/iran-payment-gateway/SKILL.mdClaude.ai و اپ دسکتاپ
فایلهای مهارت را در پوشهای به نام iran-payment-gateway بگذارید، آن را zip کنید و در تنظیمات Claude، بخش Capabilities، بارگذاری کنید.
درباره
اشتباه در پیادهسازی درگاه پرداخت گران تمام میشود: سفارشی که دو بار تأیید میشود، مبلغی که ده برابر کمتر پرداخت میشود چون ریال و تومان قاطی شده، یا callbackی که بدون تأیید سمت سرور «پرداختشده» علامت میخورد. این مهارت جریان درست پرداخت و نکتههای امنیتی آن را به Claude میدهد.
کجا به کار میآید
- افزودن پرداخت زرینپال به یک فروشگاه یا سرویس اشتراکی
- بازبینی کد پرداخت موجود از نظر امنیت و تکرار نشدن تأیید
- نوشتن نمونه کد درخواست و تأیید پرداخت در Node.js و Python
متن کامل مهارت
نمایش محتوای SKILL.md
درگاه پرداخت (Iranian payment gateways)
Flow (server-side only)
- Create the order in your DB:
status = 'pending', amount as an integer in Rial, computed on the server. - Request a payment session from the gateway; store the returned
authority(Zarinpal) /trackId(Zibal) on the order under a UNIQUE index. - Redirect the user (HTTP 302) to the gateway’s payment page.
- The user comes back to your callback URL with query parameters.
- Look up the order by the stored authority/trackId. Never take the amount or the order state from the query string.
- Verify server-to-server with the amount from your DB.
- 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_idcan be any UUID string; sandbox authorities start withS.
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_idlives 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
amountyourself. - 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
pendingafter the user should have returned:- Zarinpal:
inquiry.jsonreturnsstatusVERIFIED, 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.jsonlists the last 100 successful but unverified payments; verify each one that matches an order. - Zibal:
/v1/inquiry;status2 (paid, not verified) means call verify now.
- Zarinpal:
- 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=OKwith 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
- Zarinpal connection guide: https://www.zarinpal.com/docs/paymentGateway/connectToGateway.html
- Zarinpal sandbox: https://www.zarinpal.com/docs/paymentGateway/sandBox.html
- Zarinpal error list: https://www.zarinpal.com/docs/paymentGateway/errorList.html
- Zarinpal currency: https://www.zarinpal.com/docs/paymentGateway/moreFeatures/currency.html
- Zarinpal auto/manual verification: https://www.zarinpal.com/docs/paymentGateway/moreFeatures/session-validation.html
- Zarinpal inquiry: https://www.zarinpal.com/docs/paymentGateway/otherMethods/Inquiry.html
- Zarinpal unVerified: https://www.zarinpal.com/docs/paymentGateway/otherMethods/unVerified.html
- Zarinpal reverse: https://www.zarinpal.com/docs/paymentGateway/moreFeatures/reverse.html
- Zibal IPG API: https://help.zibal.ir/ipg/ (OpenAPI spec: https://api.zibal.ir/static/helpdocs/ipg.json)
