Payments and crypto

Connecting Django to Iran's payment gateways with django-iranian-payment

A practical guide to connecting Django to Zarinpal, Zibal, Mellat and Saman with the open-source django-iranian-payment: its two layers, installation and settings, Rial vs Toman, the payment flow, Mellat's settle step and sandbox.

django-iranian-payment: Django and Python

Sooner or later, almost every Django project in Iran has to take a payment, and every gateway does it differently: Zarinpal and Zibal speak REST/JSON, Mellat speaks SOAP and only accepts the customer through a POST form, and Mellat wants a separate settle step after verification. django-iranian-payment is the open-source package I wrote for this. This post walks through using it, and everything in it comes from the package's own README.

Two layers: a ready app or a stateless core

The first decision is which layer to use:

  • The Django app (recommended). The optional app django_iranian_payment.contrib.django creates the payment record, stores the authority, runs the verification and tracks the transaction state. Your own code shrinks to starting the payment.
  • The core. Stateless gateways you can use anywhere, including outside Django or in async code. Storing the authority and handling verification is up to you.

I would start with the app, because it handles several production problems for you. Its callback view is csrf_exempt, since Mellat and Saman post back from the bank's domain without a CSRF token. It records each receipt in a unique receipt_key field before verifying, so one purchase's receipt cannot complete a different order; both Saman and Mellat report a repeated verify as a success. And if the gateway is down at verification time, the record is not lost. Pick the core when you don't want another model and migration, or you are not in Django at all. Those protections then become yours to build, and the README points to a full example in scripts/django_custom_db.py.

Which gateways are ready

The project's rule is simple: a gateway joins the public registry only after a successful real transaction. These four are available through get_gateway("slug"):

GatewayProtocolSending the userVerified withSandbox
ZarinpalREST/JSONredirectsandbox and real transactionsyes (separate URL)
ZibalREST/JSON—sandbox and real transactionsvia merchant="zibal"
MellatSOAPPOST forma live transaction in productionnone; sandbox=True raises an error
Saman (SEP)REST/JSONPOST forma real transaction on a real terminalnone; sandbox=True raises an error

Irankish, NextPay, Sadad and Digipay live in core.experimental. They are fully implemented from the official documentation and covered by automated tests with InMemoryTransport, but none has been tested against a sandbox or a real terminal yet, so get_gateway cannot reach them and you import them explicitly. Mellat and Saman started there too. Pay.ir and IDPay are out of service.

Installation

pip install django-iranian-payment
# Mellat (SOAP, pulls in zeep):
pip install "django-iranian-payment[soap]"
# Irankish (experimental, AES+RSA):
pip install "django-iranian-payment[irankish]"
# Sadad (experimental, 3DES):
pip install "django-iranian-payment[sadad]"

Mellat needs the [soap] extra, which installs zeep. For the Django layer, add the app and its URLs, then migrate:

INSTALLED_APPS = [
    # ...
    "django_iranian_payment.contrib.django",
]

# urls.py
from django.urls import path, include

urlpatterns = [
    # ...
    path("payment/", include("django_iranian_payment.contrib.django.urls")),
]
python manage.py migrate

Settings

Everything lives in one IRANIAN_PAYMENT dictionary in settings.py:

IRANIAN_PAYMENT = {
    "sandbox": False,    # global default
    "currency": "rial",  # or "toman"
    "return_url": "/payment/result/",  # optional
    "gateways": {
        "zarinpal": {"merchant_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
                     "sandbox": True},
        "zibal": {"merchant": "zibal"},
        "mellat": {
            "terminal_id": "1234567",
            "username": "your-username",
            "password": "your-password",
            "settle_mode": "verify_settle",  # or "verify_only"
        },
    },
}

Rial or Toman

The bank always takes Rial, and amount_to_send, the amount sent to the gateway and to verify, is always in Rial. IRANIAN_PAYMENT["currency"] only sets the unit of your input: the default "rial" converts nothing, and with "toman" the package converts to Rial once, at the very start (1 Toman = 10 Rial). The setting applies to both services.start_payment and get_gateway(...).initiate(...), and a currency set on the PaymentRequest itself wins over the global value. Amounts stored on the Payment model and returned by verify stay in Rial, so divide by 10 yourself when you show Toman.

The flow: create, redirect, callback, verify

Create the payment and send the user

from django.shortcuts import redirect
from django_iranian_payment.contrib.django import services

