Gift cards and co-payment
Gift cards, loyalty cards and vouchers are secondary methods: they draw a slice of the amount and a primary method pays the rest. Several can stack, and gift cards covering everything make the payment fulfilled with no primary.
Setup
| Product | PurseHeadlessCheckout. Illicado, Buybox, Ogloba, MyBeezBox, Easy2Play (gift card and voucher) and Maxxing loyalty ship in it |
| Install | Nothing. The seven issuers are registered in SecondaryConfigs.shared |
| Dashboard | The issuer activated on the entity as a secondary method |
| Project, keys | None |
| UI | You render the PAN and CVV fields yourself: there is no payment element for a secondary method |
Check the balance and apply
func applyGiftCard(pan: String, cvv: String?) async throws {
guard let giftCard = checkout.secondaryMethods.first(where: { $0.partner == .illicado }) else { return }
let rules = checkout.secondaryConfig(for: giftCard) // requiresCVV(pan:), validatePAN(_:), validateCVV(_:)
let token = try await checkout.getSecondaryToken(method: giftCard, pan: pan, cvv: cvv) // /init, reads the balance
try checkout.applySecondary(token) // as much as the order needs…
try checkout.applySecondary(token, amount: Decimal(2)) // …or a capped slice (splittable issuers only)
checkout.removeSecondary(token)
}
secondaryConfig(for:) gives you the issuer's rules to validate the form before calling the network: whether this PAN needs a CVV, the PAN and CVV formats, and the takePolicy that says whether a partial slice is allowed at all. getSecondaryToken validates locally first, then tokenizes and reads the balance:
| Code | Meaning |
|---|---|
SECONDARY_METHOD_WRONG_PAN_FORMAT | The PAN does not match the issuer's rule |
SECONDARY_METHOD_REQUIRED_CVV | This PAN needs a CVV: check rules.requiresCVV(pan:) before calling |
SECONDARY_METHOD_WRONG_CVV_FORMAT | The CVV does not match the issuer's rule |
FAILED_TO_INIT_SECONDARY_TOKEN | /init was refused |
SECONDARY_TOKEN_EMPTY_BALANCE | The card has no balance |
METHOD_NOT_IMPLEMENTED | No SecondaryConfig is registered for this (partner, method) |
applySecondary with an explicit amount throws PurseCheckoutError.invalidTake:
| Code | Meaning |
|---|---|
INVALID_TAKE_AMOUNT | Negative, above the balance, or above what is left to pay |
MAX_AMOUNT_REACHED | Above the issuer's maximum per payment |
USAGE_LIMIT_ERROR | The issuer's per-session usage limit is spent by other cards in the split |
remainingAmountToPay, amountSplit and isPaymentFulfilled follow every change. When gift cards cover the whole amount there is no primary element to drive, so submit the checkout directly:
if checkout.isPaymentFulfilled && checkout.remainingAmountToPay == 0 {
let result = try await checkout.submitPayment()
}
With a primary selected, element.submit() sends the gift cards together with it.
Keep the gift card
When the issuer lets the shopper choose, offer to keep the card for the next session:
if token.canBeRegistered {
try checkout.registerSecondary(token, registered: true, name: "gift one")
}
The decision is applied at /validate, and checkout.registrationOf(token) reads it back. Saving needs a wallet session in the client session, as for saved cards; the label rules are the same (1–25 alphanumeric or space characters, unique).
Saved gift cards
Cards kept earlier arrive on savedSecondaryTokens, their balance fetched in the background:
ForEach(checkout.savedSecondaryTokens, id: \.id) { card in
SavedGiftCardRow(
balance: card.balanceResolved ? card.balance : nil, // nil → "checking…"
unavailable: card.disabled != nil, // the issuer refused it this session
onUse: { do { try checkout.applySecondary(card) } catch { show(error) } },
onRemove: { checkout.removeSecondary(card) },
onRename: { name in Task { try await checkout.editToken(card, name: name) } }
)
}
applySecondary(_ token: SavedSecondaryToken) re-reads the checkout's current copy rather than the value you captured, and throws secondaryTokenizationFailed when the card is disabled, when its balance has not resolved yet, or when the balance is zero.
There is only one wallet operation for a SavedSecondaryToken: editToken(_:name:). deleteToken and setTokenFavorite have no overload for it, so you cannot delete a saved gift card individually; deleteAllTokens() removes every wallet token, saved gift cards included.
An issuer the SDK does not ship
Implement SecondaryConfig and register it once at start-up; the session still decides whether the method is offered.
struct AcmeVoucherConfig: SecondaryConfig {
let takePolicy = SecondaryTakePolicy.max
func requiresCVV(pan: String) -> Bool { false }
func validatePAN(_ pan: String) -> Bool { pan.hasPrefix("ACME-") }
func buildInitPaymentData(pan: String, cvv: String?) -> [String: JSONValue] {
["voucher_code": .string(pan)]
}
func buildValidatePaymentData(_ token: SecondaryToken) -> [String: JSONValue] {
["voucher_code": .string(token.pan)]
}
}
SecondaryConfigs.shared.register(
partner: .other("acme"), method: .other("voucher"), config: AcmeVoucherConfig()
)
Every other member is defaulted: parseToken reads the generic card shape, validateCVV accepts anything, shouldPoll is false, classifyInitError returns nil. Registering an existing key replaces the shipped config.
The ANCV Connect flow (the shopper confirms in the ANCV app while the checkout polls) ships on the web SDK only. The SecondaryConfig hooks shouldPoll and classifyInitError exist so an app can register such an issuer.
Troubleshooting
| Symptom | Cause |
|---|---|
getSecondaryToken throws SECONDARY_METHOD_REQUIRED_CVV | This PAN needs a CVV: check rules.requiresCVV(pan:) before calling |
applySecondary throws USAGE_LIMIT_ERROR | Too many cards of this issuer in the split |
| A card reads as empty although it has money on it | The balance is computed against the amount being asked for; a card applied when nothing is left to pay answers zero |
A saved gift card shows disabled | Its balance could not be read this session. Let the shopper enter it again |
applySecondary on a saved card throws SECONDARY_TOKEN_EMPTY_BALANCE | Its background balance lookup has not answered yet. Gate the "Use" button on balanceResolved |
registerSecondary throws TOKEN_NOT_REGISTERABLE | The issuer's save_token_mode forbids saving, or the card is already a saved instrument |
The card was applied with registered: true but is not in the wallet afterwards | registerSecondary records a decision locally and never checks the wallet: it travels to /validate as payment_preferences.registration, and a session created without a wallet_session has nowhere to save it |
getSecondaryToken throws METHOD_NOT_IMPLEMENTED | No config for this issuer. Register one with SecondaryConfigs.shared.register |