# securepayAPI — agent integration handbook (checkout layer)

This is the compact, complete contract for an AI coding agent that builds,
reviews or operates an integration with the securepayAPI **checkout layer**:
the Payment Intents API, the JavaScript SDK / embedded widget, hosted checkout
pages, webhooks and the transaction lookup. It is written to be read by a
model: each rule is stated once, in imperative form, with exact names.

The intent update/status and transaction instrument contracts were reviewed on
**2026-11-06**; remaining contracts retain their **2026-10-29** verification. Do not invent a
route, a field, a default, a status or a retry rule that is absent here. When
something you need is missing, read the linked page or stop and ask.

Pages with the same facts, for people: https://doc.securepayapi.com/

> Scope boundary. This layer never shows you a card number: the card is typed
> into an iframe served by securepayAPI. If the merchant must send card data to
> the processing API itself (PCI DSS scope), that is the **direct core
> integration**, documented separately in the private section
> (https://doc.securepayapi.com/private). Do not mix the two: the checkout
> layer's base URL is `https://checkout.securepayapi.com`; core's is
> `https://securepayapi.com`.

---

## 1. Invariants (read first)

- **The server decides the money.** Amount, currency, live/sandbox and any
  subscription terms are fixed when the merchant's backend creates the
  Payment Intent with its secret key. Nothing the browser sends can change
  them. Never put `amount`, `currency` or `mode` under the control of page
  code or a URL.
- **The browser's "success" is not evidence.** Before fulfilling, the backend
  reads the intent back with `GET /api/v1/payment-intents/{id}` and checks
  `status`, `amount`, `currency` and `mode` against the order it created.
- **`status: "pending"` means "do not ask for money again".** The payment was
  accepted and its outcome is not settled yet. Show "being confirmed", then let
  the server poll the intent. Treating pending as failure is how a shopper pays
  twice.
- **One intent per order attempt; store the intent `id` with the order.**
  Confirm the id *you* created — never one the browser hands you. Any
  succeeded intent passes a status check, including one the same shopper paid
  earlier for the same amount.
- **A subscription is `pending_activation` after payment, never `active`.**
  The widget's success message says the first charge was captured. The
  agreement is activated by the payment platform moments later, on its side.
- **Keys:** `sp_secret_…` lives only on the server (Bearer header). `sp_client_…`
  is publishable and goes to the browser. The `client_secret` is the capability
  to pay one intent — hand it only to that customer's session, never log it.
- **Never tear the widget down while a payment is active.** From
  `payment_started` until `payment_ended`, `success` or `error` (SDK:
  `onActive(true)` … `onActive(false)`) a wallet sheet or a 3-D Secure challenge
  is open *inside the iframe*. Removing or re-creating the iframe, swapping the
  `clientSecret` or navigating kills it and leaves the prepared payment at INIT
  with no provider. A checkout that creates a new intent per cart edit must
  queue that change until the active window closes.
- **A `transaction_reference` at INIT is not a stuck payment.** After a wallet
  decline the widget prepares a new attempt, so the intent points at a fresh
  reference that has done nothing; the decline is in `last_attempt`. Read it
  before concluding anything.
- **Webhooks describe the transaction, not the intent.** Verify the signature
  on the raw body with the webhook signing secret (not the API secret), handle
  coalesced and out-of-order deliveries, and treat `GET /transactions/{id}` as
  the source of truth.

---

## 2. Credentials and identifiers

| Credential | Looks like | Where | Used for |
| --- | --- | --- | --- |
| Secret key | `sp_secret_…` | server only, `Authorization: Bearer` | create, update and read Payment Intents |
| Publishable key | `sp_client_…` | browser | identifies the account to the widget (`SecurePay('sp_client_…')`, `/embed?pk=`) |
| Client secret | `pi_<intentId>_secret_<random>` | the paying customer's browser session | completes one intent; valid **7 days**; the payment can succeed **once** |
| Webhook signing secret | shown **once** in Developer → Webhooks | server only | verifies `X-Signature` on webhooks |

Identifiers:

| Value | Shape | Notes |
| --- | --- | --- |
| Intent `id` | 24 lowercase alphanumerics | yours to store against the order |
| `transaction_reference` | newly issued: `LU` + 14 uppercase alphanumerics; store as a string and accept `[A-Za-z0-9_\-:.]{1,128}` | the payment platform's reference; the `id` in webhooks and on the transaction |
| `externalid` on the transaction / in webhooks | your `externalid` from the intent, else the intent `id` | correlation key |

Both keys are managed in the dashboard: https://app.securepayapi.com/developer.
Regenerating a key invalidates the old one immediately.

---

## 3. Endpoints

| Method | URL | From | Purpose |
| --- | --- | --- | --- |
| `POST` | `https://checkout.securepayapi.com/api/v1/payment-intents` | server | create an intent → `client_secret` |
| `POST` | `https://checkout.securepayapi.com/api/v1/payment-intents/{id}` | server | update unpaid intent `externalid` (128-character update limit) |
| `GET` | `https://checkout.securepayapi.com/api/v1/payment-intents/{id}` | server | read an intent back to **confirm** |
| `GET` | `https://checkout.securepayapi.com/sdk/v1/securepay.js` | browser | the SDK |
| `GET` | `https://checkout.securepayapi.com/embed?pk=sp_client_…` | browser (iframe) | the widget (what the SDK loads for you) |
| `GET` | `https://checkout.securepayapi.com/<productId>` | browser | a hosted checkout page for a dashboard product |
| `GET` | `https://app.securepayapi.com/api/v1/transactions/{id}` | server | read the transaction record |
| `POST` `GET` | `https://checkout.securepayapi.com/api/v1/subscription-plans` | server | create / list subscription plans (§5a) |
| `POST` | `…/api/v1/subscription-plans/versions`, `…/archive`, `…/reactivate` | server | evolve a plan |
| `GET` | `…/api/v1/subscriptions/{externalReference}`, `…/api/v1/subscriptions` | server | read an agreement (activation, status, cycles) |
| `POST` | `…/api/v1/subscriptions/cycles`, `…/cancel`, `…/pause`, `…/resume`, `…/contact` | server | bill a variable period; manage an agreement |

Every `/api/v1/*` endpoint sends **no CORS headers**: a browser call is refused
by the browser. Use the exact paths, no trailing slash. Bodies are JSON with
`Content-Type: application/json`.

The widget's own internal endpoints (`/api/card-payment/*`) are not part of the
contract. Never call them.

`/embed` is fail-closed: an unknown `pk`, or a merchant with no registered
domain, answers **404** with no hint. Opened directly (not embedded, no `init`
within 8 seconds) it renders blank.

---

## 4. Create a Payment Intent — `POST /api/v1/payment-intents`

```http
POST /api/v1/payment-intents HTTP/1.1
Host: checkout.securepayapi.com
Authorization: Bearer sp_secret_…
Content-Type: application/json
```

### Request body

| Field | Type | Required | Rules |
| --- | --- | --- | --- |
| `amount` | integer | yes | minor units (cents); `1`–`99999999`. `2000` = $20.00 |
| `currency` | string | yes | `"USD"` only (case-insensitive, stored uppercase) |
| `reference` | string | no | your order id, ≤ 200 chars. Stored; returned by `GET` |
| `externalid` | string | no | your identifier for the **transaction**, ≤ 200 chars. Forwarded to the payment platform; comes back as `externalid` in webhooks and on the transaction. Default: the intent `id` |
| `descriptor` | string | no | statement descriptor, 5–100 chars. Forwarded |
| `description` | string | no | purchase description, 5–100 chars. Forwarded |
| `metadata` | object | no | flat object, **string values only**, ≤ 4096 bytes serialised. Stored and forwarded |
| `theme` | string | no | `"light"` (default) or `"dark"` |
| `mode` | string | no | `"live"` (default) or `"sandbox"`. Decides whether real money moves. The browser cannot change it |
| `subscription` | object | no | makes this payment set up a recurring agreement — section 5 |

Unknown fields are ignored. Absent and `null` mean "not sent" for every
optional field. `""` is **not** uniformly the same: `externalid`, `descriptor`
and `description` are trimmed and an empty result counts as not sent;
`reference` is stored verbatim (an empty string stays an empty string, and it
is not trimmed); `theme` and `mode` accept only their listed values, so `""`
is a `400`. `currency` is trimmed and upper-cased before the check.

### Example

```json
{
  "amount": 2000,
  "currency": "USD",
  "reference": "order_123",
  "externalid": "shop-123-2026-10-28",
  "descriptor": "ACME Store order 123",
  "description": "Blue hoodie, size M",
  "metadata": { "order_id": "123", "customer_id": "cust_88213" },
  "theme": "light",
  "mode": "live"
}
```

### Response `200`

```json
{
  "id": "k3m9q2x7v1b8n4c6d0f5g7h2",
  "client_secret": "pi_k3m9q2x7v1b8n4c6d0f5g7h2_secret_…",
  "amount": 2000,
  "currency": "USD",
  "status": "requires_payment",
  "mode": "live"
}
```

`client_secret` is returned **only here**. It cannot be read back later. When a
`subscription` was sent, the response also carries `subscription` (the stored
block without `termsText`, plus `"operation": "setup"` or
`"recoverPaymentMethod"`).

### Errors

All errors: `{ "error": { "type", "code", "message" } }`.

| Status | `type` | `code` |
| --- | --- | --- |
| 400 | `invalid_request` | `invalid_json`, `amount_invalid`, `amount_out_of_range`, `currency_unsupported`, `reference_invalid`, `metadata_invalid`, `metadata_too_large`, `externalid_invalid`, `descriptor_invalid`, `description_invalid`, `theme_invalid`, `mode_invalid`, and the `subscription_*` codes in section 5 |
| 401 | `authentication` | `missing_api_key`, `invalid_api_key` |
| 500 | `server` | `internal_error` |

### Idempotency

There is **no** idempotency key on this endpoint. Each accepted call creates a
new intent. Create one intent per order attempt, store its `id` on the order
*before* handing the `client_secret` to the browser, and reuse that intent for
retries of the same order (a declined attempt leaves the intent payable until
it expires). Only create a second intent for a genuinely new attempt after the
first has expired or you have abandoned it.

---

## 5. Subscriptions through an intent — the `subscription` object

A Payment Intent can set up a **recurring agreement**: the customer pays the
first charge and consents in the widget; the payment platform then keeps the
card and bills the following periods itself. The merchant never handles the
card, and this layer never bills — it performs the customer-present setup only.

Prerequisites: the plan (price, interval, terms) must already exist — create
it once with `POST /api/v1/subscription-plans` (§5a) — and recurring billing
must be enabled on the merchant's cabinet by securepayAPI. Without them the
customer's payment is refused with a clear error before any money moves.

### New agreement

```json
"subscription": {
  "planExternalReference": "monthly-gold",
  "shopperExternalReference": "cust_88213",
  "subscriptionExternalReference": "membership-42",
  "termsVersion": "2026-10-01",
  "termsText": "I authorize … USD 19.99 every month … until I cancel.",
  "termsHash": "5d48345db32747eed3f5666e43c946652760795f35d09d81c4e0f6f29c353ed3",
  "billingAnchor": "2026-11-28T12:00:00-04:00",
  "billingTimezone": "America/New_York",
  "recurringSurchargeConsent": false
}
```

| Field | Required | Rules |
| --- | --- | --- |
| `planExternalReference` | yes | 1–255 chars. The plan as created on the platform. The intent's `amount`/`currency` must equal the plan's — enforced by the platform when the customer pays |
| `shopperExternalReference` | yes | 1–255 chars. **Your customer id.** Not personal data; a value containing `@` is refused (`subscription_shopper_reference_pii`) |
| `subscriptionExternalReference` | yes | 1–255 chars. Your id for the agreement; you read, pause and cancel it by this later |
| `termsVersion` | yes | 1–64 chars. Must equal the plan's current terms version |
| `termsHash` | yes | exactly 64 lowercase hex: SHA-256 of `termsText`. Must equal the plan's current terms hash |
| `termsText` | yes | 1–16000 chars. The exact consent text shown to the customer. Refused unless `sha256(termsText) === termsHash` (`subscription_terms_hash_mismatch`) |
| `billingAnchor` | yes | ISO-8601 **with an explicit UTC offset** (`Z` or `±hh:mm`), matching `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$`; at least **60 minutes** in the future |
| `billingTimezone` | no | IANA zone ≤ 64 chars; default `UTC`; `null` = default |
| `recurringSurchargeConsent` | no | only `false` is accepted today |

Rules:

- Every string must be sent **without surrounding whitespace** (exactness is
  checked, not trimmed).
- Do **not** send `consent` — consent is the customer's act in the widget
  (`subscription_consent_not_accepted_here`).
- Do **not** send a billing interval or a price — those are properties of the
  plan (`subscription_unknown_field`).
- Compute `termsHash` from the exact UTF-8 bytes of `termsText`.

### Card replacement on an existing agreement

```json
"subscription": {
  "operation": "recoverPaymentMethod",
  "subscriptionExternalReference": "membership-42",
  "termsVersion": "2026-10-01",
  "termsText": "<the consent text>",
  "termsHash": "<sha256 hex of termsText>"
}
```

`amount` must be the exact recovery amount the platform reports for the
agreement's unpaid period. `planExternalReference`, `shopperExternalReference`,
`billingAnchor` and `billingTimezone` are refused here.

### `subscription_*` error codes (all `400 invalid_request`)

`subscription_invalid`, `subscription_unknown_field`,
`subscription_operation_invalid`, `subscription_consent_not_accepted_here`,
`subscription_plan_invalid`, `subscription_shopper_invalid`,
`subscription_shopper_reference_pii`, `subscription_reference_invalid`,
`subscription_terms_version_invalid`, `subscription_terms_hash_invalid`,
`subscription_terms_text_invalid`, `subscription_terms_hash_mismatch`,
`subscription_billing_anchor_invalid`, `subscription_billing_anchor_too_soon`,
`subscription_billing_timezone_invalid`,
`subscription_surcharge_consent_invalid`,
`subscription_surcharge_consent_unsupported`.

### What happens in the widget

Automatically, with no change to your integration:

- a **Subscription — recurring payment** badge; "First payment" instead of
  "Total"; the billing start date; the full `termsText`; a consent checkbox the
  customer must tick before the button enables;
- the **email field is always shown and required** (the platform sends renewal
  notices and receipts to it) — `email: false` on `mount` is ignored;
- **cards only** — Apple Pay and Google Pay are not offered;
- in external submit mode, `submit()` rejects until the box is ticked.

When the customer pays, the widget sends the platform the stored block with the
customer's consent. The platform may still refuse the setup — plan not found or
not active, terms changed, amount or currency not matching the plan, billing
anchor already passed, recurring billing not enabled — and the customer sees
that message; no money moves.

### What you receive

`success` carries `subscription: { "status": "pending_activation",
"externalReference": "membership-42" }`. The first charge is captured; the
agreement becomes active once the payment platform confirms the authorisation,
on its side. Read it with the secret key
(`GET /api/v1/subscriptions/membership-42`, §5a) and grant access only on
`"status": "active"`, or wait for its first `subscription.renewal_*` webhook.
Never grant access on `pending_activation` alone. If the agreement is still
`pending_setup` after a few minutes, the first payment was not authenticated
with 3-D Secure and the agreement will never activate: treat the captured
charge as a one-off (fulfil or refund) and ask for another card.

---

## 5a. Plans and agreements — `/api/v1/subscription-plans`, `/api/v1/subscriptions`

Same Bearer secret key, same error envelope, no CORS. Optional `mode`
(`"live"` default | `"sandbox"`) as a body field (POST) or query parameter
(GET): plans and agreements are per cabinet. Money here is a **decimal string**
(`"19.99"`), only the intent uses minor units (`1999`). Mutations take a UUID
`idempotencyKey` — **required on `/subscriptions/cycles`**, optional elsewhere
(generated when absent); the key used is echoed back.

### Plans

`POST /api/v1/subscription-plans` body:
`{ externalReference, name, amountMode: "fixed"|"variable", amount (fixed) | maximumAmount (variable), currency: "USD", intervalUnit: "day"|"week"|"month"|"year", intervalCount, termsVersion (≤64), termsHash (64 lowercase hex = sha256 of the consent text) }`
→ `200` `{ externalReference, name, status: "active"|"archived", version, amountMode, amount, maximumAmount, requiresCommerceOrder, currency, intervalUnit, intervalCount, termsVersion, termsHash, created: boolean, idempotencyKey }` (`created: false` = the reference was replayed with identical facts). Reads and version/archive/reactivate answer the presented form instead: `{ externalReference, environment, name, status, currentVersion: {version, amountMode, amount, maximumAmount, …, createdAt}, createdAt, updatedAt }`.
Replaying an `externalReference` with identical facts returns the plan
(`created: false`); with a changed fact it is refused (today the platform
reports this as `502 upstream_error`, not `409` — never retry a plan create
with different facts, use `/versions`). Plans are immutable: change them with
`POST /api/v1/subscription-plans/versions` (same body minus `name`, plus
required `expectedVersion` = the plan's current `currentVersion.version` as
last read, else `400 expectedVersion_required`; stale → `409`, re-read; existing
agreements keep their version). `…/archive` / `…/reactivate`:
`{ externalReference }`. `GET /api/v1/subscription-plans?externalReference=…`
(one plan, with `versions[]`) or `?status=active&limit=&cursor=` (`{ items, nextCursor }`).

**Fixed vs variable** — the one decision that changes the integration:

| | fixed | variable |
| --- | --- | --- |
| intent `amount` for the setup | `== amount` (cents of the decimal) | `> 0` and `<= maximumAmount` |
| renewals | billed by the platform at `nextBillingAt`; nothing to do | **the merchant must call** `POST /api/v1/subscriptions/cycles` for every due period; nothing is charged otherwise |
| trial / intro price | first renewal date via `billingAnchor` only; price is fixed | any first amount `> 0`; `"0"` for a free period |

There is **no zero-amount setup**: a card is stored only by a real first
charge above zero.

### Agreements

`GET /api/v1/subscriptions/{externalReference}` (= `GET /api/v1/subscriptions?externalReference=`)
→ `{ externalReference, environment, status, cancelAtPeriodEnd, billingAnchor, billingTimezone, nextBillingAt, startedAt, pausedAt, cancelledAt, endedAt, shopperExternalReference, customerContact{configured, updatedAt}, plan{…}, paymentMethod{status: "pending"|"active"|"revoked"|"expired", bin, lastFour, country, activatedAt, recoveryInProgress}, recovery{eligible, cycleStatus, periodStart, periodEnd, amount, currency, attemptNumber, attemptStatus, refusalCode, retryAt}, cycles[{periodStart, periodEnd, billingAt, amount, currency, status, paid, attempts[…]}], events[{type, occurredAt, data}] }`.
`GET /api/v1/subscriptions?status=&limit=&cursor=` → `{ items, nextCursor }`.

| `status` | meaning | act |
| --- | --- | --- |
| `pending_setup` | first charge captured, activation not confirmed | wait minutes; then "not set up" |
| `active` | billing on schedule | grant access |
| `past_due` | a renewal is refused and retrying, or retries exhausted | restrict per policy; offer a card update when `recovery.eligible` |
| `paused` | paused by the merchant | — |
| `cancelled` | ended (`endedAt`); `cancelAtPeriodEnd: true` on an `active` one = scheduled | revoke at `endedAt` |
| `completed` | ran to its end | — |

Cycle `status`: `scheduled` · `processing` · `paid` · `retryable` · `failed` ·
`outcome_unknown` · `skipped`. Retries of a refused renewal: up to 4 attempts
within 30 days (1, 3, 7 days apart by default; issuer advice can change or
stop them), reported as `subscription.renewal_retry_scheduled` (`nextRetryAt`)
then `subscription.renewal_past_due`.

`POST /api/v1/subscriptions/cycles` `{ externalReference, amount: "5.99" | "0", idempotencyKey }`
(key required, `400 idempotencyKey_required` without it) — variable plans
only, when `nextBillingAt` has passed. `"0"` closes the
period without a charge (`skipped`, `subscription.renewal_skipped`) and
advances one interval. The key owns the facts permanently: same key + same
amount → same cycle; same key + other amount → `409`; second key for a billed
period → `409`. Generate the key when the amount is decided, persist it, reuse
it on retries. `409` when not active / not due / cancelled / no active card.

`POST /api/v1/subscriptions/cancel` `{ externalReference, atPeriodEnd?: true, idempotencyKey? }`
(`false` = immediately), `…/pause`, `…/resume` `{ externalReference, idempotencyKey? }`
→ `{ externalReference, status, cancelAtPeriodEnd, effectiveAt, created, idempotencyKey }`;
`409` while a cycle is processing, has an unknown outcome, or a card
replacement is in progress — retry later, never assume. `…/contact`
`{ externalReference, customerEmail }` changes the notice/receipt address.

Errors specific to these endpoints: `400 invalid_request/rejected` (the
platform's refusal, message included), `403 forbidden/not_enabled`,
`404 not_found/not_found`, `409 conflict/conflict`, `502 server/*` (platform
unreachable — retry).

---

## Update an unpaid intent's transaction reference

`POST /api/v1/payment-intents/{id}` uses your server-only Bearer secret key on
`https://checkout.securepayapi.com`. Use it when your order reference becomes
known after creating the intent. Send JSON with the required `externalid`:

```bash
curl -X POST https://checkout.securepayapi.com/api/v1/payment-intents/k3m9q2x7v1b8n4c6d0f5g7h2 \
  -H 'Authorization: Bearer sp_secret_…' \
  -H 'Content-Type: application/json' \
  -d '{"externalid":"ORDER-42"}'
```

The value must be a string trimmed to at most **128 characters**, or `null`.
A blank string becomes `null`. This update limit is separate from the **200**
character creation limit. The reference reaches core on the next payment attempt;
this call does not rewrite an existing transaction. It does not change `reference`.
Only unpaid, unconsumed intents are updatable; a succeeded intent or an intent
consumed by an uncertain capture is refused.

Success is `200 {"id":"k3m9q2x7v1b8n4c6d0f5g7h2","externalid":"ORDER-42"}`.
Errors use the ordinary `{error:{type,code,message}}` envelope:

| Status | Code | Meaning |
| --- | --- | --- |
| 400 | `invalid_json` | Body is not JSON. |
| 400 | `externalid_invalid` | Missing field, wrong type, or trimmed string longer than 128 characters. |
| 401 | `missing_api_key`, `invalid_api_key` | Missing or invalid server secret. |
| 404 | `intent_not_found` | Unknown intent or another merchant's intent. |
| 409 | `intent_not_updatable` | Already paid or consumed; read the intent and transaction before any further action. |

## 6. Confirm a payment — `GET /api/v1/payment-intents/{id}`

```http
GET /api/v1/payment-intents/k3m9q2x7v1b8n4c6d0f5g7h2 HTTP/1.1
Host: checkout.securepayapi.com
Authorization: Bearer sp_secret_…
```

### Response `200`

```json
{
  "id": "k3m9q2x7v1b8n4c6d0f5g7h2",
  "status": "succeeded",
  "amount": 2000,
  "currency": "USD",
  "mode": "live",
  "reference": "order_123",
  "externalid": "shop-123-2026-10-28",
  "metadata": { "order_id": "123" },
  "transaction_reference": "LU494ZZ07K7814E3",
  "last_attempt": null,
  "created_at": "2026-10-28T19:02:11.000Z",
  "expires_at": "2026-11-04T19:02:11.000Z",
  "paid_at": "2026-10-28T19:04:58.000Z"
}
```

| Field | Meaning |
| --- | --- |
| `status` | `requires_payment` (waiting for a card) · `processing` (capture is about to be sent or its result is pending; initialization and a 3-D Secure challenge alone do not set it) · `succeeded` (captured) · `failed` (last attempt declined; retry possible until `expires_at`) |
| `transaction_reference` | the payment platform's reference for the **current** attempt, set once an attempt was made; `null` before. The `id` in webhooks and on the transaction |
| `last_attempt` | `null` or `{ reference, outcome: "declined"\|"cancelled"\|"failed"\|"pending", message, at }` — how the **previous** attempt ended when the widget has since prepared a new one (a wallet decline, a closed sheet). Explains a `transaction_reference` that looks stuck at INIT; the browser's account, never evidence of payment |
| `paid_at` | set only when `status` is `succeeded`; `null` otherwise. Gate on `status` |
| `expires_at` | the intent cannot be paid after this |

The `client_secret` is never returned.

Reading `transaction_reference` together with `last_attempt`:

```text
status processing/requires_payment, transaction_reference set, last_attempt null
    → an attempt is prepared or in flight; wait
status requires_payment/failed, last_attempt.outcome "declined"
    → the shopper was declined (last_attempt.message says why) and the widget
      prepared a new attempt; transaction_reference is that new, untouched one
last_attempt.outcome "cancelled"
    → the browser reports a closed wallet sheet; read server facts before deciding
```

`last_attempt` is the browser's account of the attempt, taken only for the
reference the intent was bound to — enough to explain, never enough to settle.

### Errors

| Status | `code` | Meaning |
| --- | --- | --- |
| 401 | `missing_api_key` / `invalid_api_key` | credentials |
| 404 | `intent_not_found` | unknown id **or** another merchant's id — indistinguishable by design |

### Decision table (fulfilment)

```text
status succeeded AND amount/currency equal the order AND mode is live AND the id is the one you stored  → fulfil
status processing                                                                                   → capture pending/uncertain; read again; do not submit another payment
status requires_payment or failed, before expires_at                                                 → unpaid; the customer may retry
expires_at passed, not succeeded                                                                     → reconcile any pending/uncertain capture before a new payment
mode sandbox                                                                                         → a test; no money moved
```

---

## 7. Collect the card — JavaScript SDK

```html
<script src="https://checkout.securepayapi.com/sdk/v1/securepay.js"></script>
<div id="pay"></div>
<script>
  var sp = SecurePay('sp_client_…');
  var handle = sp.mount('#pay', {
    clientSecret: 'pi_…_secret_…',
    onReady:   function ()  {},
    onSuccess: function (r) { /* r.intentId, r.status, r.reference, r.subscription */ },
    onError:   function (e) { /* e.message, e.code */ }
  });
</script>
```

`SecurePay(publishableKey)` works with or without `new`; it throws without a
key. `mount(target, options)` takes a CSS selector or an element and returns a
handle.

### `mount` options

| Option | Type | Default | Meaning |
| --- | --- | --- | --- |
| `clientSecret` | string | required | from your Payment Intent |
| `theme` | `'light'` \| `'dark'` | the intent's `theme` | overrides the intent's theme for this page |
| `email` | boolean | `true` | `false` hides the email field (ignored for a subscription) |
| `submitMode` | `'internal'` \| `'external'` | `'internal'` | `external` hides the widget's pay button; you start the payment with `handle.submit()` |
| `onReady` | function | — | the widget has loaded and the SDK has handed it the `clientSecret`; the intent is then fetched inside the frame, and a load failure follows as `onError` |
| `onActive` | function | — | `true` when a payment becomes active inside the frame (wallet sheet or 3-D Secure open), `false` when it ends. **While active, never unmount the widget, change its `clientSecret` or navigate** — the sheet dies with the frame and the prepared payment is left at INIT |
| `onSuccess` | function | — | the result (below) |
| `onError` | function | — | `{ message, code?, reference?, method?, submitId? }` — `reference` names a declined wallet attempt |
| `wallets` | boolean | `false` | external mode only: offer Apple Pay / Google Pay. Requires handling `onWalletAuthorize` and `onSuccess` for wallets, and calling `handle.setWalletsEnabled(bool)` as the form becomes complete |
| `onWalletAuthorize` | function | capture | with `wallets: true`: `({ method, reference }) => boolean \| Promise<boolean>`, called after the shopper approved in the sheet and **before any charge**. Create the pending order here; `true` captures, `false`/reject aborts with nothing moved; answer within 10 s |
| `mode` | `'sandbox'` | — | **legacy guard only**: live/sandbox is decided by the intent. Passing `'sandbox'` on a live intent makes the widget refuse to load with `mode_mismatch` rather than take a real payment |

### The handle

| Method | Behaviour |
| --- | --- |
| `submit()` | `submitMode: 'external'` only. Returns a Promise that settles **exactly once** with the result, with **no timeout** (a 3-D Secure challenge takes minutes). Rejects immediately before `onReady` ("SecurePay: the payment form is not ready yet"); otherwise the widget answers with the result or a refusal (below) |
| `destroy()` | removes the iframe and listeners; rejects any pending `submit()` ("SecurePay: the widget was destroyed") |

Refusals the widget answers a `submit()` with (the promise rejects; `e.message`
is one of these, no `code`):

| Message | When |
| --- | --- |
| `The payment form is not ready yet.` | the intent is still loading |
| the widget's own unavailable reason | the intent expired, `mode_mismatch`, or the load failed — the text explains which |
| `A payment is already in progress.` | a previous `submit()` or a payment started inside the frame has not ended |
| `This payment is already complete.` | the intent was paid through this widget |
| `This payment is being confirmed.` | a wallet payment answered `pending`; never start another |
| `Please enter a valid email address.` | email shown and empty or malformed |
| `Please complete the card details.` | cardholder, number, expiry or CVC empty |
| `Please accept the subscription terms to continue.` | subscription setup, box not ticked |

Show the message as it is: the shopper sees the same text inline in the frame
for the field refusals.

### Lifecycle — the active window

```js
var paymentActive = false;
var handle = sp.mount('#pay', {
  clientSecret: cs,
  onActive: function (active) { paymentActive = active; }   // sheet / 3DS open ↔ closed
});

function onCartChanged(newTotal) {
  if (paymentActive) { pendingCart = newTotal; return; }     // never remount now
  applyCartChange(newTotal);                                 // new intent → new mount
}
```

- `onActive(true)` fires on `payment_started` (a wallet sheet opened, or the
  card flow started); `onActive(false)` on `payment_ended`, `success` or
  `error`.
- While active: no `destroy()`, no re-created iframe, no new `clientSecret`,
  no navigation. The sheet and the 3-D Secure challenge live inside the frame
  and die with it, leaving the prepared payment at INIT with no provider.
- After `payment_ended` with `outcome: "failed"` or `"cancelled"` keep the
  widget mounted: the card form is still there and the shopper can retry; the
  decline is readable on the intent as `last_attempt` (section 6).
- The same transitions are DOM events on `window`: `securepay:payment_started`,
  `securepay:processing`, `securepay:payment_ended`; without the SDK they are
  the `payment_started` / `processing` / `payment_ended` messages (section 8).

### The result (`onSuccess`, `securepay:success`, resolved `submit()`)

```json
{ "intentId": "k3m9…", "status": "succeeded", "reference": "LU494ZZ07K7814E3",
  "subscription": { "status": "pending_activation", "externalReference": "membership-42" },
  "submitId": "s1" }
```

| Field | Meaning |
| --- | --- |
| `intentId` | what your server reads back (section 6) |
| `status` | `"succeeded"`, or `"pending"` — accepted, outcome not settled: **do not ask for money again**; show "being confirmed"; let the server poll |
| `reference` | the transaction reference, when known |
| `subscription` | present for a recurring setup; always `pending_activation` |
| `submitId` | echo of the `submit()` command this answers |

Outcomes also fire as DOM events on `window`: `securepay:success` and
`securepay:error` with the same payload in `event.detail`.

### Error codes before any payment

| `code` | Meaning |
| --- | --- |
| `origin_not_allowed` | the page is not on one of the merchant's registered domains |
| `mode_mismatch` | `mount({ mode: 'sandbox' })` on a live intent |
| `intent_not_payable` | expired or already paid; message `This payment session has expired.` |

Payment declines arrive as `{ message }` with no code — show the message. A
`clientSecret` the widget cannot resolve at all (mistyped, from another
account) is shown as a failure **inside the frame** and posts no `error`; a
later `submit()` is refused with that reason. Treat a widget that never reports
`onReady` → form as a wiring problem, not a decline.

### Domains

The widget accepts the `init` message only from a page on one of the
merchant's **registered domains** — the account's website domain, extra domains
whitelisted for it, and any subdomain of those — over HTTPS. Elsewhere it
refuses with `origin_not_allowed`. Matching is on the host **including a port**
when one was registered; a registered `www.example.com` counts as
`example.com`, so every subdomain of the apex is allowed. `http://localhost`
(any port) passes the HTTPS requirement for development, but `localhost` still
has to be one of the registered hosts. Register the exact domain the checkout
runs on.

### External submit (store checkouts, plugins)

```js
var widget = sp.mount('#pay', { clientSecret: cs, submitMode: 'external' });
placeOrder.onclick = function () {
  widget.submit().then(function (r) {
    // tell your server r.intentId; the server confirms with GET, then places the order
  }).catch(function (e) { show(e.message); });
};
```

Wallet buttons are not shown in external mode unless the host opts in with
`wallets: true`: a wallet payment started inside the frame answers no
`submit()`, so the host must handle it through `onWalletAuthorize` and
`onSuccess` (below).

### Wallets

Where the device supports it, the widget offers Apple Pay (Safari) or Google
Pay above the card form; the card form always stays. The SDK sets
`allow="payment"` on the iframe. Apple Pay additionally requires the embedding
page's domain to be registered for Apple Pay on the account. No wallets for a
subscription setup.

Wallets under an external submit (a store's own "Place order"), order-first:

```js
var widget = sp.mount('#pay', {
  clientSecret: cs, submitMode: 'external', wallets: true,
  onWalletAuthorize: function (info) {   // shopper approved in the sheet; NOTHING charged yet
    return createPendingOrder().then(function () { return true; });   // false/throw = abort, nothing moved
  },
  onSuccess: function (r) { confirmOnServer(r.intentId).then(redirect); },  // wallets end here, not in submit()
  onError:   function (e) { show(e.message); }                              // e.method names the wallet
});
widget.setWalletsEnabled(requiredFieldsComplete());   // buttons start held; release when the form is complete
```

Rules: answer `onWalletAuthorize` within 10 s (Apple allows ~30 s from approval
to completion, and the authorisation plus capture still have to fit); an abort
posts `payment_ended { outcome: "cancelled" }` and shows a rejected Promise's
message in the frame; after `true` the normal `payment_started` → `success` /
`error` sequence follows with `method` set. Never call `submit()` for a wallet.

### Flow

1. Server: `POST /api/v1/payment-intents` → store `id`, return `client_secret`
   to the page.
2. Page: `mount` with the `client_secret`; show the widget.
3. Widget: card (or wallet), 3-D Secure inside the iframe, capture; posts
   `payment_started` when it begins and `success`/`error`/`payment_ended` when
   it ends — hold any remount or intent change in between.
4. Page: send `intentId` to your server (never trust it as paid).
5. Server: `GET /api/v1/payment-intents/{id}`; fulfil only on the decision
   table in section 6.
6. Afterwards: webhooks and `GET /transactions/{id}` for settlement, refunds,
   disputes.

---

## 8. Embedded widget without the SDK — postMessage protocol

Only if you cannot load the SDK. Every message, both directions:

```js
{ source: 'securepay', type: '<name>', payload: { … } }
```

Ignore anything without `source: 'securepay'`; always check `event.origin ===
'https://checkout.securepayapi.com'` and `event.source === iframe.contentWindow`.

```html
<iframe src="https://checkout.securepayapi.com/embed?pk=sp_client_…"
        title="Secure payment" allow="payment" style="width:100%;border:0;height:320px"></iframe>
```

| Direction | `type` | `payload` |
| --- | --- | --- |
| widget → page | `ready` | — ; answer with `init` |
| page → widget | `init` | `{ publishableKey, clientSecret, loc: location.href, submitMode?: 'internal'\|'external', theme?: 'light'\|'dark', collectEmail?: boolean }` — target `'https://checkout.securepayapi.com'`, never `'*'` |
| widget → page | `resize` | `{ height }` → set `iframe.style.height = Math.max(80, height) + 'px'` |
| widget → page | `payment_started` | `{ method: 'applepay'\|'googlepay'\|'card', reference? }` — a payment is now ACTIVE in the frame |
| widget → page | `processing` | `{ method, stage, reference? }` — progress while active; informational. Wallet `stage`: `validated`, `authorizing`, `authorized`, `authenticating`. Card `stage`: the status text the shopper sees (free text) — treat as opaque |
| widget → page | `payment_ended` | `{ method, outcome: 'cancelled'\|'failed'\|'pending', reference? }` — ended without a capture. `failed` is followed by `error`; a wallet `pending` is followed by `success { status: "pending" }` |
| widget → page | `success` | `{ intentId, status, reference?, subscription?, submitId? }` |
| widget → page | `error` | `{ message, code?, reference?, method?, submitId? }` |
| page → widget | `submit` | `{ id }` — external mode; the one `success`/`error` that answers echoes `submitId: id` |
| widget → page | `wallet_authorized` | `{ id, method, reference }` — `init` carried `wallets: true`; the shopper approved in the sheet, NOTHING charged yet. Answer within 10 s |
| page → widget | `wallet_capture` / `wallet_abort` | `{ id }` / `{ id, message? }` — charge, or end the attempt with nothing moved (message shown in the frame). Silence = abort |
| page → widget | `set_wallets` | `{ enabled }` — release (true) or hold (false) the wallet buttons; they start held in external mode |

**Active window rule:** from `payment_started` until `payment_ended`, `success`
or `error`, the page must not remove or re-create the iframe, change the client
secret or navigate. A wallet sheet or a 3-D Secure challenge is open inside the
frame; destroying the frame kills it and leaves the prepared payment at INIT
with no provider. Defer cart-driven intent changes until the window closes.

`loc` must be the URL of **your page** (the top window): the payment platform
needs it to allow its 3-D Secure page to be framed inside yours. Left out, the
widget falls back to its own iframe URL and the challenge cannot be shown in
your page — always send it. Send `init` within **8 seconds** of `ready`, or the
widget goes blank. The widget accepts `init` only from a registered domain, the
first `init` with a `clientSecret` wins, and later messages target the origin
`init` came from.

---

## 9. Hosted checkout pages

Each dashboard product has a page at
`https://checkout.securepayapi.com/<productId>`. The page shows the product,
applies coupon codes, offers Apple Pay / Google Pay where supported, runs 3-D
Secure and captures. Amounts are recomputed on the server from the product and
the coupon. A product created while the dashboard is in **Sandbox** has a
sandbox page (a "Test mode" banner, no real charge); a live product always
charges live — the URL cannot change that. There is no API for products; they
are created in the dashboard. Payment Intents are not involved.

---

## 10. Webhooks

Configured in the dashboard, Developer → Webhooks: an absolute `https://`
URL (≤ 2048 chars; default port; no credentials or fragment; public hostname
with a dot or public IP, resolving to public addresses only — `localhost`,
`.local`, `.internal` and private ranges are refused; redirects not followed).
On save a **signing secret** (64 hex chars) is generated and shown **once**;
saving again generates a new one. Webhooks become available once the account
is activated. One endpoint and one secret serve live and sandbox; split on
`env`.

Webhooks describe the **transaction** on the payment platform:

```http
POST /your/webhook HTTP/1.1
Content-Type: application/json
User-Agent: SecurePayAPI-Webhook/1.0
X-Timestamp: 1789123456
X-Signature: 3f1c…e9a0
```

```json
{
  "id": "LU494ZZ07K7814E3",
  "created": 1789123456789,
  "status": 1,
  "statustext": "PAID",
  "statushistory": [ { "status": 16, "statustext": "PAYING" }, { "status": 1, "statustext": "PAID" } ],
  "famt": 20,
  "currency": "USD",
  "env": 0,
  "externalid": "shop-123-2026-10-28",
  "paymenttype": 1, "paymenttypetext": "ECOM",
  "payment_channel": 1, "payment_channel_text": "ecommerce",
  "funding_method": 1, "funding_method_text": "card",
  "merchantid": "…"
}
```

| Field | Meaning |
| --- | --- |
| `id` | transaction reference = the intent's `transaction_reference` |
| `externalid` | the intent's `externalid`, else the intent `id` |
| `status`, `statustext` | code and key — table below |
| `statushistory` | every status folded into this delivery, oldest first |
| `famt`, `currency` | major units and currency |
| `env` | `0` live, `1` sandbox |
| optional | `invoice`, `order`, `three_ds_status`, `liability_shift_status`, `three_ds_eci`, `refunded_presentment_amount`, `pending_refund_presentment_amount`, `partial_refund_count` |

There is no event id and no event type on payment webhooks. Do not rely on
`created`; read the transaction for timestamps.

### Verify

```text
X-Signature = hex( HMAC-SHA256( key = signing secret, message = X-Timestamp + raw body ) )
```

```js
const { createHmac, timingSafeEqual } = require('node:crypto');
function isValidWebhook(req, rawBody) {            // rawBody: exact bytes, never re-serialised
  const ts = req.headers['x-timestamp'];
  const sig = req.headers['x-signature'] || '';
  const expected = createHmac('sha256', process.env.SECUREPAY_WEBHOOK_SECRET).update(ts + rawBody).digest('hex');
  const valid = sig.length === expected.length && timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex'));
  return valid && Math.abs(Date.now() / 1000 - Number(ts)) <= 300;   // the window is yours to enforce
}
```

### Answer, retries, ordering

- Answer any **2xx within 10 seconds** (total, including DNS and TLS; 5 s idle
  socket; response body ≤ 64 KB, otherwise ignored). Anything else (including
  a redirect, a timeout or an oversized body) is retried: after failures 1–2
  in 5 s, 3 in 10 s, 4 in 30 s, 5 in 1 min, 6 in 10 min, 7–8 in 30 min, 9–14 in
  60 min; given up after the 15th (~7 hours). A host that no longer resolves to
  a public address fails at once with no retries. Exhausted deliveries are not
  replayed automatically.
- A delivery is also sent when only the payout state (`deliverystatus`)
  changes or a refund is confirmed, so the same `status` can arrive twice.
  `statushistory` may contain repeats. Always present besides the fields shown:
  `sent`, `delivery`, `deliverystatus`, `deliverystatustext`,
  `externalReference` (= `externalid`); `order` is `{ id, version }`.
  `paymenttypetext` is the channel (`ECOM`/`POS`/`WIRE`/`ACH`), not the scheme.
  `created` is milliseconds here and seconds on `GET /transactions/{id}`.
- **Coalescing:** a newer status merges into a delivery still queued — read
  `statushistory`.
- **No ordering:** across retries an older state can arrive after a newer one.
  Never move an order backwards from a final status.
- **Duplicates:** make handling idempotent per `(id, status)`.
- Sent from `157.230.218.195`.

### Transaction statuses

| Code | Key | Meaning |
| --- | --- | --- |
| 10 | `INIT` | payment opened |
| 11 | `DDC` | device data collection (3-D Secure) |
| 12 | `AUTH3DS` | authentication started |
| 13 | `AUTH3DSCHALLENGE` | challenge required |
| 14 | `AUTH3DSCHALLENGEFORM` | challenge form presented |
| 15 | `AUTH3DSRESULT` | authenticated — about to capture |
| 16 | `PAYING` | capture submitted, outcome not known |
| 1 | `PAID` | **captured** |
| 20 | `SETTLED` | paid and settled |
| 3 | `FAILED` | declined/failed — final |
| 5 | `CANCELED` | cancelled — final |
| 34 | `REFUNDPENDING` | refund submitted |
| 9 | `REFUNDED` | fully refunded |
| 21–23 | `INFORMATIONREQUESTED`, `INFORMATIONRESPONDED`, `INFORMATIONEXPIRED` | issuer information request |
| 24–27 | `CHARGEBACKOPEN`, `CHARGEBACKREVERSED`, `CHARGEBACKWON`, `CHARGEBACKLOST` | chargeback |
| 28–30 | `PREARBITRATIONOPEN`, `PREARBITRATIONWON`, `PREARBITRATIONLOST` | pre-arbitration |
| 31–33 | `SCHEMEARBITRATIONOPEN`, `SCHEMEARBITRATIONWON`, `SCHEMEARBITRATIONLOST` | scheme arbitration |
| 2, 4, 6, 7 | `DELIVERED`, `DELIVERING`, `DELIVERYPENDING`, `DELIVERYUNKNOWN` | legacy payout codes on old transactions — treat as paid |

Final for the payment attempt: `PAID`, `SETTLED`, `FAILED`, `CANCELED`. Every
status from `PAID` onward (settlement, hold, refund, dispute) means the money
was taken at some point.

### Subscription events

Agreements emit their own events, signed with the same secret through the same
queue (same retries; a replay with the same `id` merges while one is pending),
each with an `id` to deduplicate on and a `type`. The complete set:
`subscription.paused`, `subscription.resumed`,
`subscription.cancellation_scheduled`, `subscription.cancelled` (with
`effectiveAt`); `subscription.payment_method_recovered`,
`subscription.payment_method_recovery_failed` (with `provider`,
`providerReference`, `reason`); `subscription.renewal_paid`,
`subscription.renewal_recovered`, `subscription.renewal_retry_scheduled`,
`subscription.renewal_recovery_scheduled`, `subscription.renewal_past_due`,
`subscription.renewal_skipped`, `subscription.renewal_outcome_unknown` (with
`amount`, `currency`, `periodStart`, `periodEnd`, `attemptNumber`, `outcome`,
`refusalCode`, `nextRetryAt`, `retryReason` as applicable);
`subscription.plan_version_created`, `subscription.plan_archived`,
`subscription.plan_reactivated` (with `planExternalReference`, `version` — no
subscription reference). There is **no** `subscription.activated` or
`subscription.started` webhook: observe activation by reading the agreement.
Fields include `subscriptionExternalReference`,
`environment` (`0` live / `1` sandbox), `processorConfigId`, `paymentId`,
`status`, `occurredAt`.

---

## 11. Read a transaction — `GET https://app.securepayapi.com/api/v1/transactions/{id}`

`Authorization: Bearer sp_secret_…`. `{id}` is the transaction reference
(`transaction_reference` / webhook `id`), **not** the intent id. Returns the
platform's record verbatim: `id` (the only identifier — there is no `uuid`),
`created` (Unix **seconds**), `status` (integer code), `statustext` (label such
as `Paid`, `Settled` — but for `FAILED` it is the **decline reason**, so code on
`status`), `amountpaid`, `currencypaid`, `usdpaid` (USD reporting amount, not
settlement), `usdfee`, masked `card`, `cardholder`, `cardcountry`, `cardbank`
(nullable), `card_brand` (normalized scheme or `null`), `payin_method`
(`card`/`googlepay`/`applepay`/`usdc`/`wire`/`ach`/`paypal`/`unknown`),
`payment_network` (confirmed executed network or `null`; never inferred from brand), `paymenttypetext` (channel `ECOM`/`POS`/…,
not the scheme), `externalid` (= `externalReference`), `product` (the SKU or
`null`), `order` (`{ id, version }` or `null`), `invoice`, refund fields
(`refunded_presentment_amount`, `pending_refund_presentment_amount` as decimal
strings, `partial_refund_count`, `refunds[]`), `chargeback` (`true` only for
statuses 24–33), and more. Errors: `401` bad key (also: account not yet
activated on the platform), `403` account not active, `404` not found **or**
another merchant's transaction **or** malformed id (`[A-Za-z0-9_-]{1,64}`)
**or** the platform unreachable — retry a `404` for a reference you know.

---

## 12. Sandbox and testing

- Create the intent with `"mode": "sandbox"`. Everything else is identical.
  The widget shows a **Test mode** badge; `GET` returns `"mode": "sandbox"`;
  webhooks carry `"env": 1`.
- Test cards — cardholder must be **`Jonathan Suit`**, any future expiry
  (e.g. `03/2030`), CVC `737`:

| Card | Behaviour |
| --- | --- |
| `4111 1111 1111 1111` | approves straight through |
| `5201 2815 0512 9736` | frictionless 3-D Secure |
| `4212 3456 7891 0006` | forces a 3-D Secure challenge |
| `4917 6100 0000 0000` | 3-D Secure without liability shift |

  CVC `736` simulates a decline.
- Subscriptions in sandbox need a plan on the **sandbox** processor
  configuration and recurring billing enabled there.
- Scenarios to pass: frictionless; challenge (completes inside the iframe);
  decline (intent stays payable, customer retries); `status: "pending"` handled
  without a second charge; server confirmation rejects a mismatched amount and a
  sandbox intent in a live flow; webhook signature verification on the raw
  body; a replayed webhook processed once; an out-of-order webhook does not move
  the order backwards; subscription setup ends `pending_activation` and access
  is granted only after activation.

---

## 13. Rules per endpoint — retry and idempotency

| Call | Retry? | Notes |
| --- | --- | --- |
| `POST /payment-intents` | not blindly — no idempotency key | on a timeout, check your own store before creating another; a duplicate intent is harmless only if you never hand both to the customer |
| `GET /payment-intents/{id}` | yes, freely | read-only |
| widget `submit()` | never call twice while pending | one promise per attempt; it settles exactly once |
| after `status: "pending"` | poll `GET` | never start a new payment for the same order |
| webhook handling | idempotent per `(id, status)` | re-deliveries and resends happen |
| `GET /transactions/{id}` | yes, freely | source of truth after the payment |
| new intent on a cart change | only while **no** payment is active | queue it from `payment_started` until `payment_ended`/`success`/`error`, then create the intent and remount |
| `POST /subscription-plans` | yes | replay by `externalReference` is safe when the facts are unchanged |
| `GET /subscriptions…`, `GET /subscription-plans…` | yes, freely | read-only |
| `POST /subscriptions/cycles` | yes, **with the same `idempotencyKey`** | a new key for the same period is `409`; never bill a period twice |
| `POST /subscriptions/cancel`, `pause`, `resume` | yes, with the same key | `409` while a charge is in flight → wait and retry, do not assume the outcome |

---

## 14. Pre-launch checklist

- Secret key only on the server; publishable key only in the browser; no key
  in a repository.
- Intent created server-side with the order's exact `amount`/`currency`; `id`
  stored on the order before the `client_secret` reaches the browser.
- `mode` set explicitly per environment; sandbox tests never promoted to live
  by a page flag.
- Fulfilment happens only after `GET /payment-intents/{id}` shows
  `succeeded` with matching `amount`, `currency`, `mode` and the stored `id`.
- `status: "pending"` shows "being confirmed" and triggers server polling, not
  a retry.
- Webhook endpoint verifies `X-Signature` on the raw body with the signing
  secret, answers 2xx within 10 s, is idempotent, and never moves an order
  backwards.
- For subscriptions: `termsHash` computed from the exact `termsText`;
  `shopperExternalReference` is a customer id, not an email; `billingAnchor`
  carries an offset and is at least an hour out; access granted only after the
  agreement is active.
- The checkout page runs on a registered domain over HTTPS; `loc` is the real
  page URL when wiring the iframe yourself.
- The page never removes or re-creates the widget, swaps the `clientSecret` or
  navigates while `onActive` is `true` (between `payment_started` and
  `payment_ended`/`success`/`error`); cart-driven intent changes are queued.
- Support tooling reads `last_attempt` before calling a payment "stuck": an
  INIT `transaction_reference` after a wallet decline is the new attempt, not a
  lost one.
- Logs contain intent ids and transaction references — never `client_secret`,
  keys, card data or consent text.

## Hosted invoice bank and USDC options

The [Bank Pay-ins](/bank-payins) and [USDC pay-ins](/usdc-payins) guides describe
the ordinary hosted invoice customer path. Do not generate bank or USDC as
Payment Intents or SDK capabilities. Use only offered methods and returned
network/token/address/amount/expiry instructions. Refreshing an invoice does not
renew USDC instructions. Never fulfill from an instruction or wallet submission;
confirm the canonical result. Private merchant contracts remain in the separate
direct-integration documentation.
