Migrating from `ev:` tokens

View as Markdown

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.

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:
Fieldscard.number and card.cvv, separately encryptedcard.wp_token, one envelope
Encrypt calls per cardTwoOne
3-D SecureRequired. The challenge provider reads this rail.Not possible on its own
Third-party hopEvervaultNone
ExpiryNo limit600 seconds (10 minutes)
ReuseReusableSingle use
Account bindingNot boundBound 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. Parameters and failure modes: wp: rail reference.


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.

All of these are in Error codes.


Migration steps#

  1. Implement the wp: envelope. Four steps in Card tokenization.
  2. Fetch /wp-config at runtime with a real one-hour TTL. Never pin kid or public_key. See Key rotation.
  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.