SettleFlow API

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 + charge

Three actors, and only the first one uses your API key:

  1. Your server creates the session and receives a client_secret.
  2. Your page loads the SDK and mounts the fields.
  3. 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" },
  },
});
OptionTypeNotes
placeholderstringPlaceholder text for that field.
styleobjectTypography 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));
EventPayload
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" }
typeMeaning
validation_errorA field is incomplete or invalid — surface it and let them retry.
card_errorThe issuer declined. message is safe to show as-is.
api_errorThe request failed on our side.
network_errorThe browser could not reach us.
timeout_errorNo 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

CallReturns
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 }

On this page