Tokenization error codes

View as Markdown

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


The seven codes#

CodeHTTPRetry the identical request?One-line fix
wp_token_expired400noSeal a new token
wp_token_replayed400noSeal a new token. One token, one charge.
wp_token_malformed400noRefetch /wp-config and seal again, once. Then check your encryptor.
wp_token_audience_mismatch400noMint and charge against the same account and mode
wp_token_too_large400noDo not add fields to the envelope
raw_pan_rejected400noNever send a raw PAN. Tokenize first.
wp_replay_store_unavailable503yesOur 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#

SituationAction
429, 5xx, network failureRetry with backoff
wp_token_expired, wp_token_replayedRe-tokenize, then retry once
wp_token_malformedRefetch /wp-config, re-tokenize, retry once
wp_token_too_large, wp_token_audience_mismatch, raw_pan_rejectedNever retry. Fix the integration.
permission_denied, parameter_invalidNever 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 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.

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

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

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.

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.


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.