Integration hard rules

View as Markdown

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

RuleIn one line
4. Idempotency-KeyRequired on intent create. Key it on the order, not the click
10. Branch only on these outcomesonSuccess / onError, nothing else
11. AmountsInteger cents. 4999 is $49.99, minimum 50
15. The browser integration is the ElementIntent on your server, mount in the page, the whole contract
16. Never persist card dataNothing card-shaped reaches your storage
17. Lock publishable keys to your domainsMiss it and the iframe renders blank, with no error
18. Branch on codemessage is prose and gets reworded
20. Don't show integration errors to shoppersA 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#

EndpointBody ip?What we use
POST /v1/chargesNo, ignoredThe 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/checkYes when you proxyBody 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 a device_session created 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. Set ip to the shopper's public IP, taken from a trusted proxy hop. Never your server IP, and never a raw left-most X-Forwarded-For the 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":

  1. Run the challenge.
  2. Resubmit the same charge with three_ds.session_id added and nothing else changed (a three_ds.request you 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 /check does not weaken the charge path. Charge always runs the full engine.
  • A successful /check does not pre-approve money movement.
  • risk_decision_token is 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 modeATOMIC / 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:

FieldSource
IPRequest peer only
Whether the card is “new” / MITServer
Whether 3DS “really happened”Session retrieve, not your claim
Merchant / envAPI key

10. Branch only on these outcomes#

With the Payment Element:

CallbackMeaningYour UI
onSuccess, status: 'succeeded'Money capturedConfirmation, but fulfil on the webhook
onSuccess, status: 'processing'Outcome unknownDo not fulfil. Wait for charge.succeeded, or retrieve the intent until it settles
onError, code: 'card_declined'DeclinedLeave 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:

ResponseYour UI
status: "succeeded"Receipt / success
status: "requires_action" + next_action.three_ds.session_idRun 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 declineGeneric "could not process". Never show raw fraud reasons to the buyer
HTTP 400Fix 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:

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

SurfaceEmpty allowed_domainsNon-empty, origin not listed
Payment Element framingFail closed: blank iframe (frame-ancestors 'none')Blank iframe
API DomainGuard (charge/confirm with Origin)Fail open: no origin checkdomain_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.

PatternHostMatch
*.example.comwww.example.comyes
*.example.comexample.comno
*.example.coma.b.example.comno

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.

js
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. Honour Retry-After.
  • Retry once, after fixing state: wp_token_expired / wp_token_replayed (re-tokenize). Also wp_token_malformed once. 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:

RouteRefusedUse
POST /v1/chargesemail, receipt_email, billing, order_referencecustomer.email, billing_address, description / metadata
POST /v1/payment_intentsorder_reference, billing, a customer objectreference, receipt_email, customer as a cus_… id
Confirm (…/confirm)amount, currency, billing_addressAmount is on the intent. Billing is billing (email / name / address)
POST /v1/subscriptionsplan_idplan. 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.
  • processing on onSuccess does not fulfil the order; the webhook does.
  • A decline leaves the element mounted so the buyer can retry.
  • Cart changes PATCH the existing intent from the server (the client secret is unchanged).
  • /wp-config is 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-Key is sent on every intent, charge, capture, void and refund.
  • Charge / intent / confirm bodies use this API's field names (billing_address vs billing vs receipt_email), extra keys are parameter_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.
  • /check sends the shopper's IP, not your egress IP.

Then: Going live.