اتصال به درگاه پرداخت Iran Payment Gateway
پیادهسازی امن پرداخت با زرینپال و درگاههای ایرانی: درخواست، بازگشت از درگاه، تأیید فقط یکباره و تفاوت ریال و تومان
مهارتپرداخت و بانک
رفتن به نصبقبل از نصب بخوانید
- منبعساخت بازارچههمین سایت آن را ساخته و نگه میدارد
- کد منبعمتنباز، مجوز MITآخرین بررسی: ۱۱ مهر ۱۴۰۵
نصب اتصال به درگاه پرداخت
نصب دستی در پوشه مهارتها
این دستورها را در ترمینال (پنجره خط فرمان) اجرا کنید. برای نصب فقط در یک پروژه، به جای ~/.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.mdسایت claude.ai و برنامه دسکتاپ Claude
فایل zip مهارت را دریافت کنید. بدون اینکه آن را باز کنید، در تنظیمات Claude به بخش Capabilities بروید و فایل را بارگذاری کنید.
دریافت فایل zipدرباره این ابزار
این مهارت (Skill؛ دستورالعملی که دستیار هنگام کار از آن پیروی میکند) به Claude جریان درست پرداخت و نکتههای امنیتی آن را یاد میدهد. به کار برنامهنویسهایی میآید که زرینپال یا درگاههای ایرانی دیگر را به سایت یا سرویس خود وصل میکنند.
اشتباه در درگاه پرداخت گران تمام میشود: سفارشی که دو بار تأیید میشود، مبلغی که ده برابر کمتر پرداخت میشود چون ریال و تومان قاطی شده، یا بازگشت از درگاه (callback) که بدون تأیید سمت سرور «پرداختشده» علامت میخورد.
کجا به کار میآید
- افزودن پرداخت زرینپال به یک فروشگاه یا سرویس اشتراکی
- بازبینی کد پرداخت موجود از نظر امنیت و اینکه تأیید دو بار انجام نشود
- نوشتن نمونه کد درخواست و تأیید پرداخت در Node.js و Python
متن کامل مهارت
نمایش فایل SKILL.md
درگاه پرداخت (Iranian payment gateways)
Flow (server-side only)
- Create the order in your DB:
status = 'pending',fulfilled_at = NULL, 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, fulfil only if that update changed a row, then record the fulfilment:
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 marked paid; just show the result.
-- after fulfilment succeeded:
UPDATE orders SET fulfilled_at = now() WHERE id = $2 AND fulfilled_at IS NULL;
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.
Marking paid and fulfilling are two steps, so a crash between them leaves a paid order with fulfilled_at NULL, and later callbacks return early because the order is already paid. The reconciliation job therefore retries every status = 'paid' AND fulfilled_at IS NULL order. That retry can repeat a fulfilment that ran but was not recorded, so fulfilment must be idempotent: key it on the order id (for example UNIQUE(order_id) on the shipment, credit or license row, or an idempotency key for an external API). When fulfilment is only writes in the same database (credit, license row), put the paid update, the fulfilment and fulfilled_at in one transaction instead; then it runs exactly once with no retry needed.
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 fulfil(order.id, db);
return { ok: true, refId: r.data.ref_id };
}
return { ok: false, reason: `verify failed: ${r.code}` };
}
// 3) Fulfil, then record it. A crash in between leaves fulfilled_at NULL and the
// reconciliation job calls this again, so db.fulfilOrder must be idempotent (keyed on orderId).
export async function fulfil(orderId, db) {
await db.fulfilOrder(orderId); // e.g. INSERT ... ON CONFLICT (order_id) DO NOTHING
await db.markFulfilled(orderId); // UPDATE orders SET fulfilled_at = now() WHERE id = $1 AND fulfilled_at IS NULL
}
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
fulfil(order.id, db)
return {"ok": True, "ref_id": data.get("ref_id")}
return {"ok": False, "reason": f"verify failed: {code}"}
def fulfil(order_id, db) -> None:
# Retried by reconciliation while fulfilled_at is NULL, so fulfil_order must be idempotent (keyed on order_id).
db.fulfil_order(order_id) # e.g. INSERT ... ON CONFLICT (order_id) DO NOTHING
db.mark_fulfilled(order_id) # UPDATE orders SET fulfilled_at = now() WHERE id = %s AND fulfilled_at IS NULL
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) is idempotent per order and recorded in
fulfilled_at, so a retry never fulfils twice and a crash never leaves a paid order unfulfilled (or paid update, fulfilment andfulfilled_atshare one DB transaction). - 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:
- Same job, no gateway call: orders
paidwithfulfilled_at IS NULL(a crash after marking paid) getfulfilagain. - 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.
- Crash after marking paid (stop the process before fulfilment): the reconciliation job fulfils the order, once.
- 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)
