Card tokenization (`wp:`)

View as Markdown

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.

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

RailWhat you sendChoose it when
ev:Separate Evervault tokens in card.number and card.cvvThe payment may step up to 3-D Secure. 3DS consumes this rail.
wp:One wp:v1: envelope in card.wp_tokenYou 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.

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.


What the token is#

PartValue
Prefixwp:, constant
Versionv1, the format version. A new crypto suite would be v2.
BodyStandard 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.

FieldTypeRequiredRule
panstringYesDigits only, 13 to 19 of them, Luhn-valid
expstringYesMM/YY, exactly this format
cvvstringNo3 or 4 digits. If absent, omit the field. Do not send "".
iatnumberYesIssued-at, unix seconds (not milliseconds)
jtistringYes32 hex characters, 128 random bits
audstringYesThe target merchant_account_id
kidnumberYesKey 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 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. 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.

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.


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.

Full list with causes and fixes: Error codes.