Skip to main content

irgateway — Iranian payment gateway SDK for Python

Python SDK for Zarinpal (v4) and Zibal. Synchronous (requests) and asynchronous (aiohttp) clients are supported.

Version 0.3.2 restores legacy defaults and call shapes. Payment applications with outstanding old transactions still need the migration guide because old paid amounts cannot safely be guessed.

Installation

Requires Python 3.10+.

pip install irgateway               # sync requests client
pip install 'irgateway[async]'      # enables aiohttp async clients
pip install -e '.[dev]'             # contributors: pytest and build tools

Amount and fee rules (important)

  • The default caller-side currency is IRT (toman), preserving the old nominal units. Use currency='IRR' for rial inputs.
  • Monetary input values must be integers. Floats and booleans are rejected. No floating-point fee estimates are used.
  • The SDK sends amounts to both APIs in IRR (rial). Zarinpal explicitly receives currency='IRR'; Zibal's IPG accepts rials.
  • Persist the actual payable amount from PaymentRequest.payable_amount, its amount_rials, and the payment identifier along with the order. Verify against those stored values, not callback-supplied amounts.
  • The default fee_type='BUYER' from 0.2.11 is restored in 0.3.2. A legacy call with no buyer_fee keeps the old automatic surcharge formula, without extra API calls, to reduce the risk of breaking existing applications. This formula is a compatibility estimate, not your merchant-specific gateway fee; use fee_type='MERCHANT' for no local surcharge.
  • To choose the new explicit fee behaviour, pass buyer_fee=<integer> (manual), buyer_fee=0 (free), or buyer_fee='auto' (improved opt-in estimate/quote). With fee_type='MERCHANT', omit buyer_fee; the SDK will not add a local surcharge.
  • The provider's real fee and payer attribution can differ by contract. calculate_fee() returns the Zarinpal provider quote. Calling calculate_fee(100000) directly continues treating the input as 100000 IRR, consistent with 0.2.11; use calculate_fee(100000, amount_currency='IRT') to pass toman.
  • Zarinpal auto: calls official feeCalculation API first (one extra HTTP request). For fee_type=Merchant, uses suggested_amount as the proposed gross amount, rounding up to whole configured units. If Zarinpal reports fee_type=Payer, returns zero additional local surcharge to avoid billing twice. A missing/malformed quote raises instead of guessing.
  • Zibal auto: uses an estimate, not an account-specific API quote: published IPG rate 1%, minimum 2,000 IRT, maximum 20,000 IRT, plus 10% VAT on commission (checked 2026-10-22). Solves fee-on-total consistently using integer math. Set a custom ZibalFeePolicy for negotiated tariffs; public pricing can change.
  • You must display/disclose the final amount before redirect. The extra buyer_fee is part of the charged order amount, not a guaranteed match to the provider's final merchant-specific processing costs. Provider and contractual rules can restrict passing charges to customers.
  • Wages, multiplexing amounts and cart values follow the same caller-side currency and are converted to rials in the request. Percentage-based split modes are not implemented here.

Zarinpal — synchronous

from irgateway.zarinpal import ZarinpalClient, ZarinpalConfig
from irgateway.zarinpal.enums import Environment

with ZarinpalClient(
    merchant_id='YOUR-36-CHARACTER-MERCHANT-ID',
    environment=Environment.SANDBOX,  # PRODUCTION in live environment
    config=ZarinpalConfig(currency='IRT', fee_type='MERCHANT', timeout=15),
) as client:
    payment = client.request(
        amount=100_000,                  # 100,000 toman
        callback_url='https://example.com/payments/zarinpal/callback',
        description='پرداخت سفارش ۱۲۳',
    )

    # Store payment.authority, payment.payable_amount, payment.amount_rials
    # and your local order ID in DB BEFORE redirecting the customer.
    print(payment.payment_url)

    # After a trusted callback with Status=OK, find the LOCAL pending order
    # by stored Authority; NEVER use an amount supplied by the callback.
    verification = client.verify(
        authority=payment.authority,
        amount=payment.payable_amount,    # caller currency (IRT in this example)
    )
    if verification.is_success:
        print(verification.ref_id)
        # Mark the order paid ATOMICALLY, once (unique payment identifier).

Automatic buyer surcharge — Zarinpal

