# Tokenization error codes

Every `wp:` failure returns one of seven codes. This page says what each one means, why it
happened, and what to change.

```json
{
  "error": {
    "type": "invalid_request_error",
    "code": "wp_token_expired",
    "message": "The card token has expired. Tokenize the card again.",
    "param": "payment_method"
  }
}
```

> **If you use the Payment Element, none of these are yours to handle.** The element
> tokenizes internally and manages its own retries. See
> [Payment Element](./08b-payment-element.md). This page is for integrations that build a
> `wp:` token themselves and call `POST /v1/charges`.

For the wider error envelope (declines, auth failures, rate limits) see
[Errors](./10-errors.md).

---

## The seven codes

| Code | HTTP | Retry the identical request? | One-line fix |
|------|:---:|:---:|-------------|
| [`wp_token_expired`](#wp_token_expired) | 400 | no | Seal a new token |
| [`wp_token_replayed`](#wp_token_replayed) | 400 | no | Seal a new token. One token, one charge. |
| [`wp_token_malformed`](#wp_token_malformed) | 400 | no | Refetch `/wp-config` and seal again, once. Then check your encryptor. |
| [`wp_token_audience_mismatch`](#wp_token_audience_mismatch) | 400 | no | Mint and charge against the same account and mode |
| [`wp_token_too_large`](#wp_token_too_large) | 400 | no | Do not add fields to the envelope |
| [`raw_pan_rejected`](#raw_pan_rejected) | 400 | no | Never send a raw PAN. Tokenize first. |
| [`wp_replay_store_unavailable`](#wp_replay_store_unavailable) | 503 | yes | Our outage. Retry with backoff. |

Six are `type: "invalid_request_error"`. `wp_replay_store_unavailable` is `api_error` and
is the only one where sending the exact same bytes again can work.

"Retry" in that column means *the identical request can succeed*. A **no** does not mean the
sale is lost. For an expired or replayed token, sealing a fresh one and charging again is
the correct recovery, and it should happen automatically. What must not happen is a loop
that resends the same token.

---

## Retry policy in one table

| Situation | Action |
|---|---|
| `429`, `5xx`, network failure | Retry with backoff |
| `wp_token_expired`, `wp_token_replayed` | Re-tokenize, then retry once |
| `wp_token_malformed` | Refetch `/wp-config`, re-tokenize, retry once |
| `wp_token_too_large`, `wp_token_audience_mismatch`, `raw_pan_rejected` | Never retry. Fix the integration. |
| `permission_denied`, `parameter_invalid` | Never retry |
| Hard declines (`pickup_card`, `fraudulent`) | Never retry the same card |

`wp_token_malformed` gets one retry rather than none for a specific reason: it is normally
a real defect, but it is also what a retired key looks like. One refetch-and-retry covers
the rotation case at negligible cost. A loop on it hammers us with requests that can never
succeed.

Always send an [`Idempotency-Key`](./12-idempotency.md) so a network-level retry cannot
double-charge.

---

### `wp_token_expired`

**Means:** the token was minted more than 10 minutes ago.

**Why it happens:** almost always sealing too early. Tokens queued on your backend before
the charge fires, a token minted at the start of a multi-step flow, or a shopper who left
the tab and came back.

**Do:** seal a new token from the same card data and charge with that. If you no longer
hold the card, re-collect it.

**Prevent:** seal immediately before the charge call, never before an intervening step. See
[the rail reference](./29-sdk-reference.md).

> A clock more than a few minutes off on the *encrypting* host produces this too. `iat`
> comes from your clock, so run NTP.

---

### `wp_token_replayed`

**Means:** this exact token was already used on a previous charge. Every token carries a
one-time id, and we record it for the token's whole lifetime.

**Why it happens:**

- Retrying a **declined** charge with the same token. The first attempt spent it even
  though it did not succeed.
- A retry loop, or a network timeout that fired the request twice where the first request
  actually landed.
- Reusing one token for two charges: a split payment, a retry at a different amount, an
  authorization plus a separate sale.

**Do:** seal a new token and charge again. Do not surface this to the shopper as a decline.

**Prevent:** treat a token as consumed the moment you put it in a request, whatever comes
back. For network-level retry safety, send an [`Idempotency-Key`](./12-idempotency.md): an
idempotent replay returns the original result instead of reaching the token check at all.

> **This is the code you are most likely to hit in production**, because the decline-retry
> path is easy to get wrong. Any "try again" path must re-tokenize.

---

### `wp_token_malformed`

**Means:** the token did not decrypt into a valid envelope. This is deliberately the
*only* answer for every crypto-level failure.

**Why it happens:** it is one of these, and the API will not tell you which.

- The string does not match `^wp:v1:[A-Za-z0-9+/]+={0,2}$`: truncated, whitespace, or
  URL-encoded in transit.
- **base64url instead of standard base64.** The body uses the `+/=` alphabet. A `-` or `_`
  in your token means you used the wrong encoder.
- The `info` string is not exactly `worthy-card-v1`.
- Wrong cipher suite, or `enc || ciphertext` concatenated in the wrong order.
- The envelope JSON has a missing required field, an extra field, or a malformed one:
  `exp` not `MM/YY`, `iat` in milliseconds instead of seconds, `jti` not 32 hex characters.
- The PAN fails Luhn or is not 13 to 19 digits.
- The ciphertext was altered in transit.
- The token was sealed to a key we have since retired. See
  [A note on retired keys](#a-note-on-retired-keys).

**Do:** refetch `/wp-config`, seal a new token, and retry **once**. That covers the retired
key. If it recurs, it is a defect in your encryptor: work
[the four steps](./28-card-tokenization.md#building-a-wp-token-yourself) top to bottom. The
base64 alphabet and the `info` string account for most of these.

> **The vagueness is intentional.** Distinguishing "wrong key" from "bad ciphertext" from
> "invalid PAN" would let an attacker use the endpoint as a card-validity oracle. All
> crypto failures return one code, at the same latency.

---

### `wp_token_audience_mismatch`

**Means:** the token was minted for a different merchant account than the one the charge is
billing.

**Why it happens:**

- A **test key on one side and a live key on the other**, the most common cause by far.
- An `aud` copied from another account, or a stale one after an account migration.
- A platform integration that resolves `aud` once and reuses it while charging on behalf
  of different sub-accounts.

**Do:** confirm the `aud` in the envelope is the `merchant_account_id` of the account the
charge bills, in the same mode. Keys are prefixed `pk_test_`, `pk_live_`, `sk_test_`,
`sk_live_` precisely so a mode mismatch is visible at a glance.

**Prevent:** derive `aud` from `GET /v1/ping` using the key you are about to charge with,
rather than storing it as configuration. Do not accept it as caller input: a wrong value is
only discovered after the card has already been encrypted.

---

### `wp_token_too_large`

**Means:** the token exceeded 512 characters, so it was rejected without an attempted
decrypt.

**Why it happens:** you added fields to the envelope, or `aud` is far longer than a
merchant account id. A card envelope is 180 to 260 characters, so there is a lot of
headroom and only extra data closes it. Cardholder name, address, metadata, an order id:
none of these belong inside the token.

**Do:** put everything except `pan`, `exp`, `cvv`, `iat`, `jti`, `aud`, `kid` on the charge
body instead, where it is documented and validated. See
[Charges](./03-charges.md).

> Extra fields fail twice over: over 512 characters this code, under it
> `wp_token_malformed`, because unknown keys are rejected. The envelope schema is closed.

---

### `wp_replay_store_unavailable`

**Means:** we could not reach the store that enforces one-charge-per-token.

**HTTP 503**, `type: "api_error"`. Unlike the other six, this one is not your request's
fault, and the shape says so.

**Do:** retry the same request with backoff. This is the only code here where retrying
unchanged can succeed: the token is still valid and still unused.

**Note:** we fail closed. Rather than skip the single-use check we refuse the charge, so a
degraded replay store never becomes a window in which a captured token can be spent twice.

---

### A note on retired keys

There is **no `wp_key_unknown`.** A token sealed to a key we no longer hold comes back as
[`wp_token_malformed`](#wp_token_malformed).

That is deliberate. The `kid` sits *inside* the sealed envelope, so the only way we learn
it names a retired key is by failing to open the token, which is indistinguishable from a
corrupt or tampered one. Reporting those cases differently would tell an attacker which of
them happened, and that difference is enough to turn the endpoint into a card-checking
oracle.

You should not hit this in practice. A rotation window is always longer than the one-hour
config cache, so a client that refetches on schedule never mints under a retired key. If
you see `wp_token_malformed` in a burst that starts right after a rotation, the cause is
almost certainly a **hardcoded** config. See [Key rotation](./31-key-rotation.md).

---

### `raw_pan_rejected`

**Means:** a plain card number reached the API. The request was rejected at the edge,
before it reached anything that processes payments.

**Why it happens:**

- Calling `POST /charges` with `card.number` set to a PAN, without tokenizing.
- Sending the raw card to your own backend and forwarding it.
- A variable wired to the wrong source, so the input value goes out instead of the token.

**Do:** tokenize. Every card must be a `wp:` or an `ev:` token before it is sent.

**There is no setting that allows raw PANs.** Not a permission, not a plan tier, not a
flag. The guard is unconditional, and it exists so that one bug on your side cannot put
your whole environment inside the card data environment. A value that is neither a raw PAN
nor a recognized token is rejected separately as `pan_encryption_required`.

The check runs before the token grammar, so `ev:4242424242424242` is `raw_pan_rejected`
rather than a well-formed token. Wrapping digits in a prefix does not get past it.

---

## Handling them in code

Server-side, around your own sealing function and the charge call:

```js
async function charge(card, amount, idempotencyKey) {
  let token = await sealCard(card);          // your encryptor

  try {
    return await postCharge({ token, amount, idempotencyKey });
  } catch (err) {
    switch (err.code) {
      // The token is spent or stale. A fresh one may well succeed.
      case 'wp_token_expired':
      case 'wp_token_replayed':
        token = await sealCard(card);
        return await postCharge({ token, amount, idempotencyKey });

      // Normally a defect, but also what a retired key looks like.
      // One refetch-and-reseal covers the rotation case.
      case 'wp_token_malformed':
        await refetchWpConfig();
        token = await sealCard(card);
        return await postCharge({ token, amount, idempotencyKey });

      // Integration bugs. Retrying changes nothing. Log loudly.
      case 'wp_token_too_large':
      case 'wp_token_audience_mismatch':
      case 'raw_pan_rejected':
        report(err);
        throw err;

      default:
        throw err;
    }
  }
}
```

Retry **once**, not in a loop. If a fresh token fails the same way, the fault is in the
integration and another attempt will not fix it. Reuse the same `Idempotency-Key` across
the retry so a request that actually landed is not charged twice.

> **Never show a `wp_token_*` code to a shopper.** Every one of them is a fault in the
> request, not a problem with the card. "Your card was declined" sends the shopper to
> another card that will fail in exactly the same way, and you lose the sale twice. Show a
> generic retry prompt and alert your own monitoring.