def start_payment(request, order):
    payment, redirect_url = services.start_payment(
        "zarinpal",
        amount=order.amount,
        # goes to the bank: the package's own callback route
        callback_url="https://yoursite.com/payment/callback/zarinpal/",
        # where the user lands after verify
        return_url="https://yoursite.com/orders/result/",
        order_id=str(order.id),
        description="Order payment",
    )
    return redirect(redirect_url)

callback_url goes to the bank and must be the package's own callback route; return_url is your result page, and you can set it once as IRANIAN_PAYMENT["return_url"] instead.

Mellat and Saman don't accept a plain redirect; they need a POST form. For those two, send the user to reverse("iranian_payment:go-to-gateway", kwargs={"payment_id": payment.id}) instead of redirect_url, and the go_to_gateway view renders an auto-submitting form to the bank.

Callback and verify

With the package's URLs included, there is no callback view to write: the bank returns to /payment/callback/zarinpal/, the package verifies, and the user lands on your return_url with a payment_status of success, failed or pending. If you want your own logic, call services.verify_payment(slug, authority) directly. A payment record moves through these states:

WAITING → REDIRECT_TO_BANK → RETURN_FROM_BANK → COMPLETE / FAILED / CANCEL_BY_USER

If the gateway doesn't answer during verify, the record stays in RETURN_FROM_BANK and the user comes back with payment_status=pending; the money may have left the buyer's account without being confirmed yet. Run these two from cron or Celery:

from django_iranian_payment.contrib.django import services

services.reverify_pending()
services.expire_stale(older_than_minutes=30)

The same flow with the core

With the core, you store the authority and the amount that was sent yourself:

from django_iranian_payment import get_gateway, PaymentRequest

def start_payment(request, order):
    gw = get_gateway("zarinpal")
    result = gw.initiate(PaymentRequest(
        amount=order.amount,
        callback_url="https://yoursite.com/verify/",
        order_id=str(order.id),
    ))
    order.authority = result.authority
    order.amount_sent = result.amount_to_send  # keep this for verify
    order.save()
    return redirect(result.redirect_url)

def verify_payment(request):
    authority = request.GET.get("Authority")
    order = Order.objects.get(authority=authority)
    gw = get_gateway("zarinpal")
    result = gw.verify(
        authority=authority,
        amount=order.amount_sent,
        order_id=str(order.id),
    )
    if result.is_success:
        order.mark_paid(result.reference_id)
        return redirect("/success/")
    return redirect("/failure/")

Store amount_to_send, not amount: Zarinpal doesn't return the amount in the callback, and verify needs exactly what was sent, which differs from the base amount when the customer pays a fee (FeeConfig). Shaparak gateways such as Mellat and Saman also need data from the bank's POST callback, passed through the extra parameter; the Django layer extracts it for you.

Mellat: two steps and settle_mode

Mellat works in two steps: after verification, the money still has to be settled. The default, settle_mode="verify_settle", does both atomically, and that is what I recommend. For delayed settlement, put "settle_mode": "verify_only" in Mellat's config and call settle() yourself after a successful verify. If settle never comes, the bank reverses the transaction automatically within three hours and the buyer gets the money back. The package keeps sale_order_id and sale_reference_id in the raw field of the Payment model, ready for a later settle or reverse. With the core, pass Mellat's callback values through extra:

gw.verify(authority=ref_id, amount=amount, order_id=oid,
          extra={"sale_reference_id": "...", "sale_order_id": "...",
                 "card_number": "...", "res_code": "0"})

If the user cancels, Mellat returns ResCode=17; pass it as res_code and verify returns a cancelled result without making a SOAP call.

Sandbox

Sandbox is set per gateway, so one gateway can run in sandbox while another is live. The order of precedence:

get_gateway(..., sandbox=...)  >  gateway config  >  global "sandbox"  >  False
  • Zarinpal has a separate sandbox URL and follows the flag.
  • Zibal's sandbox is chosen with merchant="zibal"; the sandbox flag has no effect on it.
  • Mellat and Saman have no real sandbox and reject sandbox=True with GatewayConfigurationError. If your global sandbox is True, set "sandbox": False explicitly for these two.

With django_iranian_payment.contrib.django installed, a Django system check stops manage.py with iranian_payment.E001 rather than letting you go live by accident. Without the full app, add django_iranian_payment.contrib.guard, which runs only that check: no model, no migration.

Before you go

  • Upgrading to 1.3.0 requires python manage.py migrate (migrations 0002 and 0003).
  • The repository has a step-by-step guide for each gateway under docs/gateways/.

The package is on PyPI and the source on GitHub. If you try an experimental gateway on a real terminal, let me know how it went: a real transaction is what moves a gateway into the public registry.