pardakht 0.8.1
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 analyzereports zero issues of any severity.dart format --set-exit-if-changed .is clean.dart testpasses 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 indoc/gateway_specs/UNVERIFIED.mdrather than being guessed.
License #
MIT. See LICENSE.