pardakht 0.8.1 copy "pardakht: ^0.8.1" to clipboard
pardakht: ^0.8.1 copied to clipboard

One type-safe API across Iranian payment gateways. Pure Dart, explicit currency units, a unified error taxonomy and server-safe verification.

pardakht #

One type-safe API across Iranian payment gateways. Every provider ships, at best, a thin single-gateway wrapper, so changing provider means rewriting the integration. pardakht gives you one interface, one error taxonomy and one money type for all of them, in pure Dart — no Flutter dependency, so the same code runs in a shelf backend, a CLI and a Flutter app.

Status: 0.8.1. All seven gateways from the original project brief — Zarinpal, Zibal, IDPay, PayPing, Pay.ir, Vandar and IranDargah — are shipped, alongside the core and proxy mode. See Supported gateways for what each one actually covers; not every adapter is confirmed to the same degree.

Install #

dependencies:
  pardakht: ^0.8.1

Quickstart #

A payment is three steps, and only the third one proves anything.

import 'package:pardakht/pardakht.dart';

final gateway = ZarinpalGateway(
  credentials: ZarinpalCredentials(merchantId: merchantIdFromYourEnvironment),
);

// 1. Open a session on a trusted server, then send the payer to the URL.
final session = await gateway.createSession(
  PaymentRequest(
    amount: Money.toman(25000), // the adapter converts to Zarinpal's Rial
    callbackUrl: Uri.parse('https://shop.example/pay/callback'),
    orderId: order.id,
    description: 'Order ${order.id}',
  ),
);
await orders.storeReference(order.id, session.reference);
// redirect the payer to session.redirectUrl

// 2. The payer comes back. Normalise whatever the provider sent.
final payload = gateway.parseCallback(request.uri.queryParameters);

// 3. Confirm it. Nothing before this establishes that money moved.
final result = await gateway.verify(
  VerificationRequest(
    reference: payload.reference,
    amount: order.amount, // from your own record, never from the callback
  ),
);

if (result.isSuccessful) {
  await orders.fulfil(order.id);
}

Four runnable programs are in example/:

dart run example/zarinpal_sandbox.dart
dart run example/zibal_sandbox.dart
dart run example/pardakht_example.dart
dart run example/backend/proxy_backend.dart

