# `wp:` rail reference

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](./08b-payment-element.md).

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](./28-card-tokenization.md); 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`.

| 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.

```json
{
  "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](./31-key-rotation.md).

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:`](./32-migration-from-ev.md).

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](./30-error-codes.md).

---

## 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](./07-3ds.md).
