DigetPay for Flutter

DigetPay — pure-Dart hosted checkout for Flutter

pub version pub points pub likes platforms license: MIT

Accept card payments in your Flutter app in minutes. DigetPay defaults to a hosted, PCI-safe WebView checkout — raw card data never passes through your app, so you keep your PCI scope small — plus a full REST API for money movement, recurring charges, and transaction history. Pure Dart, built on http and webview_flutter; no native code to write or register.

await DigetPaySdk.cardPay()
    .setOrder(DigetPaySaleOrder(id: orderId, description: 'Coffee', currency: 'SAR', amount: 15))
    .setPayer(payer)
    .onTransactionSuccess((res) => print('Paid ✓ ${res.transactionId}'))
    .onTransactionFailure((res) => print('Failed: ${res.declineReason}'))
    .onDismiss(() => print('User cancelled'))
    .start(context);

Contents

Install

dependencies:
  digetpay_plugin: ^0.5.0
flutter pub add digetpay_plugin

Quick start

Initialize once at startup, then call the static API anywhere:

import 'package:digetpay_plugin/digetpay_plugin.dart';

DigetPaySdk.initialize(
  apiKey: '<your-mobile-api-key>',
  baseUrl: kDigetPayDefaultBaseUrl, // sandbox: https://fin-api.digetpay.com/v1
  // production: https://api.digetpay.com/v1
);

Every call before initialize() throws DigetPaySdkIsNotInitializedException.

initialize() has no re-entrancy guard — call it again (e.g. after a user picks a different environment or updates their API key) to reconfigure the SDK at any time.

That's it — you're ready to open the checkout.

What you can do

✅ Available now Purpose
cardPay() Hosted, PCI-safe card checkout (WebView)
sale(request) Headless S2S card sale/authorization — ⚠️ widens PCI scope, see below
payCheckoutSession(...) Pay an existing session & enroll a recurring plan — ⚠️ widens PCI scope, see below
getCheckoutStatus(sessionId) Confirm a checkout's outcome (single transaction)
getStatusCheckout(sessionId) A session's transactions, paginated — see below
getCheckoutSessions([filter]) List your checkout sessions (paginated)
getTransactionById(id) Full transaction details by gateway transaction id
getTransactionHistory([filter]) List raw transaction records (paginated)
capture · voidd Move money on an existing transaction
refundS2s(...) · refundCheckout(...) Refund — pick the variant matching how you charged
recurringS2s(request) · recurringCheckout(request) Charge a recurringToken — pick the variant matching how you charged
getRecurringSubscriptions([filter]) List saved-card billing plans (paginated)
chargeSubscription(...) Charge an existing subscription — no card data needed
🚧 Not yet available Behaviour
applePay · externalPayment Throws UnsupportedError — no backend endpoint yet
getTransactionByOrderId · getTransactionByRrn Throws UnsupportedError — no endpoint yet
await DigetPaySdk.cardPay()
    .setOrder(DigetPaySaleOrder(
      id: orderId, description: 'Coffee', currency: 'SAR', amount: 15))
    .setPayer(DigetPayPayer(
      firstName: 'Demo', lastName: 'User', address: 'Riyadh',
      country: 'SA', city: 'Riyadh', zip: '00000',
      email: 'demo@example.com', phone: '+966500000000'))
    // .setResultUrls(...) is optional — omit to use the SDK defaults.
    .onTransactionSuccess((res) { /* res.status, res.transactionId ... */ })
    .onTransactionFailure((res) { /* res.declineReason, res.errorCode */ })
    .onDismiss(() { /* user backed out */ })
    .start(context);

start() calls POST /payment/checkout/intiate, opens the returned redirectUrl in a WebView and — once the hosted page reaches a result page — fetches GET /sdk/status?sessionId= and dispatches success or failure from the transaction's paymentStatus. Exactly one callback fires.

Flutter app
  │  DigetPaySdk.cardPay()…start(context)
  ▼
POST /payment/checkout/intiate  ──▶  { id, redirectUrl }
  │  push CheckoutPage → load redirectUrl
  ▼
