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.
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.
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.
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.
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.
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.
|
// 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. {
"data": {
"sessionId": "a33b3ff6-1f2a-4f81-99d5-299d22083a9a",
"providerId": "your-provider-instance",
"environment": "sandbox"
},
"meta": { "requestId": "..." }
} /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.
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.
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>
);
} 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.
{
"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) settlescomplete()toerror.code: 'three_ds_timeout'on its own. -
Calling
cancelChallenge()(also fromuseSecureCard()) abandons an in-progress challenge witherror.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.
# 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 rejectedvault_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. Refusedpayment_method_not_found. - Saved card on a guest checkout —
payment_method_requires_customer. - Empty or unpriced cart — minting the card session fails
amount_unavailablerather 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. |
/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.
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.