Storing a card for a wallet
Store a card without charging it by creating a save-only session from your server, mounting it, and binding the resulting payment token on the card-saved webhook rather than on what the browser reports.
A wallet stores cards before it charges them. The cardholder adds a card once, your application shows it in a list, and a charge happens later, sometimes much later, and sometimes with nobody watching.
That shape is different from a checkout. There's no amount, no order to reconcile against, and the moment that matters to your system isn't the moment the payer submits the form: it's the moment your server learns which stored payment method belongs to which wallet holder. This guide walks that pattern end to end. Your server opens a save-only session, the browser or app mounts it, and your server binds the resulting payment token from the webhook.
The rule underneath every step: your server creates the session and your server records the result. The browser renders the form and reports progress. It's never the record.
Step 1: Create the save-only session
Create the session from your server with the merchant's API key. Never from the browser: the key charges money, and a session created client-side can be created with an amount you didn't choose.
curl -X POST https://pay.your-environment.example/api/hostedpaymentpages/sessions \
-H "api-key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"hostedPageId": "00000000-0000-0000-0000-000000000000",
"saveCardOnly": true,
"expirySeconds": 900,
"correlationId": "wallet-add-card-8f21c4",
"parentOrigin": "https://wallet.example.com",
"presentationMode": "Embedded",
"idempotencyKey": "wallet-add-card-8f21c4"
}'
| Field | Why it's here |
|---|---|
saveCardOnly |
Runs a zero-dollar account verification instead of a payment, then vaults the card and captures consent. The session can't also carry a chargeable amount. |
expirySeconds |
How long the cardholder has. The bound comes from linkLifetime, which defaults to Session (the short single-use window). Send linkLifetime as Extended for a link you email or text and that's paid later, anywhere from 1 hour to 90 days. |
correlationId |
Your own identifier for this add-card attempt. It's echoed on the create response and on session reads, so it's how you recognize the session in your own logs and support tooling. Read Step 3 before you plan to match on it. |
parentOrigin |
The HTTPS origin of the page that embeds the session. Without it, no lifecycle events are posted at all. Omit it for a native app: see Hosting in a native app. |
idempotencyKey |
Makes the create safe to retry. Send the same key with the same body and you get the session you already opened back, instead of a second one. |
The response carries sessionId, shortToken, hppUrl, expiresAt, your correlationId, and idempotencyStatus.
Store sessionId against your pending wallet record now. It's the value that appears on the webhook, and binding depends on you having written it down before the cardholder submits anything.
Read idempotencyStatus rather than assuming the key worked. Create idempotency is a per-merchant switch. Until your provider enables it for the merchant, the key is accepted, reported as KeyIgnored, and a retry opens a second session. Reusing a key with a different body is refused with HostedPaymentPage:HppSession:CreateIdempotencyKeyReused and creates nothing.
Save-only needs processor support
The zero-dollar verification is a real processor capability, not something WinkPG simulates. A saveCardOnly session is rejected at creation with HostedPaymentPage:HppSession:SaveCardOnlyNotSupportedByProcessor when the merchant's processor doesn't advertise zero-dollar verification.
Handle that at create time as a configuration problem rather than a cardholder-facing error. There's no fallback to a small charge, and you shouldn't build one: a charge the cardholder didn't agree to is a different transaction with different rules.
Consent is always required on a save-only session
Vaulting a card takes a stored-credential consent record describing what the card may be used for later. On an ordinary checkout that prompt can be optional. On a save-only session it can't: the session exists only to store a credential, and the credential is stored only when the box is ticked, so an optional prompt would let the page finish on a success screen having stored nothing.
When you don't declare a usage scope, the scope defaults to UnscheduledCOF, which covers any later use with no fixed schedule. Declare the scopes you'll actually need with requestedCredentialStorage, because a later charge can't add one.
Step 2: Mount the session
Mount hppUrl with the embedded SDK exactly as you would a checkout session. The lifecycle events are the same, and the terminal event for this flow is card_saved rather than payment_succeeded.
card_saved carries the classification of the stored card:
| Key | Value |
|---|---|
paymentTokenPublicReference |
The pt_ handle for the stored credential. |
customerId |
The customer the credential was filed under. |
transactionId |
The zero-amount verification transaction. |
schemeTransactionId |
The card-scheme identifier a later merchant-initiated charge links back to. |
last4, brand |
Display values for the card you're about to show in a list. |
bin, funding, panLength |
The BIN-derived classification, so you can apply your own funding-type rules at save time without a second server call. |
status |
Always card_saved. |
Three things to know about that payload:
bin,fundingandpanLengthare present-but-null when BIN enrichment didn't resolve the range, andfundingis also null when the range resolved but the funding source didn't. Treat the funding set as open:Credit,Debit,PrepaidandChargeare today's values and a later one may appear.- Card expiry is absent by design. This envelope is delivered to a parent page, so expiry never crosses into it. It reaches your server on the webhook instead.
- Use it for display, not for the record. Render the stored payment method, dismiss the form, advance your UI. Don't treat the arrival of this event as proof the card is stored in a way you can charge: for that, see Step 3.
Tolerate event types you don't recognize instead of failing on them. The list can only grow.
Step 3: Bind the token on the webhook
Subscribe to HostedPaymentPage.CardSaved. It's a server-to-server delivery carrying the same save, and it's the one your wallet record should be written from.
{
"id": "HostedPaymentPage.CardSaved:8f21c4a9-2b17-4e63-a0dd-7c4e19b52f08",
"type": "HostedPaymentPage.CardSaved",
"version": 1,
"createdUtc": "2026-09-15T17:04:11.8820000Z",
"tenantId": "dd480b37-ba77-4338-bc19-0616aaf4903e",
"resellerId": null,
"merchantId": "6b1e4a55-9c3f-4f0a-8d4b-2f0b8a7e1d64",
"correlationId": "c0a81f3e-7b24-4c19-9f55-1d6e0a2b8c47",
"data": {
"MethodType": "card",
"PaymentTokenPublicReference": "pt_9fKq2ZmB7tLxW3aH5nR8cV1s",
"SessionId": "5d391d54-07c5-4676-9ae8-7e4a69d555de",
"HostedPageId": "9b7e2c5d-1a8e-4f30-8d4b-3f2a6c1e8d4b",
"HostedPageName": "Wallet card capture",
"VerificationTransactionId": "8f21c4a9-2b17-4e63-a0dd-7c4e19b52f08",
"CustomerId": "3f2a6c1e-8d4b-4f0a-9b7e-2c5d1a8e6f30",
"StoredCredentialConsentId": "1d6e0a2b-8c47-4c19-9f55-c0a81f3e7b24",
"CardLast4": "3391",
"CardBrand": "Visa",
"CardBin": "445673",
"CardFundingSource": "Credit",
"CardExpirationMonth": 12,
"CardExpirationYear": 2029
}
}
Parse the data members case-insensitively
The envelope's own members (id, type, merchantId, correlationId, data) are camelCase. The members inside data arrive Pascal-cased, as above, because a queued delivery stores its payload and rebuilds it as a property bag before serializing, and a naming policy doesn't rewrite a property bag's keys.
The test-send a portal user fires at an endpoint from the destination screen takes the same path and emits the same Pascal-cased members, so a mapping you prove against a test send is the mapping a real save needs. The surface to keep separate is the card_saved postMessage on the parent page, which is camelCase throughout. Don't point one parser at both.
Configure your JSON mapping to ignore case on this payload. That's one setting in every mainstream library, it costs nothing, and it keeps a handler working when a member is first met on another surface. This guide spells the members as the event sends them.
Match on data.SessionId, not on the envelope's correlationId
This is the step that's easiest to get wrong, so it's worth stating plainly.
data.SessionId is your binding key. It's the sessionId the create call returned in Step 1, and matching it against the pending wallet record you wrote there is what turns an anonymous save into a card that belongs to a person.
The envelope's top-level correlationId is a tracing value, and it isn't the correlationId you sent. WinkPG stamps that field from the request-scoped correlation identifier in effect when the payment page submitted, which is a page-load identifier belonging to the cardholder's browser session. Your session's correlationId is a property of the session (readable on the create response and on a session read), not something that rides along on this event. A wallet that keys its binding off the envelope's correlationId matches nothing.
The public reference is the chargeable handle
data.PaymentTokenPublicReference is the pt_ value you store against the wallet holder and send back later as tokenData.token. It's the only token identifier WinkPG emits: the internal vault identifier correlates to a card number and never leaves the platform.
data.CardExpirationMonth and data.CardExpirationYear are on this webhook and on no browser-facing surface. Card expiry reaches the merchant that owns the card through a server-to-server channel only, so this event is where a wallet gets it at save time, and the server-side token read below is where it refreshes it later.
The webhook can arrive before the browser event
WinkPG publishes the completion event before it posts the first lifecycle message to the parent page, and the order is intentional. A parent page that unmounted the frame on an earlier event would otherwise hold the publish behind a stranded interop call, and that wait would become your webhook latency.
So design for both orders:
- Treat the webhook as the write. Make the handler idempotent on
data.SessionIdand on the eventid. That id is derived rather than random: it's the event type joined to the save's own identity, preferring the verification transaction, then the session, then the token reference, so an at-least-once redelivery of the same save collapses onto one write. - Treat
card_savedin the browser as a UI signal. If your interface needs to show the new card immediately, render it from the envelope and let the webhook reconcile. - Never fail a save because the two arrived in an order you didn't expect.
Read token metadata from your server
When you need more than the webhook carried, resolve the token server-side. Send the public reference to the resolve endpoint with the merchant that owns it:
curl -X POST "https://pay.your-environment.example/api/tokens/resolve-payment-token-async?paymentTokenidentifier=pt_9fKq2ZmB7tLxW3aH5nR8cV1s&transactionMerchantId=6b1e4a55-9c3f-4f0a-8d4b-2f0b8a7e1d64" \
-H "api-key: $API_KEY"
The response carries the token's status, the customer it belongs to, whether token sharing is on, and a paymentDetails.cardData snapshot with expirationMonth, expirationYear, nameOnCard, last4CardNumber and the BIN classification. Card numbers and security codes are never returned from this surface.
Use it to refresh an expiry your wallet displays, or to confirm a token is still active before you offer it. Don't call it on every page render: the webhook already told you everything a list view needs.
Charging the card later
Every charge against a stored card has to declare who initiated it, and the answer changes what consent is consulted.
- The cardholder is present, choosing the stored payment method themselves: send
initiationTypeasCardholderInitiated, or leave it out. No consent is consulted, so this works for any active stored payment method. - The cardholder isn't present and you're charging on their behalf: send
initiationTypeasMerchantInitiated, amitReason, and the owning customer ininvoiceData.customerId. All three are required. The charge also needs a captured consent that permits that reason, or it's declined withstored_credential_consent_required.
Reusing a stored payment method with payment tokens covers both request shapes in full.
Consent belongs to the merchant that captured it
A consent record is captured for a specific merchant, so a merchant-initiated charge needs consent captured for the merchant doing the charging, not merely for the wallet that stored the card.
That distinction only bites once a card is usable at more than one merchant. If your wallet spans merchants, work out before you build which of these you're doing:
- One merchant stores and charges. Nothing extra to do. The consent captured at save time covers the later charges.
- A card saved at one merchant is charged by a sibling merchant under the same reseller. That takes token sharing, which a reseller opts in to, and the charging merchant still needs its own consent for a merchant-initiated charge. A cardholder-initiated charge doesn't. Sharing payment tokens across merchants covers the handle, the credential and the consent that takes.
A token is scoped to the merchant account that owns it. An API key for a different merchant can't resolve it and can't charge it, and that's a boundary, not a configuration gap.
Hosting in a native app
A native iOS, Android or Flutter app loads the payment page in a WebView, which is a top-level document. There's no parent window, so there's nowhere to post lifecycle messages. The native host channel closes that gap and delivers the same events, in the same shape, to a handler your app installs.
Two changes to the Step 1 call:
{
"hostedPageId": "00000000-0000-0000-0000-000000000000",
"saveCardOnly": true,
"hostChannel": "NativeWebView",
"expirySeconds": 900,
"correlationId": "wallet-add-card-8f21c4"
}
- Send
hostChannelasNativeWebView. - Send no
parentOrigin. A WebView load has no parent window, so an origin alongside this channel describes a delivery that will never happen. The request is rejected rather than ignored.
You don't need to send presentationMode: a native session renders the compact surface by default. The embedding allowlist and the frame-ancestors policy don't apply on this channel, and the commands you can send narrow to cancel and request_height.
Install one message handler named hppHost: a WKScriptMessageHandler on iOS, an @JavascriptInterface with a postMessage method on Android, or a JavaScript channel in Flutter. Switch on the envelope's type and treat card_saved as the terminal event, exactly as a browser integration does. Tear the handler down with the WebView.
Everything in Step 3 is unchanged. The channel moves the browser event to your app; it doesn't move the record. Your server still binds the token from the webhook.
Troubleshooting
| What you see | What's happening |
|---|---|
The session create is refused with SaveCardOnlyNotSupportedByProcessor |
The merchant's processor doesn't advertise zero-dollar verification, so there's no way to verify the card without charging it. Route the merchant to a processor that supports it. There's no fallback. |
No card_saved, and the page stays on the form |
The cardholder didn't tick the consent box. Consent is required on a save-only session and nothing is vaulted without it. The form is behaving correctly. |
session_expired or session_invalid before the cardholder submitted |
The window from expirySeconds elapsed, or the session was already consumed or revoked. Create a fresh session rather than retrying the URL: a session is single-use by default. For a link that's sent and used later, set linkLifetime to Extended and choose an expirySeconds inside that window. |
The webhook arrives before the browser reports card_saved |
Expected. The event is published before the first lifecycle message is posted, on purpose. Make the webhook handler the write and the browser event a UI signal. |
| The webhook arrives and nothing matches it | You're probably matching on the envelope's correlationId, or binding a camelCase mapping copied from the card_saved postMessage. Match on data.SessionId against the sessionId the create call returned, and parse the data members case-insensitively. |
| A token resolves for one merchant and not another | Tokens are scoped to the merchant account that owns them. Charging from a sibling merchant takes reseller-level token sharing, and a merchant-initiated charge needs consent captured for the charging merchant. |
A merchant-initiated charge is declined with stored_credential_consent_required |
The stored card has no active consent permitting that mitReason for the charging merchant. Consent is captured when the card is saved and can't be added afterward, so the card has to be saved again with the scopes you need. |
See also
- Reusing a stored payment method with payment tokens, for the charge request and the cardholder-present declaration in full.
- Sharing payment tokens across merchants, when a wallet spans more than one merchant.
- Embedded payments SDK, for mounting, the full event list, and the content security policy the SDK needs.
- Embedding a payment page in an iframe, for the iframe protocol underneath the SDK.
- Customers and saved payment methods, for what a stored payment method looks like to an operator in the application.