`wp:` rail reference

View as Markdown

The reference for the wp: tokenization rail: what the envelope contains, what the rules on it are, and how to produce one without our browser SDK.

If you use the Payment Element, skip this page. The element seals the card inside the iframe we serve and confirms the payment itself. There is no tokenization step you write. See Payment Element.

This page is for building a charge outside the element: a server calling POST /v1/charges, a native app, or your own encryptor. The end-to-end walkthrough with runnable code is Card tokenization; this page is the contract behind it.


The rail in one line#

One HPKE envelope, containing PAN + expiry + CVV, sent as card.wp_token.

jsonc
{
  "amount": 2000,
  "currency": "usd",
  "card": {
    "wp_token": "wp:v1:BFnYeY4F9PJP1DbNn9eWHc0g…",
    "exp_month": "12",
    "exp_year": "2030",
    "bin": "424242",
    "last4": "4242"
  }
}

exp_month, exp_year, bin and last4 ride outside the token in the clear. The expiry is duplicated inside the envelope, which is intentional: the sealed copy is the one the gateway trusts, and the outer copy is what routing and display read without decrypting.

There is no wp: CVV field. The CVV is inside the envelope. A wp: value on card.cvv is rejected: the SDK cannot produce one and no service can open one.

On a payment intent, the envelope is a top-level wp_token field on confirm rather than a member of card. Pass exactly one instrument; two is refused, not resolved.


Envelope contents#

The plaintext that gets sealed. The field set is closed: an unrecognized key fails the whole token as wp_token_malformed.

FieldTypeRequiredRule
panstringYesDigits only, 13 to 19, Luhn-valid. Strip spaces yourself.
expstringYesExactly MM/YY. Zero-pad the month; take the last two digits of a 4-digit year.
cvvstringNo3 or 4 digits. Omit the key when absent. An empty string counts as present and fails.
iatnumberYesIssued-at in unix seconds. Date.now() is milliseconds; a raw value reads as the year 57000 and fails the TTL check.
jtistringYes32 lowercase hex characters, 128 bits from a CSPRNG. Fresh per token.
audstringYesThe merchant_account_id this token may be spent against.
kidnumberYesKey version, copied from the same /wp-config response as public_key.

bin and last4 are deliberately not in here. Read them from the plain PAN before encryption and put them on the charge body.

Resolving aud: GET /v1/ping returns merchant_account_id for the key you present. Derive it rather than accepting it as caller input. A wrong aud is only discovered after the card has already been encrypted, and it surfaces as an opaque wp_token_audience_mismatch.


The token on the wire#

PartValue
Prefixwp:v1:
Bodybase64std(enc || ciphertext), where enc is the 65-byte P-256 encapsulated key
Grammar^wp:v1:[A-Za-z0-9+/]+={0,2}$
Max length512 characters. A card token is normally 180 to 260.

Standard base64, the +/= alphabet. A - or _ in your token means you reached for base64url, and the grammar check rejects it before anything tries to decrypt.

Cipher suite, fixed:

HPKE (RFC 9180), base mode
KEM   : DHKEM(P-256, HKDF-SHA256)    0x0010
KDF   : HKDF-SHA256                  0x0001
AEAD  : AES-256-GCM                  0x0002
info  : "worthy-card-v1"             ASCII, byte for byte

info is part of the AEAD binding. One character of drift and every decrypt fails with wp_token_malformed and no further detail.


GET /v1/wp-config#

The public key and its version. Authenticates with a publishable or a secret key.

json
{
  "kid": 1,
  "public_key": "BFnYeY4F9PJP1DbNn9eWHc0g…",
  "alg": "HPKE-P256-HKDF-SHA256-AES256GCM",
  "info": "worthy-card-v1",
  "token_ttl_seconds": 600
}
FieldNotes
kidKey version. Goes into the envelope.
public_key65-byte uncompressed P-256 point, standard base64.
algThe suite string. Check it and refuse anything you do not implement.
infoThe HPKE info. Take it from here rather than hardcoding a copy.
token_ttl_seconds600 (10 minutes). The server expires the envelope at this value; you cannot extend it. Not 900 / 15 minutes.

The keypair is platform-wide, not per merchant. Publishing the public half is the design: anyone can mint a well-formed envelope, and what stops them charging your account is the aud binding, the single-use jti and the velocity controls behind it.

Cache the response for at most an hour (Cache-Control: public, max-age=3600), then refetch. Take kid, public_key and info from the same response, together. Never hardcode any of it. A pinned value breaks every card at once at the next rotation, and the failure looks like a corrupt-token bug. See Key rotation.

A 503 wp_config_unavailable means the key is not provisioned. It is retriable, and you must not cache it.


Two rules that break code written against ev:#

Single use. One token, one charge attempt. A charge that failed still spent the token, so a retry has to seal a fresh one. Sending a spent token is wp_token_replayed, which is not a second attempt at the card.

600 seconds (10 minutes). Wall-clock from iat, not per hop. If tokens sit in a queue on your side before the charge fires, budget for it. A job that retries an hour later needs a new token, which means going back to the card.

// Wrong: the card is collected, then the order is assembled, then the charge fires.
seal() -> queue -> enrich -> charge        // wp_token_expired somewhere in the middle

// Right: seal immediately before the call that spends it.
queue -> enrich -> seal() -> charge

ev: tokens have neither limit. That is exactly why code moved from ev: to wp: breaks at the retry path and at slow checkouts rather than on the happy path. See Migrating from ev:.

Never persist a raw PAN anywhere so you can re-seal later. If you cannot re-seal, re-collect the card.


Failure modes#

Failures split by where they happen.

Before the charge, while you are producing the token. Your own code owns these, and none of them reach us:

ConditionWhat to do
PAN fails Luhn or is not 13 to 19 digitsValidate before sealing; a bad PAN wastes a round trip
alg from /wp-config is not one you implementFail loudly at config-fetch time. Encrypting with the wrong suite produces a token nobody can open.
public_key or kid absentTreat as unavailable and retry; do not seal against a partial config
Sealed token exceeds 512 charactersYou put something oversized in aud, or added a field

If you are encrypting in a browser context of your own, Web Crypto requires a secure context: HTTPS, with localhost exempt. There is no safe fallback to sending the raw card, and the API rejects raw PANs regardless.

At the charge, from POST /v1/charges. Every one of these is a fault in the request rather than a problem with the card:

CodeHTTPMeaning
wp_token_expired400Older than 600 seconds (10 minutes)
wp_token_replayed400This jti was already spent
wp_token_malformed400Any crypto-level failure, reported uniformly
wp_token_audience_mismatch400Minted for a different merchant account
wp_token_too_large400Over 512 characters, rejected without decrypting
raw_pan_rejected400A plain card number reached the API
wp_replay_store_unavailable503Our outage. The only one where the identical request can succeed.

Causes, fixes and retry rules for each: Error codes.


Where the rail is decided#

The grammar of the value chooses which decryptor opens it, so the field you use is not cosmetic:

FieldAcceptsOpened by
card.wp_tokenwp:v1: onlyOur gateway service
card.numberev: onlyEvervault
card.cvvev: onlyEvervault

Putting a wp: envelope on card.number does not fall back gracefully. The two grammars are allowlisted per field and a mismatch is refused, which is the behavior you want: the alternative would be ciphertext forwarded to an acquirer as if it were a card number.

3-D Secure runs on the ev: rail. If a payment may step up, mint ev: tokens for it. See 3-D Secure.