Stored Payment Methods
A stored payment method is a card or bank account kept on file against a contact, so you can charge it again later without the customer re-entering their details — for subscriptions, deposits and balances, repeat orders, or a "pay with my saved card" flow.
Storing a method always runs through checkout: the customer enters their details in the Checkout component, and ClientLoop exchanges them for a credential held at the payment provider. Card numbers and account numbers never reach your servers.
What you can and cannot store
| Stored? | |
|---|---|
| Card | Yes |
| Bank account | Yes |
| Consumer financing, such as FlexPay | No — an offer applies to a single purchase and cannot be kept on file |
Not every payment option can be kept on file. Any option that cannot be is withdrawn from a payment session that stores a payment method, even when your checkout configuration enables it — so the customer is never shown a way to pay that you could not charge again. FlexPay is one such option today; treat the set as one that will grow rather than as a single exception.
Step 1: Create a payment session that stores the method (Server)
Set storePaymentMethod on createPaymentSession. It takes three values:
| Value | What the customer sees | Payment options |
|---|---|---|
Disabled |
Nothing. The default. | All of them |
AskForConsent |
A choice: save my details, or not | All of them — the customer may decline and pay with one that cannot be stored |
Enabled |
A notice that their details are being kept | Only those that can be stored |
A stored method belongs to a contact, so contactId becomes required for
any value but Disabled — a contactRef (merchant-supplied contact details
that are not a platform contact) is not enough. Create the contact first with
createContact if you do not have one.
You also declare recurringProcessingModel here: how the method will be
charged later. See Choosing a recurring processing
model below.
curl -X POST https://GRAPH_URL \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"query": "mutation CreatePaymentSession($input: CreatePaymentSessionInput!) { createPaymentSession(input: $input) { id status amount currency link contactId storePaymentMethod recurringProcessingModel storePaymentMethodResult paymentMethodId } }",
"variables": {
"input": {
"orgId": "35dHMM4pFzIykUsys1CDyZ9Xtkz",
"contactId": "38crhYn0Rs9ssu1qYToJtpxiNga",
"amount": "4350.00",
"currency": "USD",
"storePaymentMethod": "Enabled",
"recurringProcessingModel": "UnscheduledCardOnFile"
}
}
}'The customer pays the 4350.00 as normal, and the method they used is kept on
file. storePaymentMethod may only be set when the session is created; it
cannot be changed with updatePaymentSession.
Step 2: Load checkout (Client)
Nothing changes. Render the session with the checkout component exactly as described in Checkout:
<checkout-session
payment-session-id="PAYMENT_SESSION_ID"
></checkout-session>Under Enabled, checkout tells the customer their details are being saved and
shows only the payment options that can be stored. Under AskForConsent it
offers the choice and leaves every option in place, so a completed session may
store nothing. Read storePaymentMethodResult on the session to find out what
happened:
| Result | Meaning |
|---|---|
Stored |
The method was stored; paymentMethodId identifies it |
Declined |
The customer chose not to save their details |
Refused |
The provider rejected the details; nothing was stored |
NotAttempted |
Storing was Disabled, or the session has not completed |
Storing a method without taking a payment
Set amount to "0.00" with storePaymentMethod: "Enabled". The customer's
details are validated with the provider and stored, and no payment is
taken. Use this to collect a card up front for a subscription that bills
later, or to put a backup method on file.
A zero amount requires Enabled rather than AskForConsent: a customer who
declined would leave the session with nothing at all to do.
curl -X POST https://GRAPH_URL \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"query": "mutation CreatePaymentSession($input: CreatePaymentSessionInput!) { createPaymentSession(input: $input) { id link paymentMethodId } }",
"variables": {
"input": {
"orgId": "35dHMM4pFzIykUsys1CDyZ9Xtkz",
"contactId": "38crhYn0Rs9ssu1qYToJtpxiNga",
"amount": "0.00",
"currency": "USD",
"storePaymentMethod": "Enabled",
"recurringProcessingModel": "Subscription"
}
}
}'The completed session has a paymentMethodId but no paymentId, because no
payment was made. Note that a zero-amount session is still a real
authorization: it can be refused, in which case storePaymentMethodResult is
Refused and nothing was stored, and where Strong Customer Authentication
applies the customer will be asked to authenticate even though nothing is being
charged.
Step 3: Read the contact's stored methods (Server)
Contact.paymentMethods is a
Relay connection: page forward
with first and after, backward with last and before, and treat cursors
as opaque. Deleted methods are omitted unless you ask for them with
includeDeleted.
curl -X POST https://GRAPH_URL \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"query": "query ContactPaymentMethods($id: ID!) { contact(id: $id) { id name paymentMethods(input: { first: 20 }) { edges { cursor node { id type status provider supportedRecurringProcessingModels card { brand last4 expirationMonth expirationYear funding } bankAccount { institutionName last4 accountType } createdAt } } pageInfo { hasNextPage endCursor } } } }",
"variables": { "id": "38crhYn0Rs9ssu1qYToJtpxiNga" }
}'type tells you which detail block is populated — Card or BankAccount.
status tells you whether the method is still chargeable:
| Status | Meaning |
|---|---|
Active |
Usable for new payments. |
Expired |
Past its expiration date. Ask the customer to store a new method. |
Invalid |
The provider no longer accepts the credential (lost card, closed account). |
Fetch a single method directly with the paymentMethod(id:) query when you
already hold its id, or with the Relay node(id:) query — PaymentMethod
implements Node, so it refetches like any other node.
Payment method ids carry a pmd prefix (pmd2Nc8xVQ1rL9mKZ4tYbWpEjHq) so they
are unique across the graph. Treat the id as opaque. The prefix is there so
the server can route the id to the right type; do not parse it, strip it, or
depend on its shape.
Step 4: Charge a stored method (Server)
chargePaymentMethod takes a payment against a method on file. There is no
customer interaction and no checkout component — this is a server-to-server
call. It returns a result rather than a payment, because a refusal is a normal
outcome here, not an error: expired cards, insufficient funds and issuer
declines all come back as success: false with the provider's reason in
message.
curl -X POST https://GRAPH_URL \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"query": "mutation ChargePaymentMethod($input: ChargePaymentMethodInput!) { chargePaymentMethod(input: $input) { success message payment { id status amount currency date paymentMethodId recurringProcessingModel } } }",
"variables": {
"input": {
"orgId": "35dHMM4pFzIykUsys1CDyZ9Xtkz",
"paymentMethodId": "pmd38crhYn0Rs9ssu1qYToJtpxiNga",
"amount": "129.00",
"currency": "USD",
"recurringProcessingModel": "Subscription",
"transactionRef": "SUB-2026-09-001",
"idempotencyKey": "SUB-2026-09-001"
}
}
}'Always send an idempotencyKey. A repeated call with the same key and
organization returns the payment created by the first call rather than charging
again, so a network timeout and retry cannot double-charge the customer. A
natural key is whatever you already use to identify the billing period or
order, as above.
Check success before reading payment. The method must be Active;
charging an Expired, Invalid or deleted method fails. The currency must
match the currency the method was stored under, and the amount must be greater
than zero.
Each payment records the recurringProcessingModel it was declared under, so
a disputed charge can be answered from the payment itself.
If you set callbackUrl, you receive the same PaymentCreated and
PaymentStatusChanged webhooks described in
Checkout.
Choosing a recurring processing model
Every stored method carries a recurringProcessingModel, declared when it is
stored and repeated on each charge. It tells the card networks what kind of
arrangement you have with the customer.
| Model | Who starts the payment | Schedule |
|---|---|---|
CardOnFile |
The customer, from your checkout | n/a |
Subscription |
You | Fixed interval |
UnscheduledCardOnFile |
You | No fixed interval |
The dividing line between the two you initiate is the schedule, not the
amount. A monthly invoice whose total changes every month is still a
Subscription, because the interval is what is fixed. Reach for
UnscheduledCardOnFile when there is no regular interval at all — a top-up
triggered by a balance falling below a threshold, or an invoice raised whenever
a job is finished.
The choice also decides how a later payment is treated for Strong Customer
Authentication: Subscription and UnscheduledCardOnFile charges are ones you
initiate and fall outside it, while a CardOnFile charge does not. In exchange,
storing a method under Subscription or UnscheduledCardOnFile requires the
customer to authenticate during the session that stores it.
A method reports what it can be used for in
supportedRecurringProcessingModels. Read it before offering a customer a
subscription against a method that may not carry one. A later charge does not
have to use the model the method was stored under.
Step 5: Delete a stored method (Server)
deletePaymentMethod releases the credential at the provider and marks the method deleted.
curl -X POST https://GRAPH_URL \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"query": "mutation DeletePaymentMethod($input: DeletePaymentMethodInput!) { deletePaymentMethod(input: $input) { id deletedAt } }",
"variables": {
"input": {
"orgId": "35dHMM4pFzIykUsys1CDyZ9Xtkz",
"id": "pmd38crhYn0Rs9ssu1qYToJtpxiNga"
}
}
}'The record is kept in a deleted state so payments already made from it stay
attributable, but it drops out of Contact.paymentMethods and can no longer be
charged. Deleting a method that is already deleted succeeds and returns it
unchanged, so a retry is safe.
Give your customers a way to remove a stored method — it is a common requirement of card-network and consumer-protection rules, and of the agreements you make with them when you keep their details on file.