Checkout surfaces

Mount Card Fields On Your Own Page

Secure Fields gives you individually mountable, provider-hosted inputs — card number, expiry, security code, postal code — that you place in your own form and style yourself. Raw card data never touches your page or ours; your server never sees it either.

Card only
Secure Fields has no PayPal, Apple Pay, or Google Pay. It is a card-capture surface, full stop — that is a property of the product, not a v1 gap. A merchant who needs those payment methods keeps using Embedded Checkout or hosted checkout alongside Secure Fields for the card-only case.

How it works

Four steps, and the only server-to-server call is the mint. Everything after that is either a mount inside the buyer's browser or the same public completion route the Embed and hosted checkout already use.

1

Your server mints a card session

POST /api/v1/checkout-sessions/:id/card-session with your sk_ secret key, against an existing Throttle checkout session. Throttle pins the buyer (if the session has a customer) and returns a short-lived the payment provider session — no credential, just an id.

2

Hand the session to the page

Pass the response straight into <SecureCardProvider session={...}>. It mounts one iframe per field on the payment provider’s origin; raw card data goes straight from the buyer’s browser to the payment provider and never touches your page or ours.

3

pay() vaults, then completes

useSecureCard().pay() submits the fields (CARD_VAULT_SUCCESS binds the card to the session server-side, with no payload), then calls the public POST /checkout-sessions/:id/complete — the same route the Embed and hosted checkout already use for orders, payments, and webhooks.

4

Handle requires_action if it comes back

A payment that needs 3-D Secure returns status: ‘requires_action’ instead of failing. The hook drives the payment provider’s own challenge iframe to a result and re-completes automatically — you only need to register a mount element.

Mint the session

Call this from your backend, with your sk_* secret key. Never call it from the browser — there is no publishable-key variant.

Field Notes
POST /api/v1/checkout-sessions/:id/card-session :id is an existing Throttle checkout session (create one first with the cart/checkout APIs, exactly as you would for the Embed).
Auth X-API-Key secret key with the checkout_sessions:write scope.
Response { sessionId, providerId, environment } — pass this object straight into <SecureCardProvider session={...}> unchanged.
Backend — mint a card session
// Your backend — mint a Secure Fields card session for an existing checkout session
const res = await fetch(
  `https://api.usethrottle.dev/api/v1/checkout-sessions/${checkoutSessionId}/card-session`,
  {
    method: 'POST',
    headers: { 'X-API-Key': process.env.THROTTLE_SECRET_KEY! },
  },
);
const { data: session } = await res.json();
// session: { sessionId, providerId, environment }
// Hand this object to <SecureCardProvider session={session} /> unchanged.
Response — 200 OK
{
  "data": {
    "sessionId": "a33b3ff6-1f2a-4f81-99d5-299d22083a9a",
    "providerId": "your-provider-instance",
    "environment": "sandbox"
  },
  "meta": { "requestId": "..." }
}
Complete against the card session minted for that checkout session
Throttle records every card session it mints on the checkout session it was minted for (the newest 20 are kept). /complete authorises only against one of those; a card session minted for another checkout session, or one Throttle did not mint for it, answers 409 transaction_not_for_session before any order or authorisation, with details.retryable: false. Mint a new card session for this checkout session and mount the fields again. A card session minted before 2026-10-05 is refused once in the same way.

Content Security Policy

The card inputs are iframes served from Throttle's payment provider, not from your own domain. If your checkout sends a Content-Security-Policy header, the fields render as blank boxes until you allow that origin — usually with no error in the page, only a CSP violation in the browser console.

Allow the provider origin in your CSP
Content-Security-Policy:
  frame-src   https://*.gr4vy.app;
  connect-src https://*.gr4vy.app https://api.usethrottle.dev;

frame-src lets the iframes mount; connect-src covers the calls they make. If you also send frame-ancestors elsewhere, it does not apply here — that directive governs who may frame you, not whom you may frame.

React example

@usethrottle/checkout-react ships a headless provider, a hook, and four unstyled field primitives. Style everything yourself — the whole point of choosing Secure Fields is that you own the pixels around the fields.

Card checkout form
import {
  SecureCardProvider,
  useSecureCard,
  CardNumberField,
  ExpiryField,
  SecurityCodeField,
  PostalCodeField,
  type SecureCardSession,
} from '@usethrottle/checkout-react';

// `session` is the Task 2 mint response, fetched server-side and passed down.
// `checkoutSessionId` is YOUR Throttle checkout session id — the ":id" you
// minted against, NOT session.sessionId (that one is the payment provider's). See below.
export function CardCheckout({
  session,
  checkoutSessionId,
}: {
  session: SecureCardSession;
  checkoutSessionId: string;
}) {
  return (
    <SecureCardProvider
      session={session}
      checkoutSessionId={checkoutSessionId}
      apiBaseUrl="https://api.usethrottle.dev"
      onSuccess={(result) => {
        window.location.href = `/thank-you?order=${result.orderId}`;
      }}
    >
      <PayForm />
    </SecureCardProvider>
  );
}

