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.