# Migrating from `ev:` tokens

`ev:` and `wp:` are the two rails a server-built charge can use. This page is the
side-by-side for moving a charge you build yourself from one to the other.

> **If you use the Payment Element, this page is not for you.** The element picks the rail
> internally and switches without you doing anything. See
> [Payment Element](./08b-payment-element.md).

> **Both formats work, and `ev:` is not going away.** 3-D Secure requires it. There is no
> deadline here. Move when the rail suits the charge.

---

## Which rail for which charge

| | `ev:` | `wp:` |
|---|---|---|
| Fields | `card.number` and `card.cvv`, separately encrypted | `card.wp_token`, one envelope |
| Encrypt calls per card | Two | One |
| 3-D Secure | **Required.** The challenge provider reads this rail. | Not possible on its own |
| Third-party hop | Evervault | None |
| Expiry | No limit | **600 seconds (10 minutes)** |
| Reuse | Reusable | **Single use** |
| Account binding | Not bound | Bound by the `aud` claim |

Pick `ev:` when the sale may step up to 3-D Secure. Pick `wp:` for a 2D charge where you
want the card sealed directly to us.

---

## Side by side

### The token on the body

```jsonc
// ev: two per-field tokens
{ "card": { "number": "ev:abc123…EncryptedPanToken",
            "cvv":    "ev:def456…EncryptedCvvToken",
            "exp_month": "12", "exp_year": "2030",
            "bin": "424242", "last4": "4242" } }

// wp: one envelope, in its own field
{ "card": { "wp_token": "wp:v1:BFnYeY4F9PJP1DbNn9eWHc0g…",
            "exp_month": "12", "exp_year": "2030",
            "bin": "424242", "last4": "4242" } }
```

Note the field, not just the string. The envelope goes in **`card.wp_token`**, and
`card.number` / `card.cvv` are left out. Each field is allowlisted to one grammar, so a
`wp:` value on `card.number` is refused rather than routed. There is no `wp:` CVV: the CVV
is sealed inside the envelope.

`bin` and `last4` are unchanged. Read them from the plain PAN before encryption; they still
travel on the body.

### Validating a token in your own code

```js
// before
if (!token.startsWith('ev:')) throw new Error('not tokenized');

// after: accept both
if (!/^(ev:|wp:v1:)/.test(token)) throw new Error('not tokenized');
```

Widen the guard rather than replacing it, since you will still be minting `ev:` for 3DS.
The server enforces this regardless; the guard is a convenience.

### The encryption itself

```js
// ev: Evervault's SDK, their key, their config endpoint
const ev = await evervault.init(teamId, appId);
const number = await ev.encrypt(pan);
const cvv    = await ev.encrypt(cvvValue);
```

```js
// wp: HPKE to our public key, one envelope for the whole card
const config = await fetchWpConfig();          // GET /v1/wp-config, cached 1h
const token  = await sealCard({ pan, exp, cvv: cvvValue,
                                iat, jti, aud, kid: config.kid }, config);
```

Full specification: [Building a `wp:` token yourself](./28-card-tokenization.md#building-a-wp-token-yourself).
Parameters and failure modes: [`wp:` rail reference](./29-sdk-reference.md).

---

## The two limits that break working `ev:` code

`ev:` tolerated patterns `wp:` does not. Neither of these shows up on the happy path, which
is why they reach production.

**Retrying a failed charge with the same token.** Under `ev:` this worked, because the
token was still valid. A `wp:` token is spent by the attempt, whatever the attempt
returned, and the retry is `wp_token_replayed`. Seal a new token before every attempt.

**Sealing early.** Under `ev:` you could tokenize whenever and charge later. A `wp:` token
dies 600 seconds (10 minutes) after `iat`, so anything that sits between sealing and
charging (a queue, an enrichment step, a slow multi-step flow) eventually returns
`wp_token_expired`. Seal immediately before the charge call.

Account binding is the third difference: the envelope carries an `aud` claim naming the
merchant account, and charging a different one is `wp_token_audience_mismatch`. Derive
`aud` from `GET /v1/ping` with the key you are charging with, rather than storing it.

There is no unknown-key error to handle. A token sealed to a retired key comes back as
`wp_token_malformed`, deliberately, so it cannot be told apart from a corrupt token. See
[A note on retired keys](./30-error-codes.md#a-note-on-retired-keys).

All of these are in [Error codes](./30-error-codes.md).

---

## Migration steps

1. **Implement the `wp:` envelope.** Four steps in
   [Card tokenization](./28-card-tokenization.md#building-a-wp-token-yourself).
2. **Fetch `/wp-config` at runtime** with a real one-hour TTL. Never pin `kid` or
   `public_key`. See [Key rotation](./31-key-rotation.md).
3. **Send the envelope in `card.wp_token`**, and stop sending `card.number` and `card.cvv`
   on that charge.
4. **Widen any token-prefix check** in your own code to accept `wp:v1:` as well as `ev:`.
5. **Move sealing to charge time** if it currently runs earlier.
6. **Make retries re-seal.** Audit every path that charges twice from one card: retry
   buttons, background retry jobs, split payments.
7. **Keep `ev:` for anything that may step up to 3DS.** The rail is not optional there.
8. **Test the decline path**, not just the happy path. Charge an amount below 1.00 in test
   mode (`"amount": 99`; the sandbox has no decline card), run your retry, and confirm you
   get a decline rather than `wp_token_replayed`.

Step 8 is the one people skip and the one that catches the real bug.

---

## Running both at once

You do not need a flag day. Each charge names its own rail, so some traffic can be `ev:`
and some `wp:` from the same account in the same hour.

That means you can move one surface at a time: web checkout first, mobile app next,
recurring-billing backend last. Nothing coordinates them.

Saved cards and existing customers are unaffected. A card already stored against a
`payment_method` id is charged the same way regardless of which format tokenized it
originally. The format is a property of the transport, not of the stored card.

---

## Where `ev:` remains

`ev:` is not dead code, and it is not deprecated. It is the rail 3-D Secure runs on, and it
is the fallback path. Keeping your `ev:` handling intact costs nothing and you will still
need it.

If you have infrastructure that only accepts `ev:`-shaped strings (a validator, a stored
column with a length constraint, a log scrubber matching `^ev:`) check it before you send
`wp:`. `wp:v1:` tokens are typically 180 to 260 characters and capped at 512.

> A log scrubber that redacts `ev:` but not `wp:v1:` will start writing card tokens to your
> logs. They are single use and expire in ten minutes, so this is not a PAN disclosure, but
> fix it in the same change.
