---
name: iran-payment-gateway
description: Integrate Iranian online payment gateways (درگاه پرداخت) server-side, with Zarinpal REST v4 as the primary example and Zibal as an alternative - payment request, StartPay redirect, callback handling, a single server-side verify, idempotent order marking, Rial vs Toman amounts, sandbox testing, error codes (100/101, -9, -50, -51, -54...), reconciliation with inquiry/unVerified, and a security checklist. Includes minimal Node.js (fetch) and Python (requests) request+verify code. Use when adding checkout or payments for Iranian users, debugging Zarinpal or Zibal callbacks and verify errors, or reviewing payment code for amount tampering and double-fulfilment bugs. اتصال امن به درگاه پرداخت زرین‌پال و زیبال
---

# درگاه پرداخت (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:

```sql
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+)

```js
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)

```python
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

- 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)
