Storing a card means storing a reference to a card. The number lives with the processor, inside their compliance boundary; you keep an identifier and enough display metadata to show the customer what they picked. That division is what lets a small team bill recurring payments without taking on the obligations of handling card data.
The part that surprises people is that tokenizing a card is not, on its own, enough to reuse it.
A token starts out single-use
When the browser tokenizes a card you get back a payment method with customer_id: null. In that state it is good for one payment and it expires in roughly fifteen minutes. It is a handle on a card someone just typed, not a stored credential.
Attaching it to a customer is the conversion step:
# Just tokenized — single-use, expiring.
{ "id": "pm_7qOHcBHz0zBtIYrS37sOT", "customer_id": null,
"card": { "brand": "visa", "last4": "1111", "exp_month": 12, "exp_year": 2030 } }
PUT /v1/payment-methods/pm_7qOHcBHz0zBtIYrS37sOT/attach
{ "customer_id": "cus_yP8jSLsFD16WmiH1mW6rl" }
-> 200
# Attached — reusable indefinitely.
{ "id": "pm_7qOHcBHz0zBtIYrS37sOT", "customer_id": "cus_yP8jSLsFD16WmiH1mW6rl", … }Missing this step produces a characteristic bug: the first payment works, and the subscription created against the same token fails later with what looks like an invalid payment method. Nothing is wrong with the card — the token simply expired, because it was never promoted.
The whole sequence
- Browser collects the card in hosted fields and calls
createPaymentMethod, on submit rather than on page load. - Server creates the customer with
POST /v1/customers, if one does not exist yet. - Server attaches the payment method to the customer with the
PUTabove. - Server records the payment method id and its display metadata in your own database.
- Later charges reference the stored id — a payment intent, or a subscription.
// 2. The customer is the thing a card gets attached to, so it comes first.
const customer = await api.post("/v1/customers", {
email: user.email,
first_name: user.firstName,
last_name: user.lastName,
metadata: { user_id: user.id },
});
// 3. Promote the token. Without this, step 5 fails ~15 minutes from now.
await api.put(`/v1/payment-methods/${paymentMethodId}/attach`, {
customer_id: customer.id,
});
// 4. Store the reference and the display fields — never the number.
await db.paymentMethods.insert({
userId: user.id,
customerId: customer.id,
paymentMethodId: paymentMethodId,
brand: card.brand, // "visa"
last4: card.last4, // "1111"
expMonth: card.exp_month,
expYear: card.exp_year,
isDefault: true,
});What belongs in your database
| Field | Store it? | Why |
|---|---|---|
| Payment method id | Yes | The handle you charge against later |
| Customer id | Yes | Needed to attach further cards and to look the customer up |
| Brand, last four | Yes | So the customer can tell which card is which |
| Expiry month and year | Yes | Lets you warn before it lapses rather than discovering it on a failed charge |
| Cardholder name | Optional | Useful for support; it is personal data, so treat it as such |
| Full card number | No | Puts you in PCI scope for storage. The processor holds it |
| CVV | Never | Storing it after authorisation is prohibited by the network rules, encrypted or not |
Keeping card data out entirely
The rule is simple: the card number must never arrive at your server, not even in a request you immediately forward. A raw number posted to your own endpoint is inside your scope the moment it hits your TLS terminator, regardless of what you do next with it.
Hosted fields achieve this by not being yours. The inputs the customer types into are iframes served by the processor, positioned inside your layout. Your JavaScript can style the containers and read validation state; it cannot read the value. The card goes from the iframe to the processor, and your code receives a token.
const form = await payments.form({ payment_method_type: "card" });
// You own the containers. The processor owns what renders inside them.
form.createField("cardNumber").inject("#card-number");
form.createField("cardExpiry").inject("#card-expiry");
form.createField("cardCvv").inject("#card-cvv");
await form.build();
// Returns a token and display metadata. There is no API here that
// would hand you the number, which is the design working as intended.
const paymentMethod = await payments.createPaymentMethod({
type: "card",
billing_details: { name: "Ada Lovelace", address: { zip: "80021", country: "US" } },
});Charging a stored card later
Once attached, the payment method id is all you need. For a one-off charge, create a payment intent and confirm it server-side:
POST /v1/payment-intents
{ "amount": 2500, "currency": "usd", "payment_method_types": ["card"] }
-> 201 { "id": "pi_…", "status": "requires_payment_method" }
POST /v1/payment-intents/pi_…/confirm
{ "payment_method_id": "pm_7qOHcBHz0zBtIYrS37sOT" }
-> 201 { "status": "succeeded", "amount_received": 2500 }No browser involvement, because there is no card to collect. This is also the path for retrying a failed subscription payment, and for any charge you initiate rather than the customer.
Expiries, reissues and involuntary churn
A stored card is a decaying asset. Cards expire, get reissued after fraud, and get cancelled when someone changes bank. For a subscription business this is a leading cause of cancellations that nobody chose — the customer still wants the product and the payment simply stopped working.
- Store the expiry and warn before it lapses. You already have the month and year; a reminder a fortnight out costs nothing.
- Let customers store more than one card, and record which is the default. A single stored card is a single point of failure for the subscription.
- Treat an expired-card decline as a prompt, not a failure. Keep the subscription alive and ask for a new card rather than cancelling on the first decline.
- Ask your processor about account updater. Where it is available, issuers push reissued card details through automatically and the stored credential keeps working without the customer doing anything.
GOOD QUESTIONS
Frequently asked
What do I actually store when I store a card?+
An identifier, and display metadata. The payment method id lives in your database alongside the brand, last four digits and expiry month and year — enough to show the customer which card they picked and to charge it again. The card number itself stays with the processor, which is the entire point.
Why does a payment method need to be attached to a customer?+
A freshly tokenized payment method is single-use and expires in about fifteen minutes. Attaching it to a customer is what converts it into a durable, reusable one. Skip that step and any later charge or subscription references a token that has already expired.
Is it ever acceptable to send a raw card number to my own server?+
It puts your server inside PCI scope, which changes your compliance obligations substantially, so the answer is effectively no for almost every business. Use hosted fields so the card goes from the browser to the processor directly and your server only ever handles the resulting token.
Can I store the CVV for future charges?+
No. Storing the CVV after authorisation is prohibited by the card network rules, with no exception for encrypting it. That is also why a card on file charge does not include one — the stored credential stands in for it.
What happens when a stored card expires?+
The charge fails, usually with an expired-card decline. Account updater services, where available through your processor, can refresh the stored credential automatically when the issuer reissues the card. Without one you need to detect the decline and prompt the customer to re-enter the card.
Should I let a customer store more than one card?+
Yes, if you bill recurring. A single stored card is a single point of failure for the whole subscription, and expiries and reissues are routine. Attaching several payment methods to one customer and recording which is the default costs very little and removes a common involuntary-churn cause.
