Integration hard rules
View as MarkdownMistakes that silently wreck fraud scoring, double-charge, or open a hole. Read once before you ship. The same list lives in the reference checkout (View Source, plus the blue card on the page).
Using the Payment Element? Eight of these apply#
The other twelve govern server-built charges, sending a PAN rail, driving 3DS, passing the buyer's IP, spending a decision token. The element does all of that inside the frame, so those rules have nothing for you to get wrong. The ones that remain are yours:
| Rule | In one line |
|---|---|
| 4. Idempotency-Key | Required on intent create. Key it on the order, not the click |
| 10. Branch only on these outcomes | onSuccess / onError, nothing else |
| 11. Amounts | Integer cents. 4999 is $49.99, minimum 50 |
| 15. The browser integration is the Element | Intent on your server, mount in the page, the whole contract |
| 16. Never persist card data | Nothing card-shaped reaches your storage |
| 17. Lock publishable keys to your domains | Miss it and the iframe renders blank, with no error |
18. Branch on code | message is prose and gets reworded |
| 20. Don't show integration errors to shoppers | A request fault is not a declined card |
Rule 17 is the one that costs people their first hour, and rule 4 is the one that double-charges. Start there.
1. Client IP#
| Endpoint | Body ip? | What we use |
|---|---|---|
POST /v1/charges | No, ignored | The buyer's: the pk_ peer, or the device_session the buyer's browser created. A sk_ peer is recorded as your server, never scored |
POST /v1/check | Yes when you proxy | Body ip, else request peer |
- Payment Element. The confirm comes from the buyer's browser, so the peer is the buyer. Nothing to do.
- Server +
sk_charge. The peer is your egress IP, and a body IP will not fix that. We record it as your server's address, never as a buyer IP: it is not scored and never blocklisted, so a card-testing burst from one buyer cannot block your backend, and saved-card renewals (recurring: true) are safe with no allowlist or setting. What it costs is the buyer IP: send adevice_sessioncreated in the buyer's browser and the charge is scored on the buyer's IP and browser. A renewal has no buyer at a browser and is charged without one. - Server +
/check. Setipto the shopper's public IP, taken from a trusted proxy hop. Never your server IP, and never a raw left-mostX-Forwarded-Forthe client could have forged.
Full detail: Charges → Client IP, Fraud checks.
2. PAN never in cleartext#
card.number and card.cvv must be Evervault tokens (ev:…), or the card arrives as a
single wp: envelope in card.wp_token. Raw digits are rejected at the edge, and there
is no degraded path that accepts them.
This binds anyone who builds a charge server-side. If you use the Payment Element, it is already satisfied: the card is entered in our iframe and tokenized there, and your page never holds the digits to begin with.
3. 3-D Secure: session id only#
When a charge is stepped up, the Payment Element drives 3-D Secure and resubmits for you. The challenge opens inside the Element's frame. Nothing below is yours to wire.
Driving it yourself, server-side or from a native app, after status: "requires_action":
- Run the challenge.
- Resubmit the same charge with
three_ds.session_idadded and nothing else changed (athree_ds.requestyou sent the first time may stay).
A session that did not authenticate never charges: a failed one is a 402 (decline_code
three_ds_failed or three_ds_card_not_enrolled), an unfinished one a 409
three_ds_session_incomplete. Do not resubmit a failed session; start a new attempt.
Never post cavv, eci, cryptogram, or auth_value. We re-read the session
server-side, so a cryptogram supplied by the client is not trusted and cannot buy a
liability shift.
Do not send three_ds with a wallet charge. The wallet cryptogram already satisfies SCA
and the API rejects the combination.
Detail: 3-D Secure.
4. Idempotency-Key: required and stable#
Money-moving POSTs require Idempotency-Key (or body idempotency_key). That includes
creating a payment intent. Omit it and you get a 400 before anything is charged.
Use a stable key per logical payment, your order id for instance, not a fresh UUID on every click of Pay. A double-submit with two different keys is two charges, and the header is the only thing standing between you and that.
Detail: Idempotency.
5. /check and risk_decision_token are not pre-approval#
- Skipping
/checkdoes not weaken the charge path. Charge always runs the full engine. - A successful
/checkdoes not pre-approve money movement. risk_decision_tokenis optional. Once sent it is strict: amount, card and device must match. It can only reject a mismatch, and it never skips screening.
Detail: Risk decision token, Fraud checks.
6. Keys and modes#
pk_… | Browser only. Mounts the element, and belongs behind an allowed-domains list |
sk_… | Server only. Creates intents, refunds and reads data. Never in a frontend bundle |
| Execution mode | ATOMIC / FRAUD_ONLY / PAYMENT_ONLY lives on the key, not the body |
Merchant account and test/live env come from the key, not from request fields. An sk_
passed to the browser SDK is rejected outright with secret_key_in_browser.
Detail: Authentication, Integration modes.
7. Device signals fail open#
device.fingerprint and device.kount_session_id improve scoring. If CSP or the collector
fails, the charge still succeeds and you score on a weaker signal. That is the trap: empty
device data looks exactly like a healthy integration. Check CSP first.
The element attaches both signals without being asked. The two getters on the client are there for integrations that build the charge server-side.
Detail: Device data collection.
8. Card identity for the decision token#
On /check, send card_bin + last4 when you have them so the token can bind the
card. Omit them and that binding is not enforced later. Nothing else is skipped: the
engine still runs, you lose only that provenance check.
9. What we derive (sending these does nothing)#
On charge, do not try to set:
| Field | Source |
|---|---|
| IP | Request peer only |
| Whether the card is “new” / MIT | Server |
| Whether 3DS “really happened” | Session retrieve, not your claim |
| Merchant / env | API key |
10. Branch only on these outcomes#
With the Payment Element:
| Callback | Meaning | Your UI |
|---|---|---|
onSuccess, status: 'succeeded' | Money captured | Confirmation, but fulfil on the webhook |
onSuccess, status: 'processing' | Outcome unknown | Do not fulfil. Wait for charge.succeeded, or retrieve the intent until it settles |
onError, code: 'card_declined' | Declined | Leave the element mounted, let the buyer retry |
A decline never arrives on onSuccess and never throws. status on onSuccess is only
ever succeeded or processing, and processing is also the deliberate fallback for any
state we do not recognise. Treating it as a success is the one mistake that ships goods
that were never paid for.
Building the charge yourself:
| Response | Your UI |
|---|---|
status: "succeeded" | Receipt / success |
status: "requires_action" + next_action.three_ds.session_id | Run 3DS, then resubmit |
status: "processing" (payment intent confirm) | Outcome unknown. Do not fulfil and do not confirm again. Retrieve the intent (GET /v1/payment_intents/{client_secret}, never charges) until it is succeeded or requires_payment_method |
| HTTP 402 / card decline | Generic "could not process". Never show raw fraud reasons to the buyer |
| HTTP 400 | Fix the request (idempotency, token mismatch, validation) |
| HTTP 503 (token store) | Retry the charge without the token if needed; the engine still runs |
Full JSON shapes: Charges → Responses. Errors: Errors.
11. Amounts#
Always cents (integer). 4999 is $49.99. A decimal is rejected rather than rounded,
because 49.99 would otherwise charge 49 cents. Minimum charge 50 cents.
The browser never sends an amount. Your server names the price when it creates the
payment intent, and the page receives a client_secret that addresses that decision
rather than the decision itself. Sending amount or currency to confirm returns
parameter_unknown: both are refused, not ignored.
When the cart total changes, your server writes the new total to the intent:
curl -X PATCH https://api.worthypayments.com/v1/payment_intents/pi_… \
-H "x-api-key: sk_live_…" -d '{"amount": 7500}'
That route takes the pi_ id, not the client secret, and needs the server-only scope
payment_intents:create. Both are deliberate: the secret is in the buyer's page, so a
secret-addressed re-price would let the buyer move their own price. The secret does not
change, so a mounted element needs nothing. No update(), no remount.
Minting a second intent instead still works, and then you do hand the browser the new
secret with element.update({ clientSecret }). It costs you an unpaid intent left to
expire on every cart edit.
Apple Pay and Google Pay mount on the merchant page, directly above the card iframe.
The sheet total is read from the intent, so there is nothing separate to remount. They
may be off (wallet-config provider: "NONE"); that is the default.
12. HMAC (if enabled on the key)#
When HMAC is on for a secret key: sign the raw body with timestamp + nonce. Re-serializing JSON changes the signature. Fail-closed if replay store is down.
That is request signing: Authentication → Request signing (HMAC). Webhook delivery signatures are a different HMAC: Webhooks → Verifying a delivery.
13. A wp: token is single use and expires in 600 seconds (10 minutes)#
Both limits are hard. GET /v1/wp-config advertises token_ttl_seconds: 600; the
gateway expires the envelope at that same value. There is not a 15-minute window.
- Reusing a token gives you
wp_token_replayed. That includes after a decline: a failed charge still spends the token. - Older than 600 seconds →
wp_token_expired.
So: tokenize immediately before charging, and re-tokenize for every attempt. A "try
again" button that reuses the previous token is the single most common wp: integration
bug.
This is a rule for server-side integrators holding their own wp: tokens. The Payment
Element mints and spends a token per attempt inside the frame.
ev: tokens have neither limit, which is exactly why code written against ev: tends to
break here. See Migrating from ev:.
14. Never hardcode /wp-config#
Fetch it at runtime, cache for at most an hour, refetch. Pasting public_key or kid into
your source or environment means your integration breaks at the next key rotation, for
every card at once.
Full mechanism: Key rotation.
15. The browser integration is the Payment Element#
There is one way to take a card payment in a page: mount the Payment Element against a payment intent your server created.
1. YOUR SERVER POST /v1/payment_intents (sk_) -> client_secret
2. BROWSER wp.mountPaymentElement('#payment', { clientSecret, onSuccess })
The fields live in our iframe, so your scripts cannot read them. That is the condition for PCI SAQ A (~30 requirements) rather than SAQ A-EP (~139), and it is the reason the element exists.
The public client publishes seven methods: mountPaymentElement, mountExpressCheckout,
savedCards, ping, getFingerprint, getKountSessionId, getDeviceSession. Card
entry, saved cards and retries happen inside the frame. 3-D Secure is driven by the
element; the challenge opens inside the frame, which grows to fit it. Apple Pay and Google
Pay mount on the merchant page, directly above the card iframe (not inside it) and only
when GET /v1/wallet-config reports them on. mountExpressCheckout draws the same wallet
buttons with no card form, for a product page or a cart; a wallet hands back an encrypted
credential, never a readable card number, so it takes no card data onto your page.
Object.keys() on the client shows fifteen, and the difference matters. The other
eight (checkout, tokenize, createCharge, handle3DS, retrieveIntent,
confirmSetup, canMakeWalletPayment, mountWalletButton) exist at runtime because the
element's own frame reaches them through internals(). They are not API: TypeScript does
not declare them, nothing supports them, and they change without a version bump. Calling
tokenize or createCharge from your page puts card data back on your page and re-opens
the SAQ A-EP path this design exists to close. Counting the keys is not a way to discover
features.
Two paths avoid a card UI on your side entirely: a hosted checkout link you redirect to, and a server-to-server charge if you already tokenize cards on your own backend. The second puts you in SAQ A-EP. Choose it deliberately.
16. Never persist card data#
Not in localStorage, sessionStorage, a cookie, a hidden form field, your database, or
your logs. Hold raw card values in a variable for as long as it takes to tokenize, then
drop them.
If you need the card again for a retry after a decline, re-prompt for it. Do not stash it. With the element this is not your problem: the card stays in the frame, the frame stays mounted through a decline, and the buyer retries in place.
17. Lock your publishable keys to your domains#
Two gates share the same list and diverge on an empty one. Know which you hit:
| Surface | Empty allowed_domains | Non-empty, origin not listed |
|---|---|---|
| Payment Element framing | Fail closed: blank iframe (frame-ancestors 'none') | Blank iframe |
API DomainGuard (charge/confirm with Origin) | Fail open: no origin check | domain_not_allowed |
The Element path is the one that matters for checkout: you must have domains set or
the form never renders. Once a list is set, a request whose Origin (or Referer)
hostname is not on it is refused with domain_not_allowed on the API, and the element
stays blank. A publishable key that sends no Origin or Referer at all is also refused
on locked keys, since an origin-less request from a browser key is what a stolen key
replayed server-to-server looks like. Secret keys are exempt from that last part: they
are expected to make origin-less calls.
Matching is exact hostname, or a *.example.com wildcard covering exactly one label.
| Pattern | Host | Match |
|---|---|---|
*.example.com | www.example.com | yes |
*.example.com | example.com | no |
*.example.com | a.b.example.com | no |
Important: A wildcard does not cover your apex. If buyers check out at both
example.com and www.example.com, list both. A wildcard alone leaves the apex
refused with domain_not_allowed, and because the element renders blank rather than
erroring, it looks like the frame is broken rather than the allowlist being short.
Set Allowed domains in the key-creation dialog under Settings → Developers → API keys. Publishable keys require at least one hostname; without it the Payment Element frames as a blank iframe on purpose.
Change the list later without minting a key: Edit allowed domains (the globe icon) on the key's row in the same table. Rolling a key copies the existing list forward rather than replacing it.
Secret keys support an IP allowlist through the same route. It matches exact addresses; CIDR ranges are not supported on this path, so a wide egress range needs each address listed.
18. Branch on code, never on message#
message is human-readable prose and may be reworded at any time. code is the stable
contract. type gives you the category.
if (err.code === 'wp_token_expired') { /* … */ } // stable
if (err.message.includes('expired')) { /* … */ } // will break
19. Retry only what is retriable#
- Retry with backoff:
429,5xx, network failures. HonourRetry-After. - Retry once, after fixing state:
wp_token_expired/wp_token_replayed(re-tokenize). Alsowp_token_malformedonce. It is normally a real defect, but it is also what a retired key looks like, and one refetch-and-retry covers that case. Once, not a loop. - Never retry blindly:
wp_token_too_large,wp_token_audience_mismatch,raw_pan_rejected,pan_encryption_required,idempotency_error,permission_denied,account_not_activated,parameter_invalid. These are integration faults; the same request always fails. - Never retry the same card: hard declines (
pickup_card,fraudulent).
20. Don't show integration errors to shoppers#
Every wp_token_* code is a fault in the request, not a problem with the card. Showing
"your card was declined" is wrong and sends the shopper to a different card that will fail
identically. Show a generic retry prompt and alert your own monitoring.
21. Your checkout page must be HTTPS#
Card encryption uses WebCrypto, which browsers expose only in a secure context. On plain
HTTP crypto.subtle is absent, so sealing a card fails with insecure_context rather
than degrading to something weaker. localhost is exempt for development; everything else
needs TLS. The exemption is the page origin (Origin: http://localhost:…), not a loopback
address in a public URL query (?origin=http://localhost… is HTML 403 at the edge. See
Errors) loopback in the URL.
A page served over http is a problem before any of that: the card is in the DOM and the
encryption key arrived over a channel anyone on the path could have answered. Serve
checkout over TLS whether you mount the element or seal cards yourself. There is no
fallback worth having, since the API rejects raw PANs regardless.
22. Unknown fields are refused#
Top-level bodies are allowlisted. An extra key is 400 parameter_unknown (param
names the field), not a silent ignore. The names Stripe (and our other routes)
use are the usual miss:
| Route | Refused | Use |
|---|---|---|
POST /v1/charges | email, receipt_email, billing, order_reference | customer.email, billing_address, description / metadata |
POST /v1/payment_intents | order_reference, billing, a customer object | reference, receipt_email, customer as a cus_… id |
Confirm (…/confirm) | amount, currency, billing_address | Amount is on the intent. Billing is billing (email / name / address) |
POST /v1/subscriptions | plan_id | plan. customer is required |
Same envelope as a missing required field, different code. Branch on code.
See Charges and Errors.
Pre-launch checklist#
- No raw PAN is sent anywhere, from any surface.
- No card data is persisted, logged, or put in browser storage.
- The amount is set only by the server that creates the intent.
- The SDK bundle URL is the pinned
/sdk/v1/path, not the unversioned one. -
processingononSuccessdoes not fulfil the order; the webhook does. - A decline leaves the element mounted so the buyer can retry.
- Cart changes
PATCHthe existing intent from the server (the client secret is unchanged). -
/wp-configis fetched at runtime with a real cache TTL. - No
sk_key appears in any client bundle. - Publishable keys are domain-locked at mint (Settings → Developers → API keys → Allowed domains).
- Amounts are integer cents. A new charge is ≥ 50; a capture or refund amount, when sent, is a whole number ≥ 1.
-
Idempotency-Keyis sent on every intent, charge, capture, void and refund. - Charge / intent / confirm bodies use this API's field names (
billing_addressvsbillingvsreceipt_email), extra keys areparameter_unknown. - Error handling branches on
code. - The decline path is tested with an amount below 1.00 in test mode (
"amount": 99; the sandbox has no decline card), including the retry. - Checkout is served over HTTPS.
If you build charges server-side, also:
- Tokenization happens immediately before the charge, not ahead of time.
- Every retry path re-tokenizes, decline retries especially.
-
/checksends the shopper's IP, not your egress IP.
Then: Going live.
