# Integration hard rules

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](https://app.worthypayments.com/demos/checkout-reference.html)
(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](#4-idempotency-key-required-and-stable) | Required on intent create. Key it on the order, not the click |
| [10. Branch only on these outcomes](#10-branch-only-on-these-outcomes) | `onSuccess` / `onError`, nothing else |
| [11. Amounts](#11-amounts) | Integer cents. `4999` is $49.99, minimum `50` |
| [15. The browser integration is the Element](#15-the-browser-integration-is-the-payment-element) | Intent on your server, mount in the page, the whole contract |
| [16. Never persist card data](#16-never-persist-card-data) | Nothing card-shaped reaches your storage |
| [17. Lock publishable keys to your domains](#17-lock-your-publishable-keys-to-your-domains) | Miss it and the iframe renders **blank, with no error** |
| [18. Branch on `code`](#18-branch-on-code-never-on-message) | `message` is prose and gets reworded |
| [20. Don't show integration errors to shoppers](#20-dont-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 a
  [`device_session`](./24-device-data-collection.md#device-session-the-buyers-ip-and-browser)
  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](./03-charges.md#client-ip-fraud-signal-not-a-body-field), [Fraud checks](./23-fraud-checks.md#client-ip-effectively-required).

---

## 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](./08b-payment-element.md), 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](./07-3ds.md).

---

## 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](./12-idempotency.md).

---

## 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](./26-decision-token.md), [Fraud checks](./23-fraud-checks.md).

---

## 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](./02-authentication.md), [Integration modes](./25-integration-modes.md).

---

## 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](./24-device-data-collection.md).

---

## 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](./03-charges.md#responses). Errors: [Errors](./10-errors.md).

---

## 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)](./02-authentication.md#request-signing-hmac).
Webhook **delivery** signatures are a different HMAC: [Webhooks → Verifying a delivery](./16-webhooks.md#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:`](./32-migration-from-ev.md).

---

## 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](./31-key-rotation.md).

---

## 15. The browser integration is the Payment Element

There is one way to take a card payment in a page: mount the
[Payment Element](./08b-payment-element.md) 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](./04-checkout-links.md) you redirect to, and a
[server-to-server charge](./03-charges.md) 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.

```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](./10-errors.md#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](./03-charges.md) and [Errors](./10-errors.md).

---

## 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](./21-going-live.md).