with ZarinpalClient(
    merchant_id='YOUR_MERCHANT_ID',
    config=ZarinpalConfig(currency='IRT', fee_type='BUYER'),
) as client:
    # Optional preview: calls feeCalculation; the returned local surcharge is in toman.
    estimated_extra = client.calculate_buyer_fee(100_000)

    # Recommended: calculate just once during request() to avoid a stale quote.
    payment = client.request(
        100_000, 'https://example.com/callback', 'خرید', buyer_fee='auto'
    )
    # Store payment.payable_amount (including extra charge) for later verify().
    print(payment.payable_amount, payment.buyer_fee)

# Manual override remains available: buyer_fee=2500 or buyer_fee=0.

auto adds one API call before the payment request; do not use it with fee_type='MERCHANT'. For Payer fee mode on Zarinpal, the payer is already charged by the gateway, so no additional local fee is added. In merchant-paid mode, suggested_amount is Zarinpal's quote of the proposed total; actual terminal charges may differ. If the fee API fails, payment creation is aborted rather than guessed. calculate_fee() exposes the unmodified provider quote.

Other Zarinpal features

from irgateway.zarinpal.types import PaymentMetadata, Wage

# Metadata: optional mobile, email and order_id. referrer_id is also available.
payment = client.request(
    amount=100_000,
    callback_url='https://example.com/callback',
    description='خرید',
    metadata=PaymentMetadata(mobile='09120000000', order_id='ORDER-123'),
    referrer_id='YOUR_REFERRER_ID',
    wages=[Wage(iban='IR123456789012345678901234', amount=20_000, description='سهم همکاری')],
)

# All monetary values in the returned quote are IRR.
fee = client.calculate_fee(1_000_000)  # legacy input is IRR
# or: client.calculate_fee(100_000, amount_currency='IRT')
status = client.inquiry(payment.authority)  # status only; NOT verification
pending = client.unverified()               # up to 100 recent unverified transactions
# client.reverse(payment.authority)         # destructive; authorized terminals only

reverse() preserves the 0.2.11 result contract: True for success, False for a recognizable gateway business rejection. Transport, HTTP protocol and invalid response failures can still raise exceptions. Reverse has eligibility restrictions; it is not an unlimited general refund API.

Zibal — synchronous

from irgateway.zibal import ZibalClient, ZibalConfig

with ZibalClient(merchant='zibal', config=ZibalConfig(currency='IRT', fee_type='MERCHANT')) as client:
    # merchant='zibal' is Zibal's test merchant; use your own merchant in production.
    payment = client.request(
        amount=100_000,
        callback_url='https://example.com/payments/zibal/callback',
        description='پرداخت سفارش ۱۲۳',
        order_id='ORDER-123',
    )
    # Persist track_id, payable_amount, amount_rials and local order ID.
    print(payment.payment_url)

    # After callback, look up your saved local pending order by track_id.
    verified = client.verify(
        payment.track_id,
        expected_amount=payment.payable_amount,  # strongly recommended
    )
    if verified.is_success:
        print(verified.ref_number)
        # Persist the payment in a DB transaction with duplicate protection.

    # Optional: investigation/reconciliation; does not confirm payment:
    details = client.inquiry(payment.track_id)

Zibal PaymentVerification.amount contains the raw rial amount returned by the API. The optional expected_amount input is in the configured caller currency, and a mismatch raises PaymentAmountMismatch. When an expected amount is given, a missing amount in the response is rejected. Always supply an expected amount in production.

Fee and splitting example

from irgateway.zibal.types import MultiplexingInfo
from irgateway.zibal import ZibalConfig

client = ZibalClient('YOUR_MERCHANT', config=ZibalConfig(currency='IRT', fee_type='BUYER'))
payment = client.request(
    amount=20_000,
    buyer_fee=500,  # 500 toman explicit customer surcharge; no estimated fee
    callback_url='https://example.com/cb',
    multiplexing_infos=[MultiplexingInfo(iban='IR123456789012345678901234', amount=5_000)],
)
# payment.payable_amount == 20500 IRT, payment.amount_rials == 205000 IRR
client.close()

Automatic buyer surcharge — Zibal

from irgateway.zibal import (
    ZibalClient, ZibalConfig, ZibalFeePolicy, estimate_buyer_fee,
)

config = ZibalConfig(currency='IRT', fee_type='BUYER')
with ZibalClient('YOUR_MERCHANT', config=config) as client:
    fee = client.calculate_buyer_fee(100_000)  # local estimate, no HTTP call
    payment = client.request(100_000, 'https://example.com/cb', buyer_fee='auto')
    print(payment.payable_amount, payment.buyer_fee)  # total, local surcharge

