iOS integration¶
SwiftUI card-entry + headless payment over a binary KMP XCFramework, exposed by a hand-written
100%-Swift facade split into two SPM products: HiPayCore (headless) and HiPayCard (UI).
pay() presents the 3DS challenge itself and returns the final, server-confirmed transaction.
Requirements¶
- Xcode (the toolchain used to build the v1 demo), Swift 5.9+/6
- Targets:
iosArm64(device) +iosSimulatorArm64(Apple-Silicon simulator) - Two SPM products:
HiPayCore(headless) andHiPayCard(UI), both resolved from the package below
Why isn't
HiPayCardin the.xcframework? The.xcframeworkis only the compiled KMP/Kotlin core (HiPayPayments).HiPayCore/HiPayCardare the hand-written Swift facade (D4) — Swift source, shipped in the package, depending on the binary. They cannot live inside a KMP-compiled framework.
Add the SDK (Swift Package Manager)¶
In Xcode: File ▸ Add Package Dependencies…, enter
https://github.com/hipay/hipay-payments-sdk-ios, choose Up to Next Major Version from
1.2.0, then add the HiPayCore and HiPayCard products to your target.
From a Package.swift:
dependencies: [
.package(url: "https://github.com/hipay/hipay-payments-sdk-ios.git", from: "1.2.0")
],
targets: [
.target(name: "YourApp", dependencies: [
.product(name: "HiPayCore", package: "hipay-payments-sdk-ios"),
.product(name: "HiPayCard", package: "hipay-payments-sdk-ios"),
])
]
SwiftPM downloads the compiled
HiPayPayments.xcframeworkfrom the release assets and verifies it against the checksum recorded in the tag's manifest, so a tampered binary fails to resolve.
Use the component¶
import HiPayCard
import HiPayCore
let config = HiPayConfiguration(username: "…", password: "…", environment: .stage)
@StateObject var card = HiPayCardEntryController(configuration: config) // allowedNetworks: [] by default
var body: some View { HiPayCardEntryView(controller: card) }
Pay (the card token never leaves the controller):
// Production: get `signature` from YOUR backend. For a first STAGE test, compute it locally
// with the StageSignature helper below (⚠️ stage/test only).
let signature = StageSignature.compute(orderId: orderId, amount: "10.00", currency: "EUR")
let tx = try await card.pay(
orderId: orderId, amount: "10.00", currency: "EUR",
description: "Order …", redirectScheme: "yourscheme",
authenticationIndicator: 0, // 0 bypass / 1 if-available / 2 mandatory 3DS
signature: signature
// threeDS: .inAppSession is the default — see "3DS presentation" below
)
switch tx.state { // tx is the FINAL state (3DS already handled)
case .completed: /* success */
case .pending: /* pending */
case .declined: /* declined */
default: /* error */
}
The card fields lock themselves while
pay()is in flight — the SDK exposescontroller.isProcessingandHiPayCardEntryViewdisables its fields on it (no parameter to wire). Disable your own Pay button the same way:.disabled(!card.canPay || card.isProcessing). The SDK also clears the card after tokenisation (PCI), socanPayis false after a successful payment — a new payment needs a fresh card entry.
Payment progress¶
controller.paymentPhase reports which step the payment is on — .tokenizing, .creatingOrder,
.authenticating, .confirming — and is nil when idle. @Published and read-only, so a host can
replace one opaque spinner with its own wording:
The payer-facing wording is deliberately yours: the SDK ships no localized strings for these.
Optional gateway parameters¶
pay(...) and payWithSavedCard(...) send what the SDK models. For anything else the gateway
accepts — a per-order notification URL, a bank-statement descriptor, the basket — pass a
HiPayOrderOptions:
let options = HiPayOrderOptions(
notifyUrl: "https://your-backend.example/hipay/notify", // overrides the back-office URL
softDescriptor: "MY SHOP", // shown on the payer's statement
customData: ["internal_reference": "ORD-987465"], // your own data, shown in the back office
custom: ["shipping": "1.00"] // a gateway parameter the SDK does not model
)
let tx = try await card.pay(/* … */, options: options)
Every property is optional and a nil one is not sent. A struct with defaulted properties rather
than a builder: adding a property later stays source-compatible for you.
Validation happens when the order is built, so pay(...) throws HiPayError.validation on a
rejected value — a non-http(s) notifyUrl, a blank value, or a custom name the SDK owns. It
fails before any order is created rather than at the gateway, and the rules are the shared ones, so
Android and iOS refuse exactly the same inputs.
customData and custom are not interchangeable. Your own data goes through customData, which
fills the gateway's custom_data; the order schema accepts no arbitrary top-level parameter, so an
invented wire name passed to custom is refused by the gateway.
Names the SDK owns are rejected in custom: the signature-covered ones (orderid, amount,
currency), the card token, the return URLs, and the customer/shipping blocks, which have typed
parameters.
On notifyUrl specifically: it overrides your account configuration and is not covered by the
order signature, so a tampered build of your app could point the notification elsewhere.
Notifications remain signed, so nothing can be forged in your name — but yours can be suppressed,
leaving a reconciliation gap. Prefer the back-office setting in production.
Accepted card networks — the account decides¶
The component asks your HiPay account which card products it is contracted for as soon as the view
appears, and only offers those. You do not configure this, and you cannot widen it: the optional
allowedNetworks you pass narrows that set.
let controller = HiPayCardEntryController(
configuration: configuration,
// Optional. Narrows what the account already accepts — never widens it.
allowedNetworks: [.visa, .mastercard],
// Optional (default "EUR"). A contract can differ per currency, so pass the currency the
// order will be created in.
currency: "EUR"
)
What the payer sees for a card the account does not accept: no brand icon, the inline
"Card type not allowed" message once the network is known, and a blocked pay button (canPay false).
Two behaviours worth knowing before you file a bug:
- No brand icon is shown while that first answer is in flight. Whether the detected network is offerable at all is exactly what is unknown at that moment.
- If the query fails (device offline, gateway unreachable), the ceiling is left OPEN and the component behaves exactly as it did before this feature existed — the locally detected icon is shown and nothing is refused. This is deliberate: a payment form must not be unusable because of a network hiccup. It also means that "the restriction does nothing" is usually a connectivity problem on the device, not a broken SDK — check that the device can reach the gateway first.
Styling¶
HiPayCardTheme bridges the shared HiPayCardEntryStyle into Swift. Start from .hipayDefault
and override per property — Kotlin default args aren't exported to Swift, so you build the theme by
mutation, and the setters enforce fail-fast bounds:
var theme = HiPayCardTheme.hipayDefault
theme.iconColor = UIColor(red: 0.38, green: 0, blue: 0.93, alpha: 1)
theme.cornerRadius = 12
theme.fieldSpacing = 8 // gap between fields; leave it for this platform's own spacing
HiPayCardEntryView(controller: controller, theme: theme)
Upgrading to 1.2.0.
HiPayCardEntryStylegainedfieldSpacing, and Kotlin default arguments are not exported, so aHiPayCardEntryStyle(...)built directly in Swift must now passfieldSpacing: nil(or a value). Building the theme by mutation, as above, needs no change — which is why it is the recommended path.Notes. A custom placeholder color applies from iOS 17 (iOS 15/16 keep the system gray). The default theme follows the host's light/dark appearance on its own — its colours are the system's semantic ones. A theme you build from a
HiPayCardEntryStylekeeps exactly the colours you gave it, in both appearances; adapting them is then yours to do.
Localization¶
The component follows the device language (fr/en/it; English fallback). The recommended way to
force a language is SDK-wide via HiPaySettings on the configuration:
let settings = HiPaySettings(localeOverride: nil) // the same shared type as Android/CMP
let config = HiPayConfiguration(username: user, password: pass, environment: .stage, settings: settings)
settings.setLocaleOverride(Locale(identifier: "fr")) // live, case-insensitive; nil = follow device
HiPaySettings is the same shared type across all platforms (Android/CMP/iOS) and is observable —
changing the locale re-localizes every card live, no re-init.
For a single surface you can still set the HiPayCardStrings.localeOverride static global once before
presenting — it wins over HiPaySettings:
One-click / saved cards¶
A returning payer pays with a card saved on a previous purchase — no card number, no security code. Off by default; nothing is stored and no card store is created until you enable it.
@StateObject var card = HiPayCardEntryController(
configuration: config,
oneClickEnabled: true,
// Optional (default 3, clamped 1...10): how many cards show before "Show more".
savedCardsDisplayCount: 3
)
Give the component a scrollable host. HiPayCardEntryView renders a plain VStack and never
scrolls on its own. With one-click enabled the payer can reveal every stored card at once via "Show
more" (up to 20 are kept), so the view can grow past a screen height. Put it inside a ScrollView —
otherwise "Show less", "New card" and your own Pay button end up off-screen with no way back:
A saved-card row handles a left-swipe of its own (to reveal the delete action), and it shares the gesture space with your scroll view: only a horizontal drag claims the row, so a vertical drag started anywhere — including on a card — scrolls the page as usual.
Offer to save on a successful payment — the component asks the payer for consent:
let tx = try await card.pay(/* … */, saveCard: true)
card.lastSaveOutcome // saved / notEligible / storageFailed
Paying with a saved card needs no new call. When the payer selects one, your existing pay(...)
routes through the stored token by itself, so your Pay button stays a single touch-point:
card.savedCards // [HiPaySavedCard]: maskedPan, network, holder, expiry
card.selectSavedCard(saved)
card.selectNewCard() // back to card entry
await card.deleteSavedCard(saved)
await card.refreshSavedCards()
payWithSavedCard(...) exists for headless hosts that drive the choice themselves.
How the payer deletes a card. A left-swipe or a long-press on a row reveals a trash affordance;
tapping the trash deletes, swiping the row back cancels. That is two deliberate steps, so there is no
confirmation dialog by default — pass confirmCardDeletion: true if your checkout wants one anyway:
@StateObject var card = HiPayCardEntryController(
configuration: config,
oneClickEnabled: true,
confirmCardDeletion: true // optional; off by default
)
The dialog is shown regardless of that flag when the deletion comes from the screen-reader "Delete card" action: that path is a single step, with no trash to aim at and no reverse swipe to undo it.
The payer's card list is filtered by the same account rules as a new entry: a stored card on a network your account no longer accepts is dropped from the list.
Only a token is stored — never the card number, never the security code. On iOS the token is held in the Keychain.
A stored token can stop being accepted: the card expired, was replaced, or the issuer revoked it.
The payment then fails with HiPayError.cardNoLongerValid, the card is dropped from the list, and the payer must enter a
card again. Handle that case explicitly — it is the one one-click failure that is not worth retrying.
3DS presentation (turnkey, default on)¶
By default pay(...) presents the 3DS challenge itself and returns the final,
server-confirmed transaction — you don't open a browser, parse the callback, or call
getTransaction. Two modes via the threeDS: parameter:
.inAppSession(default): an in-appASWebAuthenticationSession. It captures theyourscheme://redirect itself and auto-dismisses → no soft-lock, zero host wiring..externalBrowser(opt-out): the previous external-Safari behaviour. The SDK still confirms internally, but the return comes back through your app's URL scheme, so you must forward it once:
// Only needed for .externalBrowser; harmless otherwise. Register the scheme in Info.plist.
.onOpenURL { url in card.resume3DS(url) }
pay(..., threeDS: .externalBrowser) then suspends until that forward arrives and returns the
final tx. Both modes confirm via getTransaction under the hood (FR9 — redirect params are never
trusted). For full manual control, use the headless HiPayCore path instead (see below).
Abort & indeterminate returns. On any non-callback return (sheet/Safari dismissed without finishing), the SDK never assumes an abort — it reconciles with the server: a payment you actually completed comes back
.completed, a genuine abort stays.forwarding. If the server is unreachable during that check,pay()returns an indeterminate.pending("verification required") rather than a false abort or a thrown error — re-querygetTransactionlater to resolve it.A lost connection during the order is indeterminate too. If the connection drops while the order is in flight — an OS suspension during a long background does exactly that — the SDK cannot know whether the gateway took the payment, so
pay(...)returns.pendinginstead of throwing. Treat it as "to be verified", never as a failure: the payment may well have been captured. It stays listed by the recovery API below.
Interrupted payments¶
A payment can be interrupted in ways a callback cannot survive: the OS suspends your app during a 3DS challenge and drops the connection, or the process is killed outright. The SDK records what it launched, keyed on your own order id, so you can catch up afterwards.
let recovery = hiPayPaymentRecovery(configuration: configuration) // build it at launch — it is cheap
for payment in try await recovery.unresolvedPayments() { // reads the device, not the network
payment.orderId // the id YOU passed to pay()
payment.lastState // .pending while nothing final is known
payment.referenceKnown // false ⇒ the order was never answered
let refreshed = try await recovery.refreshPayment(orderId: payment.orderId, signature: signature)
_ = try await recovery.acknowledge(orderId: payment.orderId) // once you have recorded the outcome
}
You never handle a HiPay transaction reference: you ask with the id you created, and the SDK keeps the correspondence. Nothing here re-submits an order — the stored entry holds no card token, so it cannot.
An entry survives its own resolution. Only acknowledge(...) removes it, or its lifetime running
out — seven days for a payment left unanswered, forty-eight hours once final. Deleting on resolution
would lose the case this exists for: an app that dies between learning the outcome and recording it. At most fifty entries are kept, oldest first out, so a longer lifetime
cannot grow the cost of the payment path.
Both lifetimes are arguments of hiPayPaymentRecovery(...), so shorten them to test the flow.
Signing the read. An account that signs its orders refuses an unsigned transaction read with
401, so pass the HS signature your backend computed for that order. The snapshot carries the amount
and the currency precisely so it can be recomputed from the entry alone.
refreshPayment throws when the gateway cannot be reached. That says the question could not be asked,
never that the payment failed — the entry is kept, so retry later.
referenceKnown == falseis still recoverable. The HiPay reference only exists once the order has been answered, so an order whose response was lost has none — the SDK then finds the transaction from your own order id instead, and keeps the reference it gets back.This is a convenience, not the source of truth. Payment finality remains the server-to-server webhook. This API exists so your app can re-open the right screen and never conclude "failed" from an interruption.
First stage test — local signature (⚠️ STAGE / TEST ONLY)¶
The HS signature MUST be computed on your backend in production. Never ship the stage passphrase inside an app, and the SDK never computes signatures. The helper below exists only so you can get a first end-to-end stage payment from copy-paste, before wiring your backend. Delete it before release.
The hash algorithm must match your HiPay account (Stage ▸ Integration ▸ Security ▸ signature algorithm): this helper uses SHA-1 — if your account is set to SHA-256/512 the request is rejected (HTTP 401), so swap
Insecure.SHA1forSHA256/SHA512accordingly. The signed string must also exactly equal theorderId/amount/currencyyou pass topay(...)(e.g. amount"10.00", not"10"). This is the SDK's HS auth (Authorization: HS base64(username:signature)) — the signature replaces the password on the wire, so a wrong algorithm fails authentication.
import CryptoKit
import Foundation
// ⚠️ STAGE / TEST ONLY — replace with a backend call for production.
enum StageSignature {
// HiPay Stage account ▸ Integration ▸ Security ▸ "Secret passphrase".
private static let passphrase = "<your-stage-passphrase>"
// HiPay HS scheme: SHA-1(orderId + amount + currency + passphrase), lowercase hex.
static func compute(orderId: String, amount: String, currency: String) -> String {
let input = orderId + amount + currency + passphrase
return Insecure.SHA1.hash(data: Data(input.utf8)).map { String(format: "%02x", $0) }.joined()
}
}
Manual 3DS confirmation (headless / advanced — FR9)¶
Only if you bypass the turnkey presentation above (headless HiPayCore, or your own browser):
register the redirect scheme in Info.plist (CFBundleURLTypes → CFBundleURLSchemes =
yourscheme), then confirm with the captured reference:
import HiPayCore
.onOpenURL { url in
Task {
let cb = try HiPay.parseCallback(url) // orderId, status, queryParams
let reference = capturedReference ?? cb.queryParams["reference"] // prefer the captured (FR9)
let payment = HiPayPayment(configuration: config)
let tx = try await payment.getTransaction(reference: reference, signature: signature) // authoritative
// render .completed / .pending / .declined
}
}
A complete, runnable example is the demo at src/HiPay-SDK-ios-Demo (PaymentScreen.swift).
Crash reports and symbols¶
HiPayPayments.xcframework ships a .dSYM next to the framework in every slice. Xcode embeds and
signs the framework, and files its .dSYM in the dSYMs/ folder of your archive, beside your own.
Nothing to do. The usual upload paths — Crashlytics' upload-symbols, Sentry's Fastlane action,
a build phase — take the whole folder, so ours goes up with yours. Keep the archive: a report cannot
be symbolicated without the .dSYM of the exact build the payer was running.
A HiPay frame reads kfun:com.hipay.card.…, sometimes suffixed COROUTINE$0 or #internal. That is
the normal shape of a Kotlin/Native symbol, not a corrupted trace.
Reporting a crash inside the SDK. Open an issue
with the crash report — symbolicated if you have it, the raw .ips otherwise — and the SDK version.
HiPay archives a .dSYM per published version and symbolicates its own frames from it; your
application's .dSYM is never needed and must not be sent. The SDK embeds no crash reporter and
collects nothing on its own.
Upgrading to 1.2.0¶
BREAKING, Swift only: HiPayCardEntryStyle gained fieldSpacing. Swift code building the style with its memberwise initializer must pass it; building it by mutation needs no change. See Styling.
The framework is now dynamic and ships its debug symbols — see Crash reports and symbols. Xcode embeds and signs it as before; nothing to change.
A lost connection no longer reports a failure. The outcome comes back as pending: the SDK cannot know whether the gateway took the payment, and reporting a failure would be the one way it could make you refuse an order that was charged. If your code treats "not completed" as "not paid", change it to read the outcome — a pending payment is reconciled, not refused.
The entered card is cleared when a payment ends, whatever the outcome. A payer retrying after a refusal now re-enters the card; previously the fields stayed filled.
Deleting the selected saved card now selects the most recent card left, instead of dropping to the card-entry fields while other cards are still saved.
New: an interrupted payment can be found again — see Interrupted payments. Nothing to do to keep the old behaviour, but a payment left unresolved by a crash or a kill is now recoverable on your own order id.
New: the SDK reports each payment journey to HiPay, carrying your order id, HiPay's transaction reference, the amount and your application's identifier. Nothing to configure — but you have to declare it when you publish. See Privacy and store declarations.
The card fields now look locked while a payment is running, greyed out as they already were on the other platforms.
Upgrading from 1.0.0¶
One required change: the return deep link changed host. It is now
{yourScheme}://hipay-payments/gateway/orders/{orderId}/{status} — hipay-fullservice is gone. The
SDK builds and parses it for you on the turnkey path, so a card integration needs no edit. If you
build the five redirect URLs yourself against the headless core, read the host from
CallbackHostKt.HIPAY_CALLBACK_HOST or build the prefix with
CallbackHostKt.hipayCallbackBase(scheme:orderId:) rather than retyping it.
No source break and no behaviour change on iOS. One-click is no longer flagged experimental, and Apple Pay is new — see Apple Pay.
Notes¶
- Localization: FR/EN/IT (default EN) ship in the
HiPayCardresource bundle; device locale (no per-viewlocaleOverrideon iOS — that knob is Android-only). - Accessibility: VoiceOver labels/traits, relative sort priority (opt-out
setsAccessibilityOrder: false), inline errors announced politely, CVV tooltip. - PCI: the raw PAN and the vault token never leave the controller; never log card data.
- Privacy: card data leaves the device to be tokenized, and the SDK reports each journey to HiPay with your order id, transaction reference and amount. Both have to be declared when you publish — see Privacy and store declarations.
- Facade only (D4): integrate via
HiPayCore/HiPayCard— do notimport HiPayPayments(the raw KMP) directly.
Other integration paths: Android · Compose Multiplatform See also: Overview · Changelog · Report an issue