esaalat
Verified wallet payments for Flutter, with the Esaalat
hosted checkout. One branded button starts a checkout, shows the portal in a
WebView, and reports the outcome to the callback you care about — same option
names, status strings and error codes as esaalat-react on the web.
- One widget. The button owns the client, the full-screen route and result routing.
- Native result channel. The portal posts its result straight to the WebView, with polling as the fallback.
- On-brand by default. A self-contained vector lockup, Arabic and English, with an optional custom label.
- Server-created sessions. Pass
sessionTokento lock the amount on your server. - Reduced motion respected. The lockup intro renders its final frame when the platform asks for less motion.
EsaalatCheckoutButton(
publishableKey: 'pk_live_...', // Dashboard → API Keys
amount: '5000.00',
externalOrderId: order.id,
onVerified: (result) => markOrderPaid(order.id, result.intentCode),
onCancelled: (_) => setStatus('cancelled'),
onWrongAmount: (result) => setStatus('paid ${result.paidAmount}'),
)
That is the whole integration: the button creates the session, pushes the
full-screen checkout route, locks itself while the checkout is open, and routes
the terminal result to exactly one of onVerified, onCancelled,
onWrongAmount, onExpired or onFailed. Only a checkout that cannot start
goes to onError.
Contents
- Install
- The button
- Lock the amount on your server
- Imperative client
- Result
- How the WebView is resolved
- Local development
- Demo mode
- Example
- Documentation and other SDKs
- License
Install
flutter pub add esaalat
Android needs the android.permission.INTERNET permission (any app that talks
to an API already declares it). The package uses webview_flutter and
flutter_svg; see webview_flutter's platform requirements (Android 5.0+,
iOS 12+).
The button
EsaalatCheckoutButton renders the Esaalat lockup on the brand's black button
— "Pay with Esaalat" / «ادفع عبر إيصالات» — and needs no client or route
management in your app. The default lockup is one self-contained vector
artwork rendered with flutter_svg: the lead and wordmark are SVG paths, so no
image asset or host font is involved. Its wordmark starts centred and slides
aside as the lead fades in; the platform's reduced-motion setting renders the
final frame without animating.
The button uses a crisp 4px corner by default. Pass radius: 2 for sharper
corners or radius: 0 for square corners.
EsaalatCheckoutButton(
publishableKey: 'pk_live_...',
amount: '5000.00',
externalOrderId: order.id,
onVerified: (result) => setState(() => _result = result),
)
Props
| Prop | Type | Notes |
|---|---|---|
publishableKey |
String |
Required to create a session; omit it when sessionToken is used. Safe to embed. |
amount |
String? |
Order total. Required unless items or sessionToken is used. |
items |
List<CheckoutItem> |
Order lines; when present they determine the amount. |
currency |
String? |
Defaults to YER server-side. |
externalOrderId |
String? |
Your order reference, echoed back in the result. |
notes |
String? |
Internal merchant notes. |
wallet |
String? |
Optional preselected receiving wallet id. |
showTestingWallet |
bool |
Also offer the account's dedicated testing wallet, when it is enabled and monitored. Defaults to false; ignored with sessionToken. |
locale |
EsaalatLocale |
Portal language. Defaults to Arabic. |
sessionToken / portalUrl |
String? |
Open a session created by your server. Takes precedence over amount / items. |
idempotencyKey |
String? |
Stable retry key; generated per checkout when omitted. |
simulateResult |
CheckoutSimulation? |
Demo-only outcome for keys that allow simulation. |
apiBaseUrl |
String |
Override for sandbox/local. |
portalBaseUrl |
String |
Portal origin when the API returns no portal_url. |
label |
String? |
Replaces the branded lockup with your own text. |
radius |
double |
Corner radius in logical pixels. Defaults to 4; 0 gives square corners. |
enabled |
bool |
Lock the button. It is also locked automatically while its checkout is open. |
Callbacks
Exactly one result callback runs per outcome — the status of the checkout result decides which, so there is nothing to switch on:
| Callback | Fires when |
|---|---|
onVerified |
A wallet alert matched the payment. |
onCancelled |
The buyer dismissed the checkout, or the intent was cancelled. |
onWrongAmount |
A payment matched for a different amount; read result.paidAmount. |
onExpired |
The buyer ran out of time. |
onFailed |
The portal could not load a valid session. |
onError |
The checkout could not start (EsaalatCheckoutError with a code). |
Every result callback receives a CheckoutResult; the status table is
below.
A result is not a licence to fulfil. Confirm the order with the signed
payment.verified webhook, or GET /api/v1/merchant/intents/{id}/, on your
server before shipping anything.
Lock the amount on your server
The publishable key can start a session with any amount because the amount lives in the app. If it must be authoritative, create the session from your server with the client JWT and hand only its token to the app:
POST /api/v1/checkout/merchant/sessions/
Authorization: Bearer <access_token>
Content-Type: application/json
{"amount": "5000.00", "external_order_id": "order-123"}
Then let the button open it — no key is needed for that path:
EsaalatCheckoutButton(
sessionToken: session.sessionToken,
portalUrl: session.portalUrl, // optional; the SDK builds it if omitted
onVerified: onVerified,
)
The token is a capability: do not log it, and give it only to the buyer who is paying that order.
Imperative client
When the button's layout or lifecycle does not fit (a custom row, a checkout started from a deep link, a bottom-sheet trigger), use the client directly. It is the same checkout the button drives; only the wiring is yours.
final checkout = EsaalatCheckout(publishableKey: 'pk_live_...');
final result = await checkout.open(
context, // used to push the checkout route
amount: '5000.00',
currency: 'YER',
externalOrderId: 'order-123',
);
if (result.isVerified) {
// Show the buyer a success state. Confirm the order server-side first.
}
open() needs a mounted BuildContext and pushes a full-screen route. The
future completes when the route closes; if the buyer closes it, the result is
cancelled / dismissed. Call checkout.dispose() from the owning widget's
dispose().
Constructor
| Option | Default | Notes |
|---|---|---|
publishableKey |
'' |
Required to create a session; optional when every open() passes sessionToken. Safe to embed: it can only create sessions. |
locale |
EsaalatLocale.ar |
Portal language. |
apiBaseUrl |
https://api.esaalat.com/api/v1 |
Override for sandbox/local. |
portalBaseUrl |
https://esaalat.com |
Fallback only; the API's portal_url wins. |
httpClient |
http.Client() |
Custom transport (tests, proxies). |
dispose() |
— | Releases the SDK's HTTP client. |
open() options
| Option | Notes |
|---|---|
amount |
Order total (decimal string). Required unless items or sessionToken is used. |
items |
List<CheckoutItem>; determines the amount. |
currency |
Defaults to YER server-side. |
externalOrderId |
Your order reference, echoed back in the result. |
notes |
Internal merchant notes. |
wallet |
Optional preselected receiving wallet id. |
showTestingWallet |
Also offer the account's dedicated testing wallet, when it is enabled and monitored. Defaults to false. |
sessionToken |
Open a server-created session instead of creating one. |
portalUrl |
Portal URL for that session (optional; the SDK builds it). |
idempotencyKey |
Stable retry key; generated per call when omitted. |
locale |
Overrides the client locale for this checkout. |
simulateResult |
Demo-only outcome; refused unless the key allows simulation. |
pollInterval |
Fallback polling cadence. Defaults to 4 seconds. |
Session creation, timeout and API failures throw EsaalatCheckoutError
with a code of configuration, network, api, session, unavailable or
aborted; a checkout that starts but ends badly is a result, not an exception.
Result
status |
Meaning |
|---|---|
CheckoutResultStatus.verified |
A wallet alert matched the order and the amount was verified. |
CheckoutResultStatus.wrongAmount |
A payment matched but the amount differs; paidAmount says what arrived. |
CheckoutResultStatus.expired |
The buyer did not finish before the payment window closed. |
CheckoutResultStatus.cancelled |
The buyer closed the checkout, or the intent was cancelled. |
CheckoutResultStatus.failed |
The portal could not load a valid session. |
reason carries the web SDK's reasons (dismissed, intent_cancelled,
session_expired, session_not_found, network_error, ...).
How the WebView is resolved
The portal is loaded top-level, exactly as a standalone visitor sees it. The
portal posts its terminal result to the EsaalatNativeBridge JS channel this
SDK registers, so the route closes as soon as the payment is matched. It also
polls GET /checkout/public/sessions/{token} with the portal's own result
mapping as the fallback, for a page reload or a missed message.
The route closes after a verified payment (1.8 s, so the buyer sees the success
screen) or immediately for other terminal states. Network hiccups while polling
are ignored; a session the backend does not know settles as
failed / session_not_found. The wire contract is shared by every Esaalat SDK;
see the Flutter guide for the full
behaviour.
Local development
Start the Esaalat API and portal locally, with the checkout links pointing at
an address the device can reach (10.0.2.2 is the host from the Android
emulator):
FRONTEND_URL=http://10.0.2.2:3000
EsaalatCheckoutButton(
publishableKey: 'pk_...',
amount: '5000.00',
apiBaseUrl: 'http://10.0.2.2:8000/api/v1', // Android emulator
portalBaseUrl: 'http://10.0.2.2:3000',
onVerified: onVerified,
)
Cleartext HTTP is blocked by default on Android 9+; enable it for the debug
build (android:usesCleartextTraffic="true") or run the API behind HTTPS.
Demo mode
simulateResult asks the backend to resolve the session with a chosen outcome
(verified, underpaid, overpaid, cancelled, expired) as soon as a
wallet is selected. It only works for publishable keys whose client has
allow_checkout_simulation enabled — a normal production key cannot create a
simulated session. It is an open() option; the imperative client is where it
belongs.
Example
See example/ for a complete app with an amount field and a result
card built on the button. Generate its platform folders once with
flutter create . inside example/, then run it with your publishable key:
cd example
flutter create . --platforms=android,ios
flutter run --dart-define=ESAALAT_PUBLISHABLE_KEY=pk_...
Documentation and other SDKs
License
MIT © Esaalat
Libraries
- esaalat
- Esaalat hosted checkout for Flutter.