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 |
A method is stored through the CLP payment form — the card, bank account, Apple Pay and Google Pay options CLP shows in checkout. Your checkout configuration therefore has to route to CLP for anything to be stored.
Not every payment option can be kept on file. FlexPay, Pay by Bank, and a card
taken through a checkout configuration without CLP never store a method. Under
Enabled they are withdrawn from the session, even when your checkout
configuration enables them, so the customer is never shown a way to pay that
you could not charge again. Treat that set as one that may change rather than
as a fixed list.
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. The contact of such a session cannot be
changed afterwards with updatePaymentSession.
Enabled is refused unless the session's checkout configuration routes to CLP
in the session's currency, since CLP's form would be the only option left to
the customer. The same check applies when updatePaymentSession changes the
checkout configuration of an Enabled session.
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 CLP's
form offers the customer a "save my details" choice and every other option
stays in place, so a completed session may store nothing.
Read storePaymentMethodResult on the session to find out what happened. It is
set once CLP confirms the payment to ClientLoop, usually within seconds of the
customer finishing — so read it from the session (or after your
PaymentStatusChanged webhook), not the instant checkout reports success.
| Result | Meaning |
|---|---|
Stored |
The method was stored; paymentMethodId identifies it |
Declined |
The customer chose not to save their details under AskForConsent |
Refused |
The provider rejected the details; nothing was stored. Not reported by CLP's form today: a refused card can be retried in the same session, which stays Active until a payment succeeds |
NotAttempted |
Storing was Disabled, the session has not completed, or the customer paid with an option that cannot be stored |
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; the checkout-with-payment-success event reports a null
paymentId for the same reason, and the session is marked Completed once the
method is stored. createPendingPayment cannot be set on a zero-amount
session. 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.
Payment method ids carry a pmd prefix (pmd2Nc8xVQ1rL9mKZ4tYbWpEjHq) so they
are unique across the graph. Treat the id as opaque — do not parse it,
strip it, or depend on its shape.
Step 4: Charge a stored method (Server)
paymentMethodCharge 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 PaymentMethodCharge($input: PaymentMethodChargeInput!) { paymentMethodCharge(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.
A repeated key is answered from the payment the first call created: a charge
that was authorised returns that payment again, and one that was refused
returns success: false again. This holds even if the method has since been
deleted or has expired. A key reused for a different charge — another method,
amount, currency, model or invoice — is refused.
If the first call never got an answer, its payment is still Pending, and the
repeated key sends the same charge again; CLP recognises it and charges at most
once. That is possible for 72 hours after the key was first used. After that
the call fails, and the payment has to be checked and settled by hand. A repeat
that arrives while the first attempt is still being processed also fails, and
leaves the payment Pending.
Check success before reading payment. The method must be Active;
charging an Expired, Invalid or deleted method fails with an error rather
than a result. The currency must match the currency the method was stored
under, and the amount must be greater than zero.
An authorised charge returns its payment straight away with status
Pending. It moves to Completed when CLP captures it, which is when you
receive the PaymentStatusChanged webhook. A bank account debit is accepted
the same way and settles over the following days.
Each payment records the recurringProcessingModel it was declared under, so
a disputed charge can be answered from the payment itself.
The payment raises the same PaymentCreated and PaymentStatusChanged
webhooks as any other, delivered to the webhooks you configure in the portal.
callbackUrl is deprecated and not used.
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: the model it was stored under, plus
CardOnFile. 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, but the card networks may decline a merchant
initiated charge the customer never agreed to.
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.