WebView (hosted card form + 3-D Secure)
  │
  ├─ reaches a result page ──▶ GET /sdk/status?sessionId=<id>  (authoritative)
  │        ├─ paymentStatus == APPROVED ─▶ onTransactionSuccess(res)
  │        └─ otherwise                 ─▶ onTransactionFailure(res)
  │
  └─ user backs out (no result page) ──▶ onDismiss()
  • intiate is spelled that way on the backend — kept verbatim so the docs match the actual wire call (it is not a typo in your code).
  • The status response is authoritative — the SDK does not trust the result URL alone, because the gateway can route to /success briefly before settling on /failure.

Direct (S2S) card sale

⚠️ PCI scope. cardPay() is the recommended default — raw card data never passes through your app. sale() is an explicit opt-in for a headless integration: your app collects the PAN/CVV directly, which widens your PCI-DSS scope. Only use it if you've independently assessed that tradeoff.

final response = await DigetPaySdk.sale(
  SaleRequest(
    orderId: orderId,
    amount: 15,
    currency: 'SAR',
    auth: false, // true → authorization-only hold instead of a sale
    customer: Customer(
      name: 'Demo User',
      email: 'demo@example.com',
      phone: '+966500000000',
    ),
    successUrl: 'https://example.com/success',
    failureUrl: 'https://example.com/failure',
    card: CardDto(
      cardNumber: '4111111111111111',
      cardHolder: 'Demo User',
      cardExpiryMonth: '12',
      cardExpiryYear: '2030',
      cardCvv: '123',
    ),
  ),
);

sale() POSTs to /payment/s2s/sale. The integrity hash the backend requires is computed automatically — MD5 of reverse(email) + apiKey + reverse(first6+last4 of the card number), uppercased — from customer.email, card.cardNumber, and your configured API key. You never need to compute or pass it yourself (set SaleRequest.hash only if you need to override it, e.g. for auditing). The hash, PAN, and CVV are never logged.

Set auth: true to place an authorization-only hold instead of an immediate sale — the response's data['action'] reads "SALE" or "AUTH" accordingly.

Completing 3-D Secure

Most cards require a 3-D Secure challenge before sale()'s result is final. When that's needed, the response carries a self-submitting collector page: response.needs3ds is true and response.html holds the page markup — the payment stays PENDING until that page's form is submitted, which happens automatically once it's loaded in a WebView. Reuse CheckoutPage (the same widget cardPay() uses internally) to present it, passing raw html instead of a checkoutUrl:

if (response.needs3ds) {
  String? terminalUrl;
  await Navigator.of(context).push(
    MaterialPageRoute(
      builder: (_) => CheckoutPage(
        html: response.html,
        successUrl: successUrl, // the same URLs passed on the SaleRequest
        failureUrl: failureUrl,
        onFinished: (url) => terminalUrl = url,
      ),
    ),
  );
  // terminalUrl is now the success/failure return URL the challenge reached.
  // For the authoritative outcome, look the payment up — e.g.
  // DigetPaySdk.getTransactionById(response.paymentId!) — falling back to
  // isSuccessUrl(terminalUrl!, successUrl) if that lookup is inconclusive.
}

See the example app's S2S Card Sale screen for a complete version of this, including the getTransactionById fallback.

Paying a checkout session

payCheckoutSession() pays an existing checkout session with card details you collected yourself, enrolling the card into a recurring plan at the same time. Build the body with the constructor matching the plan you want:

⚠️ PCI scope. Like sale(), this carries raw card data through your app. Prefer cardPay() unless you have independently assessed PCI-DSS compliance.

// Charge now, and save the card for later on-demand charges:
final response = await DigetPaySdk.payCheckoutSession(
  sessionId: sessionId,
  request: const CheckoutPayRequest.unscheduled(
    amount: 100,
    cardNumber: '5123450000000008',
    cardHolderName: 'Fares Mohamed',
    cardExpiryMonth: '01',
    cardExpiryYear: '2039',
    cardCvv: '100',
    email: 'customer@example.com',
  ),
);

// …or split the session across a fixed schedule:
const scheduled = CheckoutPayRequest.scheduled(
  frequency: CheckoutBillingFrequency.weekly, // MONTHLY or WEEKLY
  installments: 8,
  cardNumber: '5123450000000008',
  cardHolderName: 'Fares Mohamed',
  cardExpiryMonth: '01',
  cardExpiryYear: '2039',
  cardCvv: '100',
  email: 'customer@example.com',
);

The response usually carries a 3-D Secure collector page — handle it exactly like sale() does:

