Hosted Fields SDK
Embed secure card fields in your own checkout page
Overview
Hosted Fields lets you keep your own checkout page and your own design, while the card number, expiry and CVC are typed into iframes we serve.
The card never reaches your page's JavaScript, your servers or your logs — your page
only ever holds a client_secret, which is scoped to a single payment and expires
with it. This is what keeps you in SAQ A-EP rather than SAQ D.
Prefer not to embed anything? Create the same session and redirect the customer to
hosted_page_url instead — see Payment flow. Same API, same webhooks,
SAQ A.
How it fits together
your server ──POST /v2/payment-sessions──▶ gateway (secret API key)
│ │
└──client_secret──▶ your page │
│ │
SDK mounts iframes ◀───────┘ (js from our origin)
│
customer types the card
│
confirmPayment() ──▶ tokenize + chargeThree actors, and only the first one uses your API key:
- Your server creates the session and receives a
client_secret. - Your page loads the SDK and mounts the fields.
- The iframes hold the card and talk to us directly.
1. Create a session (server side)
curl -X POST https://api.settleflow.io/v2/payment-sessions \
-H "X-Api-Key: $SETTLEFLOW_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"amount": 4999,
"currency": "EUR",
"reference": "order_12345",
"capture_mode": "automatic",
"payment_method": { "type": "card" },
"customer": { "email": "buyer@example.com" }
}'Omit payment_method.card_data — the card is collected client-side. The response
carries the client_secret:
{
"id": "pr_110a64c9aad345b2b355b2151ad",
"status": "pending",
"client_secret": "pr_110a64c9aad345b2b355b2151ad_secret_28a2e608b0f3e502",
"hosted_page_url": "https://pay.settleflow.io/pr_110a…?token=…",
"expires_at": "2026-07-27T16:30:00.000Z"
}Pass only the client_secret to your page. Never the API key.
2. Load the SDK
<script src="https://js.settleflow.io/sdk-hosted-fields.js"></script>The script defines a single global, HostedFields. It is served from a CDN
and picks up fixes automatically. If your compliance process requires pinning
an exact build, load the immutable versioned path instead —
https://js.settleflow.io/<version>/sdk-hosted-fields.js — with the
integrity hash published in the release notes; the content behind a
versioned URL never changes.
The previous URL (https://api.settleflow.io/hosted/sdk/v1/sdk-hosted-fields.js)
keeps working for existing integrations, but new integrations should use the
CDN URL above.
3. Mount the fields
<form id="checkout">
<label for="card-number">Card number</label>
<div id="card-number"></div>
<label for="card-expiry">Expiry</label>
<div id="card-expiry"></div>
<label for="card-cvc">CVC</label>
<div id="card-cvc"></div>
<button type="submit">Pay</button>
</form>const hf = HostedFields("https://api.settleflow.io");
const elements = hf.elements({ clientSecret });
const cardNumber = elements.create("cardNumber");
const cardExpiry = elements.create("cardExpiry");
const cardCvc = elements.create("cardCvc");
cardNumber.mount("#card-number");
cardExpiry.mount("#card-expiry");
cardCvc.mount("#card-cvc");Each container is replaced by an iframe that fills it — give the container the height and border you want; the input inside is transparent.
Field types: cardNumber, cardExpiry, cardCvc. Each can be created once per
elements instance.
4. Confirm the payment
document.getElementById("checkout").addEventListener("submit", async (e) => {
e.preventDefault();
const { paymentSession, error } = await hf.confirmPayment({
elements,
confirmParams: { return_url: "https://shop.example.com/order/12345/complete" },
});
if (error) {
showError(error.message);
return;
}
if (paymentSession.actionUrl) {
// The payer has something to answer before this card can be charged —
// today, which currency to be charged in. Send them there; they come back
// through your return_url.
window.location.href = paymentSession.actionUrl;
return;
}
if (paymentSession.threeDSecureUrl) {
// Strong authentication required — send the browser there and it will be
// handed on to the issuer.
window.location.href = paymentSession.threeDSecureUrl;
return;
}
// status: "captured" | "authorized" | "failed"
window.location.href = "/order/12345/complete";
});confirmPayment() collects the card from the iframes, tokenizes it and submits the
payment in one round-trip. It resolves with either paymentSession or error —
never both, and it never throws for a declined card.
The result is a snapshot taken at confirm time. Treat the webhook as the source of truth
before you fulfil an order — or re-read the payment from your own server. This matters most after
a 3-D Secure round-trip, which returns the customer to your return_url without passing back
through your JavaScript.
Customisation
Styling is per element, passed at creation. You control the typography and colour of the input; the container around it is yours to style with ordinary CSS.
const cardNumber = elements.create("cardNumber", {
placeholder: "4242 4242 4242 4242",
style: {
color: "#1a1a1a",
fontSize: "16px",
fontFamily: "Inter, system-ui, sans-serif",
fontWeight: "500",
letterSpacing: "0.02em",
"::placeholder": { color: "#9ca3af" },
},
});| Option | Type | Notes |
|---|---|---|
placeholder | string | Placeholder text for that field. |
style | object | Typography and colour applied inside the iframe. |
Supported style keys: color, fontSize, fontFamily, fontWeight,
letterSpacing, and ::placeholder.color. Anything else is ignored — the iframe
accepts a fixed set of properties rather than arbitrary CSS, which is what stops a
compromised page from restyling a field into a decoy.
Layout, borders, focus rings, error text and spacing stay in your own stylesheet, applied to the container elements.
Events
cardNumber.on("ready", () => console.log("field mounted"));
cardNumber.on("change", ({ complete, empty, error, brand }) => {
setBrandIcon(brand); // "visa" | "mastercard" | "amex" | …
setFieldError(error); // e.g. "Invalid card number"
setSubmitEnabled(complete);
});
cardNumber.on("focus", () => setFocused(true));
cardNumber.on("blur", () => setFocused(false));| Event | Payload |
|---|---|
ready | { fieldType } — the iframe is mounted |
change | { fieldType, complete, empty, error?, brand? } |
focus | { fieldType } |
blur | { fieldType } |
Track complete across all three fields to decide when to enable your submit
button. Validation happens inside the iframe, so error is the message to show —
your page never sees the digits behind it.
Errors
confirmPayment() resolves with an error object rather than throwing:
{ type: "card_error", message: "Your card was declined", code: "card_declined" }type | Meaning |
|---|---|
validation_error | A field is incomplete or invalid — surface it and let them retry. |
card_error | The issuer declined. message is safe to show as-is. |
api_error | The request failed on our side. |
network_error | The browser could not reach us. |
timeout_error | No answer within 30 seconds. The payment may still have gone through — check it before retrying. |
Some failures also carry a reason — a stable machine-readable cause:
{ type: "api_error", message: "…", code: "…", reason: "…" }Branch on reason, never on message: the message is prose, and it is subject
to rewording and translation. reason is absent when the failure has no typed
cause, so test for the value you care about rather than for its presence.
Currency conversion
Nothing to integrate. If the payer's card is issued in another currency, they
must be OFFERED the choice — card-scheme rules, and Regulation (EU) 2019/518 for
the disclosure. That conversation does not belong in a card form, so it does not
happen here: confirmPayment() answers with an actionUrl, you redirect,
the payer chooses on a page we host, and they return through your return_url.
Handle it exactly as you handle threeDSecureUrl — same shape, one step
earlier. No currency, rate or amount ever passes through this SDK.
threeDSecureUrl and actionUrl are both URLs on the API host, never a provider's or an issuer's
own address. Navigate to whichever is set as-is — do not build a Content-Security-Policy or a
redirect allow-list around a third-party domain, and do not store either URL: both are
short-lived.
Cleaning up
In a single-page app, destroy the elements when the checkout unmounts — otherwise a stale controller iframe keeps listening for a session that no longer exists:
[cardNumber, cardExpiry, cardCvc].forEach((el) => el.destroy());Testing
The examples above use the live host. Swap it for the sandbox one while you
integrate — https://api.sandbox.settleflow.io, with a pk_test_… key — and
load the SDK from that same origin. A test key is refused on the live host and
vice versa.
Test cards: 4242 4242 4242 4242 captures, 4000 0000 0000 0002 declines,
4000 0000 0000 3220 triggers a 3-D Secure challenge so you can exercise the
threeDSecureUrl branch.
Reference
| Call | Returns |
|---|---|
HostedFields(origin) | SDK instance |
hf.elements({ clientSecret }) | Elements container |
elements.create(type, options?) | Element |
element.mount(selector | node) | — |
element.on(event, handler) | — |
element.destroy() | — |
hf.confirmPayment({ elements, confirmParams }) | { paymentSession } or { error } |