# 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.

**Category:** Integration

**Last reviewed:** 15 September 2026

# Storing a card for a wallet

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.

```bash
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](#step-3-bind-the-token-on-the-webhook) 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](#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 Syntch 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`, `funding` and `panLength` are present-but-null when BIN enrichment didn't resolve the range**, and `funding` is also null when the range resolved but the funding source didn't. Treat the funding set as open: `Credit`, `Debit`, `Prepaid` and `Charge` are 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.

<!-- webhook-data-example: HostedPaymentPage.CardSaved -->
```json
{
  "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.** Syntch 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 Syntch 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

Syntch 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.SessionId` and on the event `id`. 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_saved` in 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:

```bash
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 `initiationType` as `CardholderInitiated`, 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 `initiationType` as `MerchantInitiated`, a `mitReason`, and the owning customer in `invoiceData.customerId`. All three are required. The charge also needs a captured consent that permits that reason, or it's declined with `stored_credential_consent_required`.

[Reusing a stored payment method with payment tokens](/help/guides/reusing-saved-cards) 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](/help/guides/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:

```json
{
  "hostedPageId": "00000000-0000-0000-0000-000000000000",
  "saveCardOnly": true,
  "hostChannel": "NativeWebView",
  "expirySeconds": 900,
  "correlationId": "wallet-add-card-8f21c4"
}
```

- **Send `hostChannel` as `NativeWebView`.**
- **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](#step-3-bind-the-token-on-the-webhook) 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](/help/guides/reusing-saved-cards), for the charge request and the cardholder-present declaration in full.
- [Sharing payment tokens across merchants](/help/guides/sharing-payment-tokens-across-merchants), when a wallet spans more than one merchant.
- [Embedded payments SDK](/help/guides/embedded-payments-sdk), for mounting, the full event list, and the content security policy the SDK needs.
- [Embedding a payment page in an iframe](/help/guides/hpp-iframe-integration), for the iframe protocol underneath the SDK.
- [Customers and saved payment methods](/help/guides/customers-and-saved-payment-methods), for what a stored payment method looks like to an operator in the application.

## See also

- [All documentation](https://devportal-simpay-sbx.winkpg.io/llms.txt): the machine-readable index of every public page on this site.
