Card on file: customer-initiated (CIT) and merchant-initiated (MIT) charges
Charging a card you have already seen once is a two-part sequence. The first part is a customer-initiated transaction (CIT), where the customer is at checkout and the charge runs on a fresh token. The second part is a merchant-initiated transaction (MIT), which is an upsell or a later charge made without the customer present using the payment instrument returned by the CIT.
The rule that governs which one you send:
Customer present: send a fresh
token. No customer present: send the storedpaymentInstrumentId.
| Customer-initiated (CIT) | Merchant-initiated (MIT) | |
|---|---|---|
paymentInstruction | { "token": "<fresh token>" } | { "paymentInstrumentId": "<id>" } |
isMerchantInitiated | false | true |
redirectUrl | Strongly recommended (not enforced) | Not needed |
| 3DS | May trigger a challenge | Out of scope |
If you already attach tokens before charging:
That path still works, and nothing has been removed. The attach call creates the instrument as before, and the first charge against it succeeds. What fails is the next customer-present charge on that instrument: the CVV is spent, so the CIT declines. Merchant-initiated charges on the same instrument are unaffected, because they never needed a CVV.
Integrations that create one instrument per checkout will not have noticed. Integrations that reuse a stored instrument at checkout are already hitting this.
Moving to the flow below removes the extra call and the failure mode at once.
Why a CIT always uses a fresh token
The token carries the CVV the customer typed at checkout, and that CVV covers exactly one charge. What decides whether a stored instrument still works is how many charges it has taken, not how it was created:
- On an instrument created by a CIT, that single charge was the CIT itself, so a later customer-present charge declines immediately.
- On an instrument created by the attach call, the first charge succeeds and the next one declines.
Either way you cannot rely on a stored instrument for a customer-present charge. A genuine CIT always has a fresh, customer-entered CVV available, so there is no reason to use the instrument path for a CIT. This includes returning customers: someone standing at your checkout is customer-present, regardless of how many times you have charged them before.
The CIT is also the only leg that carries Strong Customer Authentication (SCA) / 3DS. An MIT has no cardholder present to authenticate, so it never produces a challenge.
isMerchantInitiated: trueunconditionally skips SCA:The flag alone decides this. Sending
truewith a fresh token on a card that would otherwise force a 3DS challenge still returns an approval withhas3ds: falseand noapprovalLink. The token's freshness does not re-enable authentication.Treat the flag as a deliberate declaration that no cardholder is present, not as a hint. Setting it on a customer-present charge silently bypasses 3DS, which for EU, UK, and Japan merchants means bypassing a regulatory requirement. See 3DS2 guide.
Step 1: Run the customer-initiated charge
Tokenize the card (FramePay or the tokenization endpoint), then send the token straight to POST /transactions. Do not attach it first (see When to use attach instead).
curl -X POST \
"https://staging-api.payments.ai/v1/public-api/organizations/${ORGANIZATION_ID}/transactions" \
-H 'Content-Type: application/json' \
-H "Authorization: ApiKey ${API_KEY}" \
-d '{
"type": "sale",
"customerId": "${CUSTOMER_ID}",
"currency": "USD",
"amount": 10,
"paymentInstruction": { "token": "${TOKEN}" },
"isMerchantInitiated": false,
"redirectUrl": "https://yoursite.com/{id}/{result}"
}'
The token is single-use and is consumed by this transaction. The response returns a paymentInstrument object whose id is the instrument you will use for MITs.
Do not also call the attach endpoint with this token:
POST /customers/{customerId}/payment-instrumentswith a token already spent on a transaction returns422. The transaction has already created the instrument for you. See Token lifecycle.
Step 2: Resolve the final result before storing anything
201 Created does not mean the customer was charged (see HTTP 201 does not mean logical success). A CIT that goes through a 3DS challenge returns a non-final state at create:
{
"data": {
"id": "txn_abc123",
"status": "waiting",
"result": "unknown",
"combinedStatus": "waiting",
"paymentLink": "https://3ds.example/..."
}
}
Resolve the final state one of three ways: read {result} on the redirectUrl the customer returns to, retrieve the transaction by ID, or handle the transaction-processed / transaction-declined webhook. See the 3DS2 guide for the full challenge flow.
Final result | What to do |
|---|---|
approved | Store paymentInstrument.id. The card is now usable for MITs. |
declined, abandoned, canceled | Discard the instrument. Re-tokenize to try again. |
unknown | Not a final state. Keep resolving; do not store the instrument yet. |
Step 3: Run merchant-initiated charges
Once the CIT is approved, charge the stored instrument without the customer present. No redirectUrl, no CVV, no 3DS.
curl -X POST \
"https://staging-api.payments.ai/v1/public-api/organizations/${ORGANIZATION_ID}/transactions" \
-H 'Content-Type: application/json' \
-H "Authorization: ApiKey ${API_KEY}" \
-d '{
"type": "sale",
"customerId": "${CUSTOMER_ID}",
"currency": "USD",
"amount": 10,
"paymentInstruction": { "paymentInstrumentId": "${PAYMENT_INSTRUMENT_ID}" },
"isMerchantInitiated": true
}'
Required: charge an MIT only after an approved CIT
A merchant-initiated transaction runs only on a payment instrument whose customer-initiated transaction reached
result: "approved". Resolve the CIT to its final result first, store the instrument only then, and charge only instruments that passed. This ordering is part of the flow, not an optional check.
Full sequence
Common mistakes
| Mistake | Result |
|---|---|
Charging a stored paymentInstrumentId while the customer is present (including a returning customer at checkout) | Works at most once, then declines like an ordinary payment decline: status: "completed", result: "declined", and cvvResponse.originalMessage: "No CVC/CVV provided, but was required". This is not a 400, so check result rather than catching an HTTP error. |
| Calling attach with a token already spent | 422 (UnprocessableContent), whether it was spent on a transaction or on an earlier attach. The reverse also fails: charging a token already attached returns PaymentInstrumentInvalidToken. |
Storing paymentInstrument.id from a CIT that returned result: "unknown" | Not a final state. A 3DS CIT is unknown at create; the charge may still decline. |
Firing the MIT before the CIT reached approved | Breaks the required ordering. Resolve the CIT to a final result first, and charge only instruments whose CIT was approved. |
Sending isMerchantInitiated: true on a customer-present charge | Skips SCA entirely (see the warning above). The charge is approved with has3ds: false and no approvalLink, even on a card that would otherwise force a challenge. |
When to use attach instead
POST /customers/{customerId}/payment-instruments is the subscription path: it turns its own fresh token into an instrument you pass to POST /subscriptions, which then activates on the hosted payment form. See Attach a token to a customer.
For one-off charges, card-on-file, and upsells, use the CIT flow on this page instead. The attach step is not part of it.