Migrating stored cards & recurring payments

For partners whose merchants 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 integration moves to the new platform using the guide for it, then — once that integration is built and live for a merchant — that merchant's stored cards move too, without any customer re-entering a card, in four phases that you control per merchant.

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.

Where this sits in the migration
Step 1 — build and go live with your new-platform integration (Hosted Checkout, Embedded Components or the direct API) using the guide for your legacy integration: find it here. Step 2 — this guide: move the stored cards. Everything below assumes step 1 is done; if it isn't yet, start there and come back when your new integration is live.

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.

No customer re-enters a card. The import and the first-use conversion happen behind the scenes.
No bulk conversion on your side. Your token database migrates itself, charge by charge, as your normal billing runs.
No gap. Both platforms run side by side until you switch, so there is never a moment when a customer could fall through.
Higher authorisation rates. Converted cards are backed by card-scheme network tokens, which issuers approve more often than stored card numbers and which keep working when a card is reissued — so more recurring charges succeed, with fewer retries and failed renewals.
One kind of token. At the end, every customer has a Teya token and one API charges them all — see Card on File.

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.

1 New cards go to the new platform
2 Two platforms side by side
3 Teya imports your stored cards
4 Legacy tokens move on first 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.

Planning this across a merchant base
The phases run per merchant, and the import in Phase 3 is per merchant too. Agree phase dates with your Teya partner contact in batches, and make sure Phase 1 is complete for a merchant — every store, every code path, every background job — before you confirm it for import.

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

Tokens are resolved per merchant and store — charge a migrated token with the store_id it was migrated for.
Send legacy values exactly as stored; the format is validated strictly (max 64 characters, the two shapes above).
Store the returned token_response.token_id on every successful charge — it is the customer's permanent Teya token.
Put the Phase 4 cut-over behind a flag so rollback is a config change.

What replaces what

Legacy platformNew platformWhat it means for you
Multi-use token tm_… / virtual card 991001…Teya token — a UUID in token_response.token_idCharged 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/multistore_payment_method: true on a SALE or a zero-amount ACCOUNT_VERIFICATION transactionTokenisation happens inside the payment; no separate call. See Card on File.
POST /api/payment with a multi-use tokenPOST /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 paymentinitiator (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 deletionDELETE /v1/tokens/{token_id} → 204Delete the token when the customer removes a saved card.
Your own token databaseYour own token database — now holding a Teya token (UUID) or, temporarily, a legacy valueThe 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 a failure_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:

FieldValuesMeaning
initiatorMERCHANTThe cardholder is not present — a recurring charge, an instalment, or a merchant-initiated card-on-file payment. No 3DS/SCA is performed.
CUSTOMERThe 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_typeRECURRING, INSTALLMENT, CARD_ON_FILEThe 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:

  1. 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.
  2. If not, the imported records are consulted under your merchant identity to find the stored card behind the token.
  3. The card details are retrieved — in memory, transiently; they never appear in responses, logs or events.
  4. A network token — a card credential issued by the card scheme itself — is created for that card, under your store.
  5. 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.
  6. 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 return 202 with redirect_html for 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 same token_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.

HTTPDescription you'll seeMeaningWhat to do
400Legacy token must be either a tm_ prefixed token or a 16-digit virtual card PAN starting with 991001The value doesn't match the two valid shapesFix the stored value or the lookup that produced it — this was never a valid token.
400Legacy token has not been migrated to a new token. Please contact support.No imported record for your store + this tokenEither 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.
400Legacy token payment method is not enabledThe legacy-token flow isn't switched on for your environment yetAsk your Teya contact about rollout status.
500Error resolving legacy tokenThe first-use conversion failed part-wayTransient — 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

Phase 1 go-live: deploy the new-card path, verify a real saved card end to end in staging with test cards, then disable legacy token creation everywhere.
Phase 2 is a steady state, not an event. There is no pressure on its duration.
Phase 3: confirm to your Teya contact that legacy token creation has stopped; keep your routing unchanged until the import is confirmed.
Phase 4 cut-over: behind a flag. Canary first — one internal customer or a small slice of a billing run; verify the charge, the returned token, and a follow-up TOKEN charge. Then a full billing cycle, watching the failure ratio.
Wind down: when your legacy-value-only count flattens, agree with your Teya contact how long the imported data stays available, and clean dead legacy values out of your database at your leisure.
Need help?
Your Teya partner contact is the route for agreeing phase dates per merchant, confirming each import (test and production), test-environment legacy-token fixtures, rollout status of the legacy-token payment method, and the retention window for imported data. For integration questions, see Support.

Next steps