if (response.needs3ds) {
  // present response.html in a CheckoutPage(html: ...)
}

To read back everything recorded against a session, getStatusCheckout() hits GET /payment/checkout/status and answers with a page of transactions:

final page = await DigetPaySdk.getStatusCheckout(sessionId);
for (final txn in page?.content ?? const <Transaction>[]) {
  print('${txn.transactionType}: ${txn.paymentStatus}');
}

getStatusCheckout() vs getCheckoutStatus() — they are different endpoints, not aliases. getCheckoutStatus() hits /sdk/status and resolves a single Transaction; getStatusCheckout() hits /payment/checkout/status and returns a PageDto<Transaction>.

Transaction history & lookup by id

getTransactionById(id) fetches full details for a single transaction by its gateway transaction id (GET /payment/transactions/digetpay/{id}/details), including its businessUnitHierarchy. getTransactionHistory([filter]) lists raw transaction records the same way, paginated:

final txn = await DigetPaySdk.getTransactionById(gatewayTransactionId);

final history = await DigetPaySdk.getTransactionHistory(
  // pageNumber is 0-based — the first page is 0.
  const TransactionFilter(pageNumber: 0, pageSize: 30, status: 'SUCCESS'),
);

Which method do I use? getCheckoutSessions() lists checkout sessions (PageDto<CheckoutSession>); getTransactionHistory() lists raw transactions (PageDto<Transaction> — purchases, recurring charges, and refunds all appear here). They are not interchangeable — pick based on which shape you need.

Listing checkout sessions

getCheckoutSessions() returns a paginated PageDto<CheckoutSession> from GET /payment/checkout/sessions:

final page = await DigetPaySdk.getCheckoutSessions(
  const CheckoutSessionFilter(page: 1, limit: 20),
);

for (final session in page?.content ?? const <CheckoutSession>[]) {
  print('${session.merchantOrderId} · ${session.amount} ${session.currency} · ${session.status}');
}

⚠️ Which id do I use?

A CheckoutSession carries two ids. For capture · refundCheckout · voidd, always use session.gatewayTransactionIdnot session.id (which identifies the checkout session, not the transaction).

// ✅ correct — these sessions were paid via hosted checkout, so use the Checkout variant
await DigetPaySdk.refundCheckout(transactionId: session.gatewayTransactionId!, amount: session.amount!);
// ❌ wrong — session.id is NOT a transaction id

