Card tokenization (`wp:`)
View as MarkdownA 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 version1is provisioned,GET /v1/wp-configserves 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:curl -s -o /dev/null -w '%{http_code}\n' https://api.worthypayments.com/v1/wp-config \ -H "x-api-key: $WP_API_KEY"
200is the rail as described here. A503 wp_config_unavailablewould 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 overev:unless you ask fortokenize(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.
| 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 anev:token: the challenge provider cannot read our envelope. On a card-not-present sale that may step up, mintev: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#
| 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.
binandlast4are 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:
curl https://api.worthypayments.com/v1/test/tokens \
-H "x-api-key: sk_test_xxx" \
-H "Content-Type: application/json" \
-d '{"number":"4111111111111111"}'
{
"object": "test_token",
"card": { "number": "ev:test_token_pan_1111", "cvv": "ev:test_token_cvc_xxx", "last4": "1111" }
}
Then charge them:
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.
numbertakes a full test PAN;cardtakes a brand alias (visa,mastercard,mc,amex,discover,jcb,diners,maestro), and a brand word innumberis read as the same alias. Either way the token is labelled with the card's last four digits — sovisa,mastercard,mcandamexall mintev:test_token_pan_1111and charge as Visa, because the sandbox numbers for those three brands end in the same four digits. A live key gets400 test_mode_only.
The response needs no renaming:
card.cvvcomes back named the way bothPOST /chargesand the Element confirm read it, so the wholecardobject goes straight through. It used to be emitted ascvc, 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 thewp: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#
curl https://api.worthypayments.com/v1/wp-config -H "x-api-key: pk_test_xxx"
{
"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#
{
"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
infostring must match byte for byte. If it differs, decryption fails on our side with no explanation of why: you getwp_token_malformedand nothing more specific. That is deliberate. A distinguishable crypto error is a card-validation oracle.
A minimal Node implementation with @hpke/core:
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.
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 you400 raw_pan_rejected. There is no configuration that relaxes this. - One token, one charge. A spent
jtiiswp_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 setsiat, 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.