function PayForm() {
  const { ready, status, error, pay, registerThreeDSecure } = useSecureCard();
  const busy = status === 'vaulting' || status === 'authorizing' || status === 'challenging';

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        pay();
      }}
    >
      <CardNumberField className="my-input" />
      <ExpiryField className="my-input" />
      <SecurityCodeField className="my-input" />
      <PostalCodeField className="my-input" />

      {/* Mount point for the payment provider's own 3-D Secure challenge iframe. Register it
          once — it only actually mounts if a payment comes back requires_action. */}
      <div ref={(el) => { if (el) registerThreeDSecure(el); }} />

      <button disabled={!ready || busy}>
        {status === 'challenging' ? 'Confirming with your bank…' : busy ? 'Paying…' : 'Pay'}
      </button>
      {error && <p role="alert">{error.message}</p>}
    </form>
  );
}
Two ids, two different systems — do not mix them up

session.sessionId (from the mint response) is the payment provider's checkout session id. It is what constructs the SecureFields instance and mounts the field iframes.

checkoutSessionId (the prop you pass to <SecureCardProvider> separately) is Throttle's checkout session id — the same :id you minted against in the step above. It is what pay() uses to call POST /checkout-sessions/:id/complete.

Swap them and every payment breaks: the fields will mount and take input fine (they only need the payment provider's id), but complete() will call /checkout-sessions/{provider id}/complete, which does not exist on Throttle's side, and every payment 404s at the very last step.

Status

useSecureCard() exposes an explicit state machine. Render any of it — there is no need to infer state from side effects.

Status What it means What to render
idle Provider mounted; fields not yet registered. A loading state over the field area.
mounting The Secure Fields instance exists; individual fields are registering. Same loading state — fields are not yet interactive.
ready All required fields (number, expiry, CVV) are registered. Enable the Pay button. ready is also exposed as its own boolean.
vaulting pay()/vault() called; waiting on the payment provider to accept or reject the card. Disable the button: “Saving card…”
authorizing The card vaulted; Throttle's /complete call is in flight. Disable the button: “Processing payment…”
challenging /complete returned requires_action; the payment provider's own 3-D Secure iframe is mounted at your registered element and running. Make the registered mount element visible to the buyer: “Confirming with your bank…”
complete The order was created. onSuccess already fired. Navigate away, or show a confirmation.
error Vaulting, the challenge, or completion failed. Read error.code / error.message (see below) and let the buyer retry.

3-D Secure

A payment that needs step-up authentication does not fail — complete() resolves with status: 'requires_action' instead of an order. pay() handles this for you.

Server response — requires_action
{
  "status": "requires_action",
  "paymentStatus": "requires_action"
}
// No orderId / paymentId yet — this is not a completion. useSecureCard()
// handles this response for you; you will only ever see it if you call
// complete() yourself instead of pay().

When that happens, the hook moves to challenging and drives the payment provider's own 3DS iframe (mounted at the element you passed to registerThreeDSecure) to a result, then re-calls /complete for the authoritative outcome. Register the mount element alongside your card fields — it only actually mounts a challenge when one is needed, so it costs nothing to always include it.

  • If the buyer never resolves the challenge, challengeTimeoutMs (5 minutes by default, overridable via the same-named <SecureCardProvider> prop) settles complete() to error.code: 'three_ds_timeout' on its own.
  • Calling cancelChallenge() (also from useSecureCard()) abandons an in-progress challenge with error.code: 'three_ds_abandoned'.
  • If the challenge resolves but the payment still needs authentication, or if the buyer keeps retrying without ever clearing it, Throttle caps re-entry at 5 attempts and returns 409 three_ds_unresolved. Treat this as terminal: tell the buyer to start a new checkout rather than retrying the same session again.

Saved cards

Whether a card gets saved is decided entirely by the checkout session you minted the card session against — nothing the browser sends can change it.

  • If that Throttle checkout session carries a customerId (set directly, or inherited from its cart), Throttle pins the the payment provider buyer at mint time. A successful payment vaults the card against that customer, and it appears in their saved payment methods on the next read.
  • A session with no resolvable customer mints and charges as a guest. The payment still completes normally; nothing is saved, and that is correct behaviour, not a degraded path.

Testing failures

The success path is the easy half. These are the failures worth driving before you go live, and how to cause each one on purpose.

Declines

Sandbox card simulators generally approve everything, so you cannot get a decline by picking a “bad” card number. Cause one with a decline rule instead — the same mechanism you would use in production to refuse high-risk traffic. Conditions can match amount, currency, country, card scheme, card type or metadata.

Make any charge over $99 decline
# Decline every card charge over $99 on this application.
# Routing -> Decline rules in the dashboard does the same thing.
curl -X PUT https://api.usethrottle.dev/api/v1/card-decline \
  -H "X-API-Key: $THROTTLE_SECRET_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"rules":[{"conditions":[{"type":"amount","op":"gt","currency":"USD","min":99}],"errorCode":"flow_high_value"}]}'

# Remove it again when you are done.
curl -X PUT https://api.usethrottle.dev/api/v1/card-decline \
  -H "X-API-Key: $THROTTLE_SECRET_KEY" \
  -H 'Content-Type: application/json' -d '{"rules":[]}'

A charge matching the rule comes back card_declined, and the message carries your own errorCode so you can tell your rules apart from a real issuer decline.

A declined session stays usable. The claim is released, the pending order is reused rather than duplicated, and the buyer can submit again with another card. Retrying a declined session three times creates exactly one order.

3-D Secure

A challenge does not happen because of the card number alone. It needs the card session to carry the amount (it always does), 3-D Secure enabled on the payment service, and a 3DS scenario matching the card or amount. Scenarios are sandbox-only. Without one configured, every card behaves as if no challenge were required — including the PANs other processors treat as 3DS test cards.

Once configured, drive: the challenge completing (complete), the buyer abandoning it (cancelChallenge() → three_ds_abandoned), and the buyer walking away (three_ds_timeout after challengeTimeoutMs).

The rest

  • Double submit — call pay() twice quickly. The second is rejected vault_in_progress; the first is never orphaned.
  • Session already paid — complete twice. The second is already_completed.
  • Saved card that is not theirs — pass another customer's paymentMethodId. Refused payment_method_not_found.
  • Saved card on a guest checkout — payment_method_requires_customer.
  • Empty or unpriced cart — minting the card session fails amount_unavailable rather than producing a session that silently cannot do 3DS.
  • Strict CSP — the fields render as blank boxes. See Content Security Policy above.

Error reference

Errors surface as { code, message } — from a rejected vault()/complete()/pay() promise, or as useSecureCard().error after the state machine moves to error.

Minting (your server, calling /card-session)

Status Code When it fires
400 environment_required The request carried no resolvable environment context.
404 not_found The checkout session id does not exist for this application/environment.
422 no_payment_connection No active payment connector is configured for this application/environment.
500 card_session_failed Generic fallback for anything else that goes wrong minting the session.

Field mounting and vaulting (the hook, in the browser)

Code When it fires
not_ready vault() was called before the required fields finished registering. Gate your submit button on ready.
vault_in_progress A second vault()/pay() call was made while one was already in flight. Disable the button while status is vaulting or later.
card_vault_failed The payment provider rejected the card during vaulting with no more specific code of its own.
card_declined, card_invalid, card_expired the payment provider's own vault-time validation echoed straight through. No order is ever attempted — the card never left vaulting.

3-D Secure (the hook)

Code When it fires
three_ds_element_missing /complete returned requires_action but registerThreeDSecure() was never called. Always register the mount element alongside your card fields.
three_ds_abandoned Your own code called cancelChallenge() mid-challenge.
three_ds_timeout challengeTimeoutMs elapsed with no result.
three_ds_failed The challenge could not be started or run at all — the payment provider's addThreeDSecure() threw, or a custom resolveChallengeFn rejected. A rejection that carries its own code is surfaced under that code instead.
three_ds_unresolved The challenge finished but the payment still needs authentication, or the server's 5-attempt re-entry cap was hit (409). Terminal — start a new checkout.

Completion (surfaced from /complete)

Status Code When it fires
402 payment_declined The processor declined the authorization. message carries the processor's reason when one is available.
409 checkout_in_progress A concurrent complete() call for the same session won the race. This call lost it safely — no double charge.
409 payment_processing A pending order and a processing payment exist for an authorization in flight for a non-3DS reason (e.g. a settling bank debit). Safe to poll or retry shortly.
409 transaction_not_for_session The card session was not minted for this checkout session (see Mint the session). Not retryable: mint a new card session and mount again.
409 checkout_total_changed An order from an earlier attempt on this cart already has a payment at another total (details.orderTotal). An earlier order with no money on it is re-totalled to the current cart instead.
409 tax_calculation_failed Calculated tax is recalculated at completion on this rail, and that calculation failed. Nothing was charged.
409 trial_not_available The checkout grants a free trial and the card already had one on this merchant. Checked after the authorization and before capture; a non-zero authorization is voided.
422 session_expired The checkout session's TTL passed, or it was already used.
422 already_completed The checkout session was already completed by an earlier request.
422 address_required The session needs a shipping (and/or billing) address before it can complete; details names which.

Fallback codes

Code When it fires
complete_failed complete() failed for a reason that carried no code of its own.
payment_failed The /complete error response itself carried no error.code.
A $0 checkout makes no processor call
When a one-off checkout's total is $0 (for example a 100% code), /complete does not authorise or capture anything: the order is paid at $0 with a captured $0 payment, no card is saved and the invoice shows no card. A recurring $0 checkout (a trial) still authorises $0 to save the card. See Completion errors for every completion refusal.
Charging still goes through /complete
Only the mint above needs a secret key. POST /checkout-sessions/:id/complete is already public (session-id-bearer, status-gated, time-limited) — the hook calls it directly from the browser, the same way the Embed and hosted checkout already do. No new browser-facing credential exists anywhere in this design.