PageDto exposes content, number (the backend's 1-based page), size, totalElements, totalPages, first, and last.

Money movement

Operate on an existing transaction by its gateway transaction id (no card data involved):

await DigetPaySdk.capture(transactionId: id, amount: 10); // POST /payment/s2s/capture
await DigetPaySdk.voidd(id);                              // POST /payment/s2s/void

Refunds have two endpoints — pick the one matching how the payment was taken, so the refund lands on the right rail:

// Charged with sale() — the direct/S2S rail:
await DigetPaySdk.refundS2s(transactionId: id, amount: 10);      // POST /payment/s2s/refund

// Charged with cardPay() — the hosted-checkout rail:
await DigetPaySdk.refundCheckout(transactionId: id, amount: 10); // POST /payment/refund

refund(...) still works and is not deprecated — it is shorthand for refundCheckout(...), exactly the endpoint it has always used. Prefer the explicit names in new code so the target is obvious at the call site.

These return a typed DigetPayResponse. HTTP errors and declines come back as an unsuccessful response — they do not throw:

DigetPayResponse Meaning
isSuccess 2xx and not declined/failed
status / result Gateway status text
transactionId · orderId · rrn Identifiers
message · errorCode · declineReason Failure detail
data The raw decoded payload (for anything not typed above)

Recurring charges & subscriptions

A recurring charge reuses a recurringToken from an earlier transaction — no card data involved. Like refunds, this has two endpoints; pick the one matching where the token came from:

final request = RecurringRequest(
  transactionId: initialTransactionId,
  orderId: 'ORD-2025-887899',
  recurringToken: recurringToken,
  amount: 100,
  currency: 'SAR',
  // Optional — when supplied it is nested verbatim as the `order` object;
  // omit it and the key is left out of the request body entirely.
  order: const Order(
    number: 'ORD-2025-887899',
    amount: 100,
    currency: 'SAR',
    description: 'Monthly subscription payment',
  ),
);

// Token from a direct sale() sent with recurringInit: true:
await DigetPaySdk.recurringS2s(request);      // POST /payment/s2s/recurring

// Token from a hosted checkout (see Transaction.recurringToken):
await DigetPaySdk.recurringCheckout(request); // POST /payment/checkout/recurring

recurring(...) still works and is not deprecated — it is shorthand for recurringCheckout(...), exactly the endpoint it has always used.

For saved-card billing plans (subscriptions), list them and charge one on demand — again, no card data needed, the subscription's saved token is charged server-side:

final page = await DigetPaySdk.getRecurringSubscriptions(
  const RecurringSubscriptionFilter(page: 1, limit: 20),
);

for (final sub in page?.content ?? const <RecurringSubscription>[]) {
  if (sub.isCompleted) continue;
  final result = await DigetPaySdk.chargeSubscription(
    subscriptionId: sub.id!,
    amount: 50,
    email: 'customer@example.com',
  );
  print(result?.isSuccess); // SubscriptionChargeResult — a flat, non-enveloped body
}

Handling transaction data

getCheckoutStatus returns a typed Transaction? (a DECLINED transaction is still returned — inspect paymentStatus). Some fields are sensitive; the SDK delivers them to you but never logs them:

  • pan — already masked by the backend (4323 2** **** 0853); safe to show.
  • cardHolderName — PII; display it, but don't log/persist broadly.
  • recurringTokensensitive: it can initiate future charges. Keep it server-side, never log it, and don't surface it in the UI.

Debug logging

Pass an optional logger to initialize for a redacted, step-by-step trace — HTTP method + path + status, checkout milestones, and the terminal outcome. It never receives the API key, card data, request/response bodies, or query values.

import 'dart:developer' as developer;

DigetPaySdk.initialize(
  apiKey: '<key>',
  logger: (message) => developer.log(message, name: 'DigetPay'),
);
💳 checkout: creating session for order 6f… (15.0 SAR)
➡️ → POST /payment/checkout/intiate
⬅️ ← 201 OK POST /payment/checkout/intiate {code=201, session=ab02…, redirect=https://fin-admin.digetpay.com/pay/checkout}
🌐 webview → https://fin-admin.digetpay.com/pay/checkout/success
➡️ → GET /sdk/status?sessionId
⬅️ ← 200 OK GET /sdk/status {code=200, status=APPROVED, txn=070ae…, msg=Success}
✅ checkout: APPROVED → onTransactionSuccess

Leave logger unset (the default) for zero logging in production.

Platform setup

Pure-Dart plugin — no native code to write. You only need standard network/WebView configuration.

Android

  • minSdkVersion 24 (Android 7.0) in android/app/build.gradle.

  • INTERNET permission in android/app/src/main/AndroidManifest.xml (the release manifest does not include it automatically):

    <uses-permission android:name="android.permission.INTERNET"/>
    

iOS

  • Deployment target iOS 13.0+ in ios/Podfile and the Runner target.
  • DigetPay endpoints and the hosted page are served over HTTPS, so the default App Transport Security policy works with no changes. Avoid a blanket NSAllowsArbitraryLoads in production.

Hosted checkout targets Android and iOS (via webview_flutter); Flutter web and desktop are not supported in this version.

Security — mobile API key

DigetPayConfig.apiKey is embedded in your app binary, and anything shipped in a mobile app can be extracted. Always initialize with a publishable / restricted mobile key scoped to client operations — never a full server secret. Rotate the key if it is ever exposed, and keep privileged operations behind your own server.

For card entry, prefer cardPay() — raw card data never touches your app process, so your PCI scope stays small. sale() is an explicit, opt-in exception to that: it carries raw PAN/CVV through your app, which widens your PCI-DSS scope. Only reach for it if you've independently assessed that tradeoff for your integration.

Not yet available

applePay, externalPayment, getTransactionByOrderId, and getTransactionByRrn throw UnsupportedError until the corresponding backend endpoints exist (see the TODO(endpoint) markers in the source). getTransactionByOrderId — list sessions with getCheckoutSessions() and match on CheckoutSession.merchantOrderId, or filter getTransactionHistory() instead.


MIT © DigetPay · Issues & source: https://github.com/DigetPay/digetpay_plugin

Libraries

digetpay_plugin
DigetPay — pure-Dart Flutter payment plugin.