# If your contract has other rates, configure them explicitly.
custom_config = ZibalConfig(
    fee_type='BUYER',
    fee_policy=ZibalFeePolicy(
        rate_bps=50,      # 0.5%; 100 basis points = 1%
        minimum_irt=0,    # fee cap/floor always expressed in TOMAN
        maximum_irt=10_000,
        vat_bps=1000,     # 10% on fee
    ),
)
# A standalone estimator is also available:
print(estimate_buyer_fee(100_000, currency='IRT', policy=custom_config.fee_policy))

Zibal: the public tariff is not guaranteed for a particular merchant account, special contract, promotion or exemption. For an exact amount required by your own agreement, use an explicit integer buyer_fee, or configure the policy only after checking your merchant contract. The estimator accounts for the extra fee itself (a gross-up), and always rounds upward to the smallest unit available (IRT or IRR). VAT and percentages use integer math, never floats.

Asynchronous clients

from irgateway.zarinpal import AsyncZarinpalClient
from irgateway.zarinpal.enums import Environment
from irgateway.zibal import AsyncZibalClient

async def create_payment():
    async with AsyncZarinpalClient('YOUR_MERCHANT_ID', Environment.SANDBOX) as client:
        p = await client.request(10000, 'https://example.com/cb', 'خرید')
        return p.payment_url

async def create_zibal_payment():
    async with AsyncZibalClient('zibal') as client:
        p = await client.request(10000, 'https://example.com/cb')
        return p.payment_url

Both Sync and Async clients accept buyer_fee='auto' in request() when configured with fee_type='BUYER'. The omitted buyer_fee case in the default BUYER mode uses the 0.2.11 formula for compatibility and does NOT call the new quote API. For async clients, use await client.calculate_buyer_fee(amount) to preview the extra charge; Zarinpal performs an API call, Zibal estimates locally. Sync and async clients have the same request, verify and inquiry semantics. Async clients support async with and await close(); sync clients support with and close(). Do not share a requests.Session between threads.

Transaction safety / error handling

from irgateway import (
    GatewayTransportError,
    GatewayHTTPError,
    GatewayProtocolError,
    PaymentAmountMismatch,
)
from irgateway.zarinpal.errors import ZarinpalException, AlreadyVerifiedException
from irgateway.zibal.errors import ZibalException

try:
    result = client.verify(...)  # see gateway-specific call signatures above
except AlreadyVerifiedException:
    # Don't mark an arbitrary order paid. Reconcile using local DB + gateway.
    pass
except PaymentAmountMismatch:
    # Halt fulfillment and investigate.
    pass
except (GatewayTransportError, GatewayHTTPError, GatewayProtocolError):
    # Outcome can be UNKNOWN; reconcile before retrying any mutating call.
    pass
except (ZarinpalException, ZibalException) as exc:
    print(exc.code)

Previously verified does not mean a new successful payment. Zarinpal 101 and Zibal 201 raise dedicated gateway exceptions by default. verify(..., allow_already_verified=True) can return a result whose already_verified is True; is_success remains False so application code cannot accidentally treat duplicates as new approvals. Use this for reconciliation, not fulfillment.

Important:

  1. Validate the callback's parameters, but do not trust Status=OK or success=1 as proof of payment.
  2. Locate the pending order from your own database and fetch its stored payable amount and gateway ID.
  3. Call verify; for Zibal also pass expected_amount.
  4. Persist PAID with atomic compare-and-set/transaction plus a unique constraint for the gateway payment ID. Fulfill exactly once.
  5. If a request times out, the gateway may already have completed it. There are no implicit automatic retries. Use inquiry/unverified and your local ledger to reconcile; inquiry alone never verifies a payment.
  6. Do not log full customer card numbers, personal data, merchant secrets, or raw unredacted gateway payloads.

Migration from 0.2.11

Special case — users who already adopted 0.3.1: that version defaulted to MERCHANT. To keep that no-surcharge behaviour in 0.3.2, set ZarinpalConfig(fee_type="MERCHANT") or ZibalConfig(fee_type="MERCHANT") explicitly. There is no safe way to infer which old default the application intended.

Goal of 0.3.2: existing request(...), verify(...) and reverse(...) calls, dataclass names and legacy to_dict() keys remain usable without adding parameters. That is a compatibility objective—not a guarantee for every outstanding transaction, custom monkey patch, or code that relied on undocumented internals.

