A drop-in bottom sheet that takes a payment through SimPay: lists the channels, creates the transaction, handles BLIK Level 0 and redirect channels, and confirms the outcome against the API before reporting anything.
| Module | What it is |
|---|---|
sdk |
the library — payment sheet and the MobileSessionProvider integration point |
app |
demo shop showing the full flow |
Adding this to your own application? See INTEGRATION.md for the dependency wiring, the session provider, the deep link activity and a table of failure symptoms. Everything below is the reference.
- Before you start: the merchant panel
- Quick start
- How a payment runs
- Reference
- Demo app
- Development
- License
Nothing works until the service is prepared in the SimPay merchant panel. Open the service settings and:
- Turn on mobile SDK support. Without it the session endpoint issues no token and the sheet fails before loading a single channel. No code can work around this.
- Register the deep link scheme, character for character identical to the manifest
and to
TransactionReturns.
merchant panel AndroidManifest.xml TransactionReturns
-------------- ------------------- ------------------
scheme: myapp ↔ android:scheme="myapp" ↔ "myapp://payment-return?..."
^^^^^
the same value in all three places
A mismatch has no compile error and no error message. The payment succeeds and the payer simply never returns to the app.
The scheme is global to the device and owned by nobody, so pick something specific such
as reverse-DNS (pl.example.shop). For production prefer verified App Links (https://
with assetlinks.json) — the flow is identical, only the intent filter differs.
val checkout = SimPayMobileCheckout(
configuration = SimPayMobileConfiguration(
serviceId = "a1b2c3d4",
merchantDisplayName = "Example Store"
),
sessionProvider = object : MobileSessionProvider {
override suspend fun provideSession(): MobileSdkSession =
merchantBackend.createMobileSdkSession() // never the API key here
}
)
checkout.present(activity, order) { result ->
when (result) {
is CheckoutResult.Succeeded -> // transaction_paid — still confirm via webhook
is CheckoutResult.Pending -> // outcome open, never treat as paid
is CheckoutResult.Failed -> // show result.error
CheckoutResult.Cancelled -> // payer dismissed the sheet
}
}The API address is fixed to https://api.simpay.pl and the header is always
Bearer <token> with the full mobsdk_ prefix. An integrator supplies only the service
id, the display name and a session provider.
merchant backend app + SDK
---------------- ---------
POST /mobile-sdk/session
Authorization: Bearer <API key>
|
v
mobsdk_... token (~15 min) ----> MobileSessionProvider
|
v
GET /mobile-sdk/channels
POST /transactions (once per checkout)
|
+-------------+-------------+
v v
POST /blik/level0/{id} Custom Tab redirect
| |
+-------------+-------------+
v
GET /mobile-sdk/transactions/{id}
polled until terminal
The merchant API key must never be embedded in a mobile application. The session endpoint needs it, so the backend exchanges it for a short lived token.
| API status | Result |
|---|---|
transaction_paid |
Succeeded |
transaction_new / generated / confirmed |
keeps polling |
transaction_fraud_possibility |
Pending (verification required) |
transaction_failure / fraud |
Failed, not retryable |
transaction_expired / refunded |
Failed |
| unrecognised status | Pending, never success |
| polling budget elapsed | Pending |
Every channel is created as a direct channel, so the SimPay picker is skipped and the payer goes straight to the bank, wallet or BLIK confirmation. The API therefore needs the full payer context:
| Field | Source |
|---|---|
directChannel |
id of the tapped channel (blik, mbank, …) |
customer.email |
TransactionOrder.customerEmail |
customer.ip |
MobileSdkSession.payerIp, fallback payerIpOverride |
customer.countryCode |
TransactionOrder.customerCountryCode, else derived from currency |
antifraud.useragent |
real device agent (WebSettings.getDefaultUserAgent) |
Validated locally, before any transaction exists: a missing payer IP fails with
MissingPayerIp; a currency shared by several countries (EUR) fails with
MissingCountryCode rather than guessing; the country code must be ISO 3166-1 alpha-2.
A rejected ticket closes the transaction on the SimPay side, so the next code creates a
new one. Only an explicit rejection (HTTP 400/409/422) drops the transaction;
retries stop after MAX_BLIK_REJECTIONS (3). The abandoned id is remembered, so
dismissing the sheet reports Pending/Failed with that id rather than Cancelled.
Valid for about 15 minutes. SessionHolder refreshes once on HTTP 401 and repeats the
call — but never for requests whose outcome is unknown. The token is a JWT after the
mobsdk_ prefix; its payload carries sub = payment:<serviceId>, scopes and exp.
Texts live in values/strings.xml (English) and values-pl/strings.xml (Polish);
Android picks by device locale. Adding a language means adding one file — no code
changes. All names carry the simpay_ prefix, enforced by resourcePrefix, so the SDK
can never overwrite a host resource.
API messages are not shown to the payer — they are English and technical, and stay
in CheckoutResult.Failed.message for logging. If the host app limits packaged locales
(androidResources.localeFilters), it must include the languages it wants from the SDK.
The whole integration is DemoCheckout.present.
It runs offline by default on FakeMobileSdkApi. To drive a debug build against the
real API, add to local.properties (git-ignored):
simpay.serviceId=...
simpay.apiKey=...
simpay.payerIp=...All three are required; with any missing the demo stays offline. Release builds ignore them entirely. Real-API runs also need the panel prepared — see section 1.
The fake API is demo-only. The SDK never falls back to it, so a successful fake run does not prove a working integration.
./gradlew :app:installDebug # run the demo
./gradlew :sdk:testDebugUnitTest # unit tests
./gradlew :sdk:lintRelease # lintMIT — see LICENSE