Migrating stored cards & recurring payments
For businesses that store customers' cards on the legacy platform and charge them again later — subscriptions, instalments or saved cards. This is the second part of a wider migration: first your checkout moves to the new platform using the guide for your integration, then — once that integration is built and live — your customers' stored cards move too, without anyone re-entering a card, in four phases that you control.
Who this guide is for
Follow this guide if your systems hold token values received when customers saved their cards on the legacy platform, and you charge those tokens again. The stored values look like one of these:
- a multi-use token:
tm_followed by letters and digits, e.g.tm_aBcDeFgHiJkLmNoPqRsTuVwXyZ012345 - a virtual card number: 16 digits starting with
991001, e.g.9910012837465190
It covers those tokens, the payments you make with them, and how new cards get saved from now on. It does not cover migrating your checkout itself — that is the first part of the project, with its own guide, and it must be finished before the token migration starts.
Why migrate, and what you gain
The legacy platform, including its token store, is retiring in early 2027. Tokens minted on it cannot be charged on the new platform directly — but they don't need to be re-collected either: Teya imports your stored cards, and each legacy token upgrades itself the first time you charge it.
What the migration involves
Four phases, in this order. The order is what makes it safe: at some point Teya takes a copy of your stored cards from the legacy platform, and any token created after that copy would be missing from it. So the first thing to stop is the creation of legacy tokens; the last thing to stop is their use.
Effort: a small development project — one new-card path, a one-line routing rule, and one field change for the cut-over. The phases can be weeks apart; there is no pressure on how long Phase 2 lasts.
Developer guide
The four phases in detail: what to build, the one routing rule, and what happens on the first charge of a legacy token. Token payments themselves are documented on Charge a Saved Payment Method; this page only covers what is specific to migrating.
Before you start
1. Prerequisites
- Your integration already takes card payments on the new platform, with credentials per store — see the migration overview if not.
- You can call
POST /v3/transactions/online— the direct API used for token payments. Read Card on File and Charge a Saved Payment Method first; the requests below are those pages' requests with one different payment method.
2. Agree the phase dates with your Teya contact
Phase 3 can only start after Phase 1 is genuinely done: a legacy token created after the export will not be in the import, and in Phase 4 it fails with an explicit "not migrated" error rather than falling back to the legacy platform. Your Teya contact also provides test-environment legacy-token fixtures and confirms when the legacy-token payment method is enabled for your environment.
3. Find the code you'll be touching
Search your codebase for the places that:
- create tokens on the legacy platform (
/api/token/single,/api/token/multi, SecurePay tokenisation) — Phase 1's scope - charge stored values (
tm_,991001) — Phase 2's routing and Phase 4's scope
4. Ground rules
store_id it was migrated for.token_response.token_id on every successful charge — it is the customer's permanent Teya token.What replaces what
| Legacy platform | New platform | What it means for you |
|---|---|---|
Multi-use token tm_… / virtual card 991001… | Teya token — a UUID in token_response.token_id | Charged with payment method TOKEN. Legacy values are accepted once more, as payment method LEGACY_TOKEN, to convert them (Phase 4). |
POST /api/token/single, /api/token/multi | store_payment_method: true on a SALE or a zero-amount ACCOUNT_VERIFICATION transaction | Tokenisation happens inside the payment; no separate call. See Card on File. |
POST /api/payment with a multi-use token | POST /v3/transactions/online with payment_method.type: "TOKEN" | Same endpoint as every other direct-API payment. See Charge a Saved Payment Method. |
| Recurring flag on the payment | initiator (MERCHANT / CUSTOMER) + recurring_payment_type (RECURRING / INSTALLMENT / CARD_ON_FILE) | MERCHANT = cardholder not present, no 3DS. CUSTOMER = cardholder present, 3DS applies and may challenge. |
| Token deletion | DELETE /v1/tokens/{token_id} → 204 | Delete the token when the customer removes a saved card. |
| Your own token database | Your own token database — now holding a Teya token (UUID) or, temporarily, a legacy value | The routing rule below keys on which one the customer has. |
Phase 1 — Saving new cards on the new platform
What: stop creating tokens on the legacy platform. From this day, when a customer saves a card, save it on the new platform and store the Teya token you get back.
Why: every card saved from now on is already migrated, and the export in Phase 3 will be complete.
A token is created by setting store_payment_method: true on a transaction — as part of a sale (type: "SALE"), or without charging the customer (a zero-amount type: "ACCOUNT_VERIFICATION", for an "add a payment method" flow). Either way the response carries a token_response:
{
"type": "ONLINE_TRANSACTION",
"online_transaction": {
"transaction_id": "5f7b1a3a-3b7a-4b1a-9b1a-3a3b7a4b1a9b",
"status": "SUCCESS",
"token_response": {
"token_status": "SUCCESS",
"token_id": "1e2d4f80-9c3e-4f5b-83aa-9e8d2b6a6c49",
"brand": "VISA",
"last4": "1234"
}
}
}
Store token_id against the customer. brand and last4 are for what you show them ("Visa ending 1234").
Things to get right:
- Token creation can fail while the payment succeeds:
token_status: "FAILED"with afailure_reason. Treat it as "saving the card didn't work", not as a payment problem. - A token is only kept when the payment succeeds. Don't store anything from a failed transaction.
- Disable legacy token creation everywhere — a forgotten code path undermines Phase 3.
Full reference: Card on File — storing the card.
Phase 2 — Two platforms side by side
What: existing legacy tokens keep being charged where they always were — on the legacy platform, unchanged. Customers who saved a card since Phase 1 are charged on the new platform, as an ordinary token payment.
Why: nothing is at risk while the import is prepared, and every card saved during this phase is already on the new platform.
curl -X POST https://api.teya.com/v3/transactions/online \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"store_id": "5bca0d99-3f47-4d91-9372-cf12b8ec50db",
"type": "SALE",
"initiator": "MERCHANT",
"amount": { "value": 10000, "currency": "GBP" },
"merchant_reference_id": "order_ABC123",
"payment_method": {
"type": "TOKEN",
"token": {
"token_id": "1e2d4f80-9c3e-4f5b-83aa-9e8d2b6a6c49",
"recurring_payment_type": "RECURRING"
}
}
}'
Your billing engine's routing rule for this whole phase is one line:
for each due charge:
customer has a Teya token (UUID) → new platform, payment method TOKEN
customer has a legacy value → legacy platform, unchanged
Two fields describe why you are charging a stored card:
| Field | Values | Meaning |
|---|---|---|
initiator | MERCHANT | The cardholder is not present — a recurring charge, an instalment, or a merchant-initiated card-on-file payment. No 3DS/SCA is performed. |
CUSTOMER | The cardholder is present (e.g. clicking "pay with saved card" on your site). 3DS/SCA is performed and may require a challenge. Default if omitted. | |
recurring_payment_type | RECURRING, INSTALLMENT, CARD_ON_FILE | The type of stored-card usage. CUSTOMER-initiated payments support only CARD_ON_FILE; MERCHANT-initiated payments support all three. |
Things to get right: amount.value is in the smallest currency unit (100 ISK = 100, 100 EUR = 10000). Send an Idempotency-Key per charge attempt.
Full reference: Charge a Saved Payment Method.
Phase 3 — Teya imports your stored cards
Once you've confirmed to your Teya contact that no new legacy tokens are being created, Teya takes the file of your stored cards from the legacy platform and imports it into the new one. Your customers never re-enter a card and you never touch card data. Keep the Phase 2 routing unchanged while the import runs; Teya confirms when it is complete.
Phase 4 — Moving legacy tokens to the new platform
What: with the import confirmed, retire the "legacy platform" branch of your routing. A legacy token is now charged on the new platform — same endpoint, same fields — with one different payment method:
"payment_method": {
"type": "LEGACY_TOKEN",
"legacy_token": {
"token_id": "tm_aBcDeFgHiJkLmNoPqRsTuVwXyZ012345",
"recurring_payment_type": "RECURRING"
}
}
What happens on first use. The first time each legacy token is charged on the new platform, it is upgraded in-line, inside that payment:
- The platform checks its mapping store for your store + this token. If a mapping already exists, the payment proceeds directly as a normal token payment and none of the following steps run.
- If not, the imported records are consulted under your merchant identity to find the stored card behind the token.
- The card details are retrieved — in memory, transiently; they never appear in responses, logs or events.
- A network token — a card credential issued by the card scheme itself — is created for that card, under your store.
- The mapping legacy token → network token is saved. Concurrent first charges of the same token are safe: one mapping wins and both payments use the same network token.
- Only then does the payment itself run — authorisation, persistence, webhook — using the network token.
Two properties of this design are worth knowing: the imported card record is never used to pay directly — it exists only to mint the network token, once; and if any step of the conversion fails, the payment fails with an explicit error (below) with nothing half-saved, so a later retry starts clean. Expect the first charge of each token to take a little longer than usual; every later charge skips the conversion entirely.
What comes back — and the one thing you must do with it. A successful charge returns 201, and the response carries the customer's new Teya token:
"token_response": {
"token_status": "SUCCESS",
"token_id": "9c1b6f2e-4a8d-4e0f-b3aa-2f7d1c5e8a90",
"brand": "VISA",
"last4": "1234"
}
The same token_response also appears on the transaction lookup and on the payment.succeeded.v1 webhook. Store the token_id, and from this customer's next charge on send it as payment_method.type: "TOKEN". Your routing rule becomes:
for each due charge:
customer has a Teya token (UUID) → payment method TOKEN
only a legacy value → payment method LEGACY_TOKEN,
store token_response.token_id on success
Things to get right:
- Each billing cycle converts every customer it touches. A legacy token always resolves to the same Teya token, so charging with the legacy value again simply returns the same
token_id— a lagging deploy or a webhook/response race does no harm. - Track the drain with one query: customers who still have only a legacy value. When that number flattens, what's left is customers you haven't billed since the switch; their tokens convert whenever you next charge them.
- Customer-present charges (
initiator: "CUSTOMER") may return202withredirect_htmlfor a 3DS challenge — handle it exactly as on Charge a Saved Payment Method. The conversion happens before the challenge, and the completed payment carries the sametoken_response.
Errors specific to legacy tokens
All errors use the API's standard shape — a code and a human-readable description. A payment that fails this way is never sent for authorisation: the customer is not charged and no webhook is emitted.
| HTTP | Description you'll see | Meaning | What to do |
|---|---|---|---|
400 | Legacy token must be either a tm_ prefixed token or a 16-digit virtual card PAN starting with 991001 | The value doesn't match the two valid shapes | Fix the stored value or the lookup that produced it — this was never a valid token. |
400 | Legacy token has not been migrated to a new token. Please contact support. | No imported record for your store + this token | Either the import (Phase 3) hasn't completed for this store, or the token was created after Phase 1 should have ended. Don't retry in a loop; flag it and check with your Teya contact. |
400 | Legacy token payment method is not enabled | The legacy-token flow isn't switched on for your environment yet | Ask your Teya contact about rollout status. |
500 | Error resolving legacy token | The first-use conversion failed part-way | Transient — safe to retry later. Nothing was half-migrated. |
The signal to watch at cut-over is the ratio of legacy-token failures to attempts. A burst of "not migrated" errors on day one almost always means Phases 3 and 4 happened in the wrong order — or that something, somewhere, is still creating legacy tokens.
Testing and rolling out
TOKEN charge. Then a full billing cycle, watching the failure ratio.Next steps
- Charge a Saved Payment Method - Token payments, 3DS challenges and the full request reference
- Card on File - Storing cards during a payment or with an account verification
- Webhooks - Receive and verify payment notifications
- Migration overview - The other migration guides
- API Reference - Full API endpoint documentation