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. Usecurrency='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, itsamount_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 nobuyer_feekeeps 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; usefee_type='MERCHANT'for no local surcharge. - To choose the new explicit fee behaviour, pass
buyer_fee=<integer>(manual),buyer_fee=0(free), orbuyer_fee='auto'(improved opt-in estimate/quote). Withfee_type='MERCHANT', omitbuyer_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. Callingcalculate_fee(100000)directly continues treating the input as 100000 IRR, consistent with 0.2.11; usecalculate_fee(100000, amount_currency='IRT')to pass toman. - Zarinpal
auto: calls officialfeeCalculationAPI first (one extra HTTP request). Forfee_type=Merchant, usessuggested_amountas the proposed gross amount, rounding up to whole configured units. If Zarinpal reportsfee_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 customZibalFeePolicyfor negotiated tariffs; public pricing can change. - You must display/disclose the final amount before redirect. The extra
buyer_feeis 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:
- Validate the callback's parameters, but do not trust
Status=OKorsuccess=1as proof of payment. - Locate the pending order from your own database and fetch its stored payable amount and gateway ID.
- Call
verify; for Zibal also passexpected_amount. - Persist
PAIDwith atomic compare-and-set/transaction plus a unique constraint for the gateway payment ID. Fulfill exactly once. - 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.
- 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)
- Zarinpal connection and verification
- Zarinpal currency
- Zarinpal fee calculation
- Zarinpal inquiry
- Zarinpal reversal
- Zarinpal unverified
- Zibal IPG docs (documentation is rendered dynamically)
- Zibal official public payment tariffs
- Zibal official Node.js gateway SDK
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)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| irgateway-0.3.2.tar.gz | 41.6 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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.