Behaviour v0.2.11 v0.3.2
Default fee_type BUYER BUYER (restored)
Omitting buyer_fee Old formula, no fee quote HTTP call Same old formula, one HTTP payment request
buyer_fee='auto' Not available Explicit opt-in: official Zarinpal fee quote / configurable Zibal tariff estimate
buyer_fee=<integer> Not available Optional manual customer charge
fee_type='MERCHANT' No local surcharge No local surcharge
Legacy request result to_dict() Old keys Same keys by default; to_dict(include_extended=True) adds new amount fields
Old ZarinpalConfig / ZibalConfig to_dict() Old keys Old keys by default; extended format is opt-in
reverse() rejection from gateway False False (restored); network/protocol errors still raise
calculate_fee(amount) (Zarinpal) Input was sent as-is (IRR) Still accepts IRR by default; amount_currency='IRT' is now available
verify(authority, amount) (Zarinpal) Amount was sent as-is (financial bug) Uses last successful request's real total if the same client instance still knows the authority; otherwise amount is in config.currency, default IRT
Multiplexing/wages Incorrect unit conversion possible All amounts use caller currency and are sent in IRR
Zibal verify(track_id) Returns paid response Preserved; optional expected_amount now validates the amount

Critical: verifying an existing order

A callback may happen in another worker or after restart. _recent_payments is an in-memory, bounded, best-effort cache and is not a persistent ledger. Never rely on it for payment security or exactly-once fulfillment. Save authority/track_id and amount_rials when the request is created.

For a new order persisted using 0.3.2, verify after reload with the stored payable_amount (in config currency), or with the exact original stored rial total:

result = client.verify(authority, stored_amount_rials, amount_is_rials=True)

For outstanding orders created with 0.2.11, do not recalculate their fees with new pricing. Retrieve the exact rial total charged when the request was created (from your stored request/payload or trusted settlement records). If you used the old manual-rial workaround, the same explicit flag is required when upgrading:

result = client.verify(authority, old_stored_gross_rials, amount_is_rials=True)

If your old implementation saved only the base price in tomans and then created a separate client for verification, there is no safe universal way to reconstruct the charged amount. Recover the original stored payment data before switching versions; an automatic guess is unsafe. The library deliberately does not retry verification with several guessed amounts.

Reading the extra fields

payment = client.request(100000, 'https://example.com/cb', 'test')  # Zarinpal example
print(payment.payable_amount, payment.amount_rials, payment.buyer_fee)
legacy_record = payment.to_dict()  # old keys only
full_record = payment.to_dict(include_extended=True)  # add amount-related keys

Python requirement: 3.10+ (the old README's 3.7 claim was inaccurate). The 0.3.x version includes important fixes for HTTP errors, response parsing and duplicate verification. Always test active payments against the actual merchant's sandbox or a dedicated test terminal before rolling this out.

Testing

python -m pytest -q

Tests are offline and use mocked gateway responses; no payment was made. They do not replace live sandbox/merchant integration testing. API functionality that depends on merchant-specific permissions (including reverse, shared settlements and added customer charges) must be confirmed against your own merchant account before deployment.

API references (checked October 2026)

License

MIT — see LICENSE.

Metadata

Release files for irgateway 0.3.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for irgateway 0.3.2
File Size Uploaded Provenance
irgateway-0.3.2.tar.gz 41.6 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for irgateway 0.3.2
File Interpreter ABI Platform
irgateway-0.3.2-py3-none-any.whl Python 3 none any Details

Total release size: 74.2 kB

Release files / irgateway-0.3.2.tar.gz

Download URL irgateway-0.3.2.tar.gz
Size 41.6 kB
Tags Source
SHA-256 checksum
How to use checksums
c29f4cfb99fab952be442c56fcb61c04f5a979fbbf4362990bcd6b4601dda54d
BLAKE2b-256 checksum
How to use checksums
11df6901dd3b49affdbfa838ffab06e4e3bd978877f2997cbdec1bea60d7e3e4
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via python-requests/2.34.2

Release files / irgateway-0.3.2-py3-none-any.whl

Download URL irgateway-0.3.2-py3-none-any.whl
Size 32.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
4d17d588126e6c4403e55969b370bf34bdf4852ccf6c8dfb119f365f6c6cfa12
BLAKE2b-256 checksum
How to use checksums
c2428a4b4ec06031f6279677b6a7c7739c48abc8a8b82f4f84bd1e62d6ebf151
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via python-requests/2.34.2

Security for irgateway 0.3.2

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page