# Card tokenization (`wp:`)

A card number must never reach WorthyPayments in the clear. `wp:` is a token format that
makes that true: the PAN, expiry and CVV are encrypted into one opaque string, and only
the gateway service can open it.

```
wp:v1:BFnYeY4F9PJP1DbNn9eWHc0g…
```

You send that string as `card.wp_token` on a charge.

> **If you use the Payment Element, you do not need this page.** The element tokenizes
> inside the iframe we serve and confirms the payment itself. Nothing on this page is
> something you would call. See [Payment Element](./08b-payment-element.md).

This page is for building a charge **outside** the browser element: a server that calls
`POST /v1/charges` directly, a native mobile app, or your own encryptor in any language
with an HPKE library.

> **Live since 20 August 2026.** The `wp:` rail is enabled on the live platform: key
> version `1` is provisioned, `GET /v1/wp-config` serves the public half, and every step
> on this page runs against production.  Confirm it for yourself before you build. One
> request, and the same check a year from now:
> ```bash
> curl -s -o /dev/null -w '%{http_code}\n' https://api.worthypayments.com/v1/wp-config \
>   -H "x-api-key: $WP_API_KEY"
> ```
>`200` is the rail as described here. A `503 wp_config_unavailable` would mean the key is
>not currently served and nothing on this page can run. It is not a transient error to
>retry past, so tell us if you ever see one.  The rail is opt-in on the client: the SDK
>tokenizes over `ev:` unless you ask for `tokenize(card, "wp")` (or `"both"`). If you
>would rather not build an encryptor at all, the [Payment
>Element](./08b-payment-element.md) tokenizes inside the iframe we serve and needs nothing
>from this page.

---

## Two rails, and which one you want

A card charge carries its card on one of two rails. They are not interchangeable.

| Rail | What you send | Choose it when |
|---|---|---|
| `ev:` | Separate Evervault tokens in `card.number` and `card.cvv` | The payment may step up to 3-D Secure. 3DS consumes this rail. |
| `wp:` | One `wp:v1:` envelope in `card.wp_token` | You are building a 2D charge and want no third-party hop. |

> **Warning:** `wp:` alone cannot run 3-D Secure. Opening a 3DS session requires an `ev:`
> token: the challenge provider cannot read our envelope. On a card-not-present sale that
> may step up, mint `ev:` as well and send whichever the moment needs. See
> [3-D Secure](./07-3ds.md).

Because PAN, expiry and CVV are sealed together, **there is no `wp:` CVV**. A standalone
`wp:` value on `card.cvv` is refused: nothing can produce one and nothing can open one.

> **Handling a PAN puts you in a wider PCI assessment.** Our iframe keeps you on SAQ A
> (~30 requirements); a card entered into your own page or passing through your server is
> SAQ A-EP (~139) or broader. `wp:` reduces where the PAN travels, it does not remove the
> obligation. Choose this rail because you cannot use the element, not to avoid scope.

Rail-by-rail parameters, envelope contents and failure modes:
[`wp:` rail reference](./29-sdk-reference.md).

---

## What the token is

| Part | Value |
|------|-------|
| Prefix | `wp:`, constant |
| Version | `v1`, the format version. A new crypto suite would be `v2`. |
| Body | Standard base64 (RFC 4648 §4, the `+/=` alphabet, **not** base64url) |

Grammar, identical in every implementation:

```
^wp:v1:[A-Za-z0-9+/]+={0,2}$
```

**Maximum length 512 characters.** A typical card token is 180 to 260. Anything longer is
rejected without an attempted decrypt.

The token is **single use** and **expires in 600 seconds (10 minutes)**. Both limits are
hard. `GET /v1/wp-config` returns `token_ttl_seconds: 600`; the gateway enforces that
same value. Mint as late as you can, immediately before the charge. A token is also bound
to the merchant account it was minted for: you cannot mint against one account and charge
another.

> **Every sealing produces a different token, even for the same card.** The encryption is
> non-deterministic by design. Do not cache tokens, deduplicate on them, or use one as a
> card identifier. It identifies nothing once spent.

---

## What goes in it

This is the JSON that gets encrypted. If you write your own encryptor, you build this
object yourself, and the field set is exact.

| Field | Type | Required | Rule |
|-------|------|:---:|------|
| `pan` | string | Yes | Digits only, 13 to 19 of them, Luhn-valid |
| `exp` | string | Yes | `MM/YY`, exactly this format |
| `cvv` | string | No | 3 or 4 digits. **If absent, omit the field.** Do not send `""`. |
| `iat` | number | Yes | Issued-at, **unix seconds** (not milliseconds) |
| `jti` | string | Yes | 32 hex characters, 128 random bits |
| `aud` | string | Yes | The target `merchant_account_id` |
| `kid` | number | Yes | Key version, from `/wp-config` |

**No extra fields.** An unrecognized key makes the whole token `wp_token_malformed`.

> `bin` and `last4` are **not** inside the envelope. Read them off the plain PAN before
> you encrypt, and send them as ordinary fields on the charge body. They are not
> recoverable from the token.

---

## Test environment

Test mode is selected by your key: a `*_test_*` key is test mode. Same engine, same
tokenization, no live acquirer. See [Testing](./15-testing.md) for the wider test-mode
guide.

### Test cards

⚠️ **The old list here was Stripe's, and NMI's sandbox refuses all of it.** A TEST charge
is posted to NMI's own sandbox now, which accepts **seven** card numbers and answers
everything else with `Bad Request. Please use a valid test card or test ACH account from
the documentation.` `4242 4242 4242 4242`, the `4000 …` decline family and the rest buy
nothing on either rail — including this one, because a `wp:` envelope carries a real PAN
to the same gateway.

The seven numbers, the AVS and CVV triggers, the amount that produces a decline, the
duplicate-transaction window and the sandbox's 3-D Secure cards are one table, in one
place: [Testing → Test cards](./15-testing.md#test-cards-verified-2026-09-06-against-nmis-sandbox).
Keeping a second copy here is what sent an integrator down a broken test card before.

CVV `123` works for all of them (`1234` for Amex), and any future expiry does.

### First charge from a terminal

You do not need a browser to prove the charge path works. `POST /v1/test/tokens` mints
tokens for a test card:

```bash
curl https://api.worthypayments.com/v1/test/tokens \
  -H "x-api-key: sk_test_xxx" \
  -H "Content-Type: application/json" \
  -d '{"number":"4111111111111111"}'
```
```json
{
  "object": "test_token",
  "card": { "number": "ev:test_token_pan_1111", "cvv": "ev:test_token_cvc_xxx", "last4": "1111" }
}
```

Then charge them:

```bash
curl https://api.worthypayments.com/v1/charges \
  -H "x-api-key: sk_test_xxx" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 2000,
    "currency": "usd",
    "card": {
      "number": "ev:test_token_pan_1111",
      "cvv": "ev:test_token_cvc_xxx",
      "exp_month": "12",
      "exp_year": "2030",
      "last4": "1111"
    }
  }'
# → { "id": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3", "object": "charge", "status": "succeeded", … }
```

> **Two fields, one label.** `number` takes a full test PAN; `card` takes a brand alias
> (`visa`, `mastercard`, `mc`, `amex`, `discover`, `jcb`, `diners`, `maestro`), and a brand
> word in `number` is read as the same alias. Either way the token is labelled with the
> card's **last four digits** — so `visa`, `mastercard`, `mc` and `amex` all mint
> `ev:test_token_pan_1111` and charge as Visa, because the sandbox numbers for those three
> brands end in the same four digits. A live key gets `400 test_mode_only`.

> The response needs no renaming: `card.cvv` comes back named the way both
> `POST /charges` and the Element confirm read it, so the whole `card` object goes
> straight through. It used to be emitted as `cvc`, which meant copying it verbatim
> silently dropped the CVV.

> This endpoint mints **`ev:`** tokens. It is the fastest way to exercise the charge,
> idempotency and webhook paths from a script, but it does not exercise the `wp:` path.
> For that, encrypt directly as below.

---

## Building a `wp:` token yourself

If you are writing a server-side integration, a native app, or an in-house encryptor in
Python, PHP, Java, Go, or anything else with an HPKE library, the format is fully
specified. Four steps.

### 1. Fetch the public key

```bash
curl https://api.worthypayments.com/v1/wp-config -H "x-api-key: pk_test_xxx"
```
```json
{
  "kid": 1,
  "public_key": "BFnYeY4F9PJP1DbNn9eWHc0g…",
  "alg": "HPKE-P256-HKDF-SHA256-AES256GCM",
  "info": "worthy-card-v1",
  "token_ttl_seconds": 600
}
```

`public_key` is a 65-byte uncompressed P-256 point in standard base64. `alg` is a fixed
string: if you do not recognize it, fail rather than guess. Take `info` from this response
and pass it into HPKE, rather than hardcoding your own copy.

Cache this for up to an hour (`Cache-Control: public, max-age=3600`), then refetch.
**Never hardcode the response.** See [Key rotation](./31-key-rotation.md).

The endpoint accepts a publishable key. The response is the public half of a platform-wide
keypair, not a per-merchant secret; what binds a token to your account is the `aud` claim
you put in the envelope.

### 2. Build the envelope

```json
{
  "pan": "4111111111111111",
  "exp": "12/30",
  "cvv": "123",
  "iat": 1754870400,
  "jti": "9f86d081884c7d659a2feaa0c55ad015",
  "aud": "mer_01H8XYZ…",
  "kid": 1
}
```

`iat` is unix **seconds**. `jti` is 32 hex characters from a cryptographic RNG, a fresh one
per token, never reused. `aud` is your merchant account id, which `GET /v1/ping` returns
as `merchant_account_id` for the key you are using. `kid` is copied from `/wp-config`. Omit
`cvv` entirely when you do not have one.

### 3. Encrypt with HPKE

```
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, exactly this string
```

Seal the JSON to the public key. Concatenate the 65-byte encapsulated key with the
ciphertext (`enc || ciphertext`) and base64-encode the result with the standard alphabet.

> **The `info` string must match byte for byte.** If it differs, decryption fails on our
> side with no explanation of why: you get `wp_token_malformed` and nothing more specific.
> That is deliberate. A distinguishable crypto error is a card-validation oracle.

A minimal Node implementation with `@hpke/core`:

```js
import { CipherSuite, DhkemP256HkdfSha256, HkdfSha256, Aes256Gcm } from '@hpke/core';

const suite = new CipherSuite({
  kem: new DhkemP256HkdfSha256(),
  kdf: new HkdfSha256(),
  aead: new Aes256Gcm(),
});

const raw = Uint8Array.from(Buffer.from(config.public_key, 'base64'));
const pk = await suite.kem.importKey('raw', raw, true);
const sender = await suite.createSenderContext({
  recipientPublicKey: pk,
  info: new TextEncoder().encode(config.info),
});

const envelope = JSON.stringify({ pan, exp, cvv, iat, jti, aud, kid: config.kid });
const ct = await sender.seal(new TextEncoder().encode(envelope));

const out = new Uint8Array(sender.enc.byteLength + ct.byteLength);
out.set(new Uint8Array(sender.enc), 0);
out.set(new Uint8Array(ct), sender.enc.byteLength);

const token = 'wp:v1:' + Buffer.from(out).toString('base64');
```

### 4. Charge

The envelope goes in **`card.wp_token`**. Leave `card.number` and `card.cvv` out
entirely: the CVV is already sealed inside, and a `wp:` value on `card.cvv` is refused.

```bash
curl https://api.worthypayments.com/v1/charges \
  -H "x-api-key: sk_test_xxx" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 2000,
    "currency": "usd",
    "card": {
      "wp_token": "wp:v1:BFnYeY4F9PJP1DbNn9eWHc0g…",
      "exp_month": "12",
      "exp_year": "2030",
      "bin": "411111",
      "last4": "1111"
    }
  }'
```

Read `bin` and `last4` off the plain PAN *before* you encrypt. They are not recoverable
from the token, and both fraud scoring and routing read them.

Confirming a payment intent takes the same envelope as a top-level `wp_token` field rather
than a `card` object. See [Charges](./03-charges.md).

---

## Rules that are enforced, not advisory

- **A raw PAN is rejected at the edge.** Sending `"number": "4242424242424242"` gets you
  `400 raw_pan_rejected`. There is no configuration that relaxes this.
- **One token, one charge.** A spent `jti` is `wp_token_replayed`. A failed charge still
  spends the token, so a retry has to re-tokenize.
- **Ten minutes.** Past that, `wp_token_expired`. The clock on the encrypting host sets
  `iat`, so run NTP.
- **Publishable keys belong in the browser; secret keys never do.** A `pk_` key can create
  charges with a freshly tokenized card, which is why domain locking matters. It cannot
  charge a saved card, capture or void. See
  [Integration hard rules](./27-integration-hard-rules.md).

Full list with causes and fixes: [Error codes](./30-error-codes.md).
