`wp:` rail reference
View as MarkdownThe 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.
{
"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.
| Field | Type | Required | Rule |
|---|---|---|---|
pan | string | Yes | Digits only, 13 to 19, Luhn-valid. Strip spaces yourself. |
exp | string | Yes | Exactly MM/YY. Zero-pad the month; take the last two digits of a 4-digit year. |
cvv | string | No | 3 or 4 digits. Omit the key when absent. An empty string counts as present and fails. |
iat | number | Yes | Issued-at in unix seconds. Date.now() is milliseconds; a raw value reads as the year 57000 and fails the TTL check. |
jti | string | Yes | 32 lowercase hex characters, 128 bits from a CSPRNG. Fresh per token. |
aud | string | Yes | The merchant_account_id this token may be spent against. |
kid | number | Yes | Key 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#
| Part | Value |
|---|---|
| Prefix | wp:v1: |
| Body | base64std(enc || ciphertext), where enc is the 65-byte P-256 encapsulated key |
| Grammar | ^wp:v1:[A-Za-z0-9+/]+={0,2}$ |
| Max length | 512 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.
{
"kid": 1,
"public_key": "BFnYeY4F9PJP1DbNn9eWHc0g…",
"alg": "HPKE-P256-HKDF-SHA256-AES256GCM",
"info": "worthy-card-v1",
"token_ttl_seconds": 600
}
| Field | Notes |
|---|---|
kid | Key version. Goes into the envelope. |
public_key | 65-byte uncompressed P-256 point, standard base64. |
alg | The suite string. Check it and refuse anything you do not implement. |
info | The HPKE info. Take it from here rather than hardcoding a copy. |
token_ttl_seconds | 600 (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:
| Condition | What to do |
|---|---|
| PAN fails Luhn or is not 13 to 19 digits | Validate before sealing; a bad PAN wastes a round trip |
alg from /wp-config is not one you implement | Fail loudly at config-fetch time. Encrypting with the wrong suite produces a token nobody can open. |
public_key or kid absent | Treat as unavailable and retry; do not seal against a partial config |
| Sealed token exceeds 512 characters | You 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:
| Code | HTTP | Meaning |
|---|---|---|
wp_token_expired | 400 | Older than 600 seconds (10 minutes) |
wp_token_replayed | 400 | This jti was already spent |
wp_token_malformed | 400 | Any crypto-level failure, reported uniformly |
wp_token_audience_mismatch | 400 | Minted for a different merchant account |
wp_token_too_large | 400 | Over 512 characters, rejected without decrypting |
raw_pan_rejected | 400 | A plain card number reached the API |
wp_replay_store_unavailable | 503 | Our 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:
| Field | Accepts | Opened by |
|---|---|---|
card.wp_token | wp:v1: only | Our gateway service |
card.number | ev: only | Evervault |
card.cvv | ev: only | Evervault |
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.