The first two run a real payment against each provider's own test account — no registration needed, no money moves. The third walks through the whole flow with no network at all. The fourth starts a merchant backend and drives it with a proxy-mode client. There is no idpay_sandbox.dart, payping_sandbox.dart, payir_sandbox.dart, vandar_sandbox.dart or irandargah_sandbox.dart: IDPay's API host was unreachable throughout development, no PayPing merchant account was available to test against, Pay.ir's entire domain — not just an API host — could not be reached at all, Vandar's sandbox is not self-service (its docs direct a merchant to ask Vandar's own support team to enrol a business), and this project had no IranDargah account at all, so no example claiming to run live against any of the five has actually been run — see doc/gateway_specs/idpay.md, doc/gateway_specs/payping.md, doc/gateway_specs/payir.md, doc/gateway_specs/vandar.md and doc/gateway_specs/irandargah.md.

Security and architecture #

Verification belongs on a server #

The callback URL is an ordinary HTTP request that anyone on the internet can make with any parameters they like. Only verify(), called with merchant credentials from a server you control, establishes that a payment happened.

A merchant key compiled into a mobile app is not secret — anyone can decompile the APK and read it. With that key an attacker can verify payments as you, which on most Iranian gateways means marking unpaid orders as paid.

Proxy mode #

For mobile and web clients, PaymentGateway.remote calls your own backend instead of the provider:

  mobile / web client            merchant backend              gateway
  ───────────────────            ────────────────              ───────
  RemotePaymentGateway  ───────▶  ZarinpalGateway     ───────▶  Zarinpal
    (no credentials)              (holds the key,
                                   prices the order)
                        ◀───────                      ◀───────
final gateway = PaymentGateway.remote(
  backendBaseUrl: Uri.parse('https://shop.example/api/payments/'),
  gatewayId: 'zarinpal',
  headers: {'authorization': 'Bearer $appSessionToken'},
);

The client holds no merchant credential and — just as importantly — does not get to say what an order costs. Your backend must price the order from its own records; otherwise an attacker simply asks to pay one Rial. The four endpoints the backend implements are documented on RemotePaymentGateway, with a working implementation in example/backend/.

See doc/security.md for the full checklist.

Supported gateways #

Gateway Id Native unit Sandbox Inquiry Refund Status
Zarinpal zarinpal Rial yes yes full only shipped
Zibal zibal Rial yes yes no shipped
IDPay idpay Rial yes yes no shipped*
PayPing payping Toman no no full only shipped**
Pay.ir payir Rial yes no no shipped***
Vandar vandar Rial no yes no shipped****
IranDargah irandargah Rial yes no no shipped*****
Proxy mode your backend's caller's yes yes shipped

Native units and capabilities are left blank rather than guessed. Each is filled in only once it has been confirmed against the provider's own documentation and pinned by a test, with the source recorded in doc/gateway_specs/, each of which cites a URL and a retrieval date for every claim: Zarinpal, Zibal, IDPay, PayPing, Pay.ir, Vandar, IranDargah.

* IDPay's adapter is sourced from documentation alone. Its API host (api.idpay.ir) returned 502 Bad Gateway on every attempt made during development, so — unlike Zarinpal and Zibal — nothing about it has been confirmed against a live response. Test it against IDPay's own sandbox before trusting it with a real payment.

** PayPing's adapter is sourced from its own official OpenAPI document, with only the shape of an unauthorized request (401, empty body) confirmed live — there was no PayPing merchant account to test a full payment against. Test it against a real account before trusting it with a real payment.

*** Pay.ir's adapter is the least verified in this package. Neither pay.ir nor its documentation site could be reached at all from the environment it was written in, so it is sourced from a search engine's own index of the vendor's documentation page, cross-checked against a well-starred community client, rather than from the page itself or from any live response. It is marked @experimental. Test it against Pay.ir's api: "test" sandbox before trusting it with a real payment.

**** Vandar's adapter is sourced directly from its own documentation site, which — unlike Pay.ir's — was fully reachable. No merchant account existed to test a full payment, so it is not confirmed against a live response either, but it is not marked @experimental: the documentation is precise enough on its own, with worked JSON examples for every success shape. Test it against a real session before trusting it with a real payment. An earlier version of this adapter targeted the wrong API version — a community-maintained docs mirror it was first written against had gone stale behind Vandar's real v4 API — caught and fixed by checking Vandar's own current site directly; see doc/gateway_specs/vandar.md.

***** IranDargah's adapter should be treated with more caution than any other in this package. Its documentation site was reachable and read directly, but two independent, well-established community SDKs describe a completely different API — a different domain, authentication scheme and field names throughout. This adapter targets the vendor's current documentation, per this project's own source-ranking, but the conflict was never resolved against a live account. On top of that, no confirmed signal exists for what a repeat verification returns — the one thing this package considers most important to get right for every gateway — so verify maps only a clean success to verified. It is marked @experimental. Do not rely on it without confirming both the sourcing conflict and the repeat-verification behaviour against a real account first.

Zarinpal also supports settlement splitting (تسهیم), restricting payment to a particular card, and a sandbox that needs no account. Its reverse endpoint takes no amount, so refunds are all-or-nothing and only within 30 minutes of payment.

Zibal has no separate sandbox host — the literal merchant value zibal is itself the documented, permanent test account, valid on the same gateway.zibal.ir used in production (ZibalCredentials.test()). It supports settlement splitting and card restriction like Zarinpal, but documents no refund or reversal endpoint at all, so refund always throws.

IDPay selects its sandbox with an X-SANDBOX header rather than a host or a credential (GatewayOptions.sandbox controls it), and its callback can arrive as either a GET query string or a POST body depending on how the web service is configured — parseCallback accepts a plain map either way. It documents the strictest verification deadline of the three: a paid transaction must be verified within ten minutes or IDPay refunds the payer automatically, and it states explicitly that detecting a repeated payment is the merchant's own responsibility. It documents no refund endpoint either.

PayPing is the one gateway here that speaks Toman, confirmed straight from its own official documentation rather than inferred from a community default — resolving what this project's brief flagged as its highest-risk open question before any PayPing code was written. It authenticates with a bearer token rather than a body field, documents no sandbox at all, and its verify endpoint answers 202 while still deciding — a genuinely different "not yet" signal from every other provider's outright transient failure, handled by a small retry loop scoped to that one status so it cannot be double-counted against GatewayHttpClient's own 5xx retries. It supports full-amount reversal within 30 minutes of verification, gated behind an isReversible flag set at creation time (exposed through PaymentRequest.metadata['isReversible'], since no core field represents it). See PayPingGateway's own documentation for why verify needs a reference built from the callback, not from the session createSession returns.

Pay.ir speaks Rial, selects its sandbox with the literal api: "test" value rather than a separate host (PayIrCredentials.test(), the same shape as Zibal's test merchant), and documents no inquiry endpoint distinct from verify and no refund endpoint at all. A repeat verification arrives as errorCode: -6 through the same channel a genuine failure uses, not as a distinguished success field, so it must be checked before that shape is read as a rejection. It documents a settlement-split feature, but that feature splits by percentage against a mobile number rather than by fixed amount against an IBAN, which does not fit this package's Wage model — left unimplemented rather than forced into a shape that would misrepresent it. See doc/gateway_specs/payir.md for the full account of why this adapter is the least verified one shipped.

Vandar speaks Rial and is the one gateway here whose session-creation rejection carries no error code at all — only a flat array of Persian sentences — so this adapter classifies that one failure by HTTP status instead, the only structured signal it offers, while verify and the transaction inquiry endpoint each answer with their own proper numeric field. Its sandbox exists but is not self-service: Vandar's own docs direct a merchant to ask its support team to enrol a business named "sandbox," so there is no VandarCredentials.test() the way Zibal and Pay.ir have. callback_url, and the Referer header sent during the redirect, must both match a domain registered in the merchant's panel — a check this adapter cannot perform locally, since that domain list is not available through any API it calls. Refund is not implemented: Vandar's refund service is authenticated with an OAuth bearer token from a wholly different flow than the single api_key this adapter's credentials model, a bigger design change than one adapter should carry on its own. See doc/gateway_specs/vandar.md.

IranDargah speaks Rial and is the only gateway here that authenticates with a Bearer token rather than a body field, and the only one that documents (and this adapter honours) an Idempotency-Key header on session creation. It never sends the documented direct_verify flag, which would let a bank reference arrive on the callback without a server-side verify call — the trust model this package's whole design refuses to build, since a callback is an unauthenticated request anyone can forge. inquire and refund both throw: IranDargah's GET endpoints for reading transaction state have no documented response shape to model, and no refund-creation endpoint is documented at all. See doc/gateway_specs/irandargah.md — and read it before using this adapter for anything real; it documents a genuine conflict between two sources this project could not resolve, and the package's only unconfirmed already-verified pathway.

SOAP-based bank-direct gateways — Mellat, Saman, Parsian, AsanPardakht, Sadad — are out of scope. They need SOAP, terminal certificates and IP allow-listing. The interface is designed so they can be added later.

Currency #

Iranian gateways disagree about units: some take Rial, some take Toman, and one Toman is ten Rial. Passing a bare int into an API that accepts either is how merchants charge ten times the intended price. Money makes that impossible to express:

final price = Money.toman(25000); // 25,000 Toman = 250,000 Rial
price.inRial;                     // 250000

Each adapter declares its own native unit and converts internally, so you never have to know which unit a provider wants. Conversions are exact or they throw:

Money.rial(1005).toToman(); // CurrencyConversionError — rounding would
                            // change what the payer is charged

Zero and negative amounts are rejected at construction, and every conversion and arithmetic operation is checked for overflow.

Errors #

Every failure is a PaymentException, and the hierarchy is sealed, so a switch over it is checked for exhaustiveness at compile time:

try {
  final result = await gateway.verify(request);
} on PaymentException catch (error) {
  final message = switch (error) {
    GatewayRejectedException(:final code) => resolver.resolve(code),
    NetworkPaymentException() => 'Could not reach the provider.',
    TimeoutPaymentException() => 'The provider did not answer in time.',
    MalformedResponseException() => 'The provider sent an unusable reply.',
    ConfigurationException() => 'This payment method is misconfigured.',
  };
}

Each provider's numeric codes are normalised onto PaymentErrorCode, while the original code and the provider's own Persian message are preserved verbatim on the exception. Payer-facing wording comes from an injected PaymentMessageResolver; English and Persian maps ship with the package.

alreadyVerified is a success #

Verifying twice is normal — a payer refreshes the callback page, a request times out and is retried, two callbacks race. Zarinpal answers 100 the first time and 101 every time after; Zibal answers 100 and then 201; IDPay answers 100 and then 101 again, on the same status field its callback and inquiry both use; PayPing answers HTTP 200 the first time and HTTP 409 with metaData.code: 110 on every repeat; Pay.ir answers status: 1 the first time and errorCode: -6 on every repeat, through the same error channel a genuine rejection uses; Vandar answers status: 1 the first time and status: 2 on every repeat, the same field either way. Every provider encodes the identical fact differently, and all of them mean the money was collected. Code that checks only for the first shape reads the second answer as a failure and refunds a real payment.

IranDargah is the one exception, and it is a gap rather than a design choice. No confirmed code for a repeat verification could be found in its documentation, so IranDargahGateway.verify never returns alreadyVerified — a second call for an already-confirmed transaction is currently indistinguishable from a genuine rejection. See doc/gateway_specs/irandargah.md.

VerificationStatus.alreadyVerified exists so that cannot happen quietly. Check result.isSuccessful, not result.status == VerificationStatus.verified.

Retries are deliberately asymmetric #

verify and inquire are retried automatically on transport failures and 5xx responses, with exponential backoff and jitter. createSession and refund are never retried automatically, whatever the retry policy says.

Both change state, and a request that failed in transit may well have been processed anyway. A retried session request can leave you with two live sessions for one order; a retried refund can pay the customer twice. This is a safety property of the package, not a tuning parameter — raising maxAttempts does not affect it. To retry a session deliberately, pass PaymentRequest.idempotencyKey on a gateway whose supportsIdempotencyKey capability is set.

Business rejections are not retried either: the provider made a decision, and repeating the call produces the same one more slowly.

Logging #

Silent by default. When you attach a logger, everything passes through a redactor first:

final gateway = SomeGateway(
  credentials: credentials,
  options: GatewayOptions(
    logger: CallbackPaymentLogger(
      (level, message, error, stack) => myLogger.write(message),
      minimumLevel: LogLevel.debug,
    ),
  ),
);

Credentials are removed by exact value — every GatewayCredentials declares its own secrets — so redaction does not depend on guessing field names. Card numbers, national identifiers, phone numbers, IBANs and authorization headers are masked by pattern as a second layer. Payment references stay visible: they grant nothing on their own and are the first thing support needs.

result versus status #

Some providers split "did this API call succeed" from "what state is the transaction in" across two separate fields. Zibal is the clearest example: its inquire call answers result: 100 — the report was produced successfully — even for a transaction that was never paid, because result never speaks to the payment itself. status is the field that does. ZibalGateway.verify and ZibalGateway.inquire each read only the field that is authoritative for that call; see the "Two fields, two jobs" section of the ZibalGateway doc comment for the full reasoning.

IDPay avoids the split entirely — it has one status field, shared by the callback, verify and inquiry — but its two operations still disagree about what one value means. 100 and 101 from verify are two faces of the same successful call and map to verified and alreadyVerified respectively, while inquire maps both to plain verified: "already verified" describes a fact about a verify call, and inquire never makes one.

Adding a gateway #

PaymentGateway is a public interface and the core models are public types, so a provider can be added from outside this package without forking it. Every adapter also runs against a shared contract test suite, so new ones cannot skip the basics.

See doc/adding_a_gateway.md for a complete worked example, or read example/demo_pay_gateway.dart, which is a full adapter for a fictional provider.

Migrating #

Coming from a single-gateway package? See doc/migration_from_single_gateway.md.

Contributing #

Pull requests are welcome, particularly new gateway adapters. Before opening one:

  • dart analyze reports zero issues of any severity.
  • dart format --set-exit-if-changed . is clean.
  • dart test passes and line coverage stays at or above 90%.
  • A new adapter passes the shared contract suite unchanged. If the suite needed loosening to fit, the core abstraction is wrong — say so in the pull request rather than special-casing the adapter.
  • A new adapter ships a spec file under doc/gateway_specs/ recording every endpoint, the complete result-code table and the native currency unit, each with a source URL and a retrieval date. Anything that could not be confirmed goes in doc/gateway_specs/UNVERIFIED.md rather than being guessed.

License #

MIT. See LICENSE.

3
likes
160
points
42
downloads

Documentation

Documentation
API reference

Publisher

unverified uploader

Weekly Downloads

One type-safe API across Iranian payment gateways. Pure Dart, explicit currency units, a unified error taxonomy and server-safe verification.

Repository (GitHub)
View/report issues

Topics

#payment #iran #gateway #fintech #zarinpal

License

MIT (license)

Dependencies

http, meta

More

Packages that depend on pardakht