flintn-checkout
v0.0.19
Published
FlintN Payment SDK — drop-in iframe checkout for card payments and wallets with localization and theming.
Maintainers
Readme
flintn-checkout
FlintN Payment SDK — Embed payment forms via iframe checkout or headless hosted fields.
Installation
npm install flintn-checkoutNote: for local/non-production use, pass
origin(e.g.origin: 'http://localhost:3000').
Iframe Checkout
Full checkout UI rendered inside a single iframe. Includes card form, express payments (Apple Pay, Google Pay, PayPal), and 3DS handling.
React
import { useFlintNPayment } from 'flintn-checkout/react';
function Checkout() {
const { containerRef, isReady, paymentResult, events, error } = useFlintNPayment({
config: {
clientSessionId: 'your_client_session_id',
},
onPayment: (result) => {
if (result.status === 'PAYMENT_SUCCESS') {
console.log('Payment succeeded:', result.data);
} else {
console.log('Payment failed:', result.error);
}
},
onEvent: (event) => {
// Per-attempt analytics — fires for every payment attempt outcome
// (initiated, soft/hard decline, 3DS, cancel, network error, success)
// and for UI navigation in the LIST and RADIO variants (form entered/exited).
console.log('Event:', event.type, event.event);
},
});
return (
<div style={{ display: 'flex', justifyContent: 'center' }}>
<div ref={containerRef} style={{ width: '100%', maxWidth: 440 }} />
</div>
);
}Vanilla JavaScript
import { createFlintNPayment } from 'flintn-checkout';
const payment = createFlintNPayment({
config: {
clientSessionId: 'your_client_session_id',
},
onPayment: (result) => {
if (result.status === 'PAYMENT_SUCCESS') {
console.log('Payment succeeded:', result.data);
} else {
console.log('Payment failed:', result.error);
}
},
onEvent: (event) => {
console.log('Event:', event.type, event.event);
},
onReady: () => {
console.log('Widget ready');
},
debug: true,
});
payment.mount('#payment-container');
// Later, when you want to tear the widget down (e.g. on route change):
// payment.unmount();HTML
<!DOCTYPE html>
<html>
<head>
<title>FlintN Checkout</title>
</head>
<body>
<div id="payment-container" style="max-width: 440px; margin: 0 auto;"></div>
<script type="module">
import { createFlintNPayment } from 'flintn-checkout';
const payment = createFlintNPayment({
config: {
clientSessionId: 'your_client_session_id',
},
onPayment: (result) => {
console.log('Payment result:', result);
},
onEvent: (event) => {
console.log('Event:', event);
},
});
payment.mount('#payment-container');
</script>
</body>
</html>External express buttons (expressButtons: false)
By default the iframe renders express buttons (Apple Pay / Google Pay /
PayPal) inside the checkout form. Set config.expressButtons: false to keep
the iframe card-form only and render the wallet buttons directly in your own
page — full control over placement and layout, and wallet payment sheets open
from the top-level page instead of inside an iframe.
React — the hook mounts the buttons for you and merges wallet results
into the same onPayment / onEvent callbacks as the card form:
import { useFlintNPayment } from 'flintn-checkout/react';
function Checkout() {
const {
containerRef,
applePayRef,
googlePayRef,
payPalRef,
expressAvailable,
paymentResult,
} = useFlintNPayment({
config: {
clientSessionId: 'your_client_session_id',
expressButtons: false,
},
// Single callback for wallets AND the card form
onPayment: (result) => console.log('Payment:', result),
// Single per-attempt stream for wallets AND the card form
onEvent: (event) => console.log('Event:', event),
});
return (
<div style={{ maxWidth: 440 }}>
{/* Your layout, your styling. Hide with CSS while unavailable —
do not conditionally unmount the refs. */}
<div style={{ display: expressAvailable ? 'block' : 'none' }}>
<div ref={applePayRef} />
<div ref={googlePayRef} />
<div ref={payPalRef} />
<hr />
</div>
{/* Card form iframe — no express buttons inside */}
<div ref={containerRef} style={{ width: '100%' }} />
</div>
);
}Vanilla JavaScript — pair createFlintNPayment with the standalone
express module:
import { createFlintNPayment, createFlintNExpressButtons } from 'flintn-checkout';
const payment = createFlintNPayment({
config: { clientSessionId: 'your_client_session_id', expressButtons: false },
onPayment: (result) => console.log('Card payment:', result),
});
payment.mount('#payment-container');
const express = createFlintNExpressButtons({
config: { clientSessionId: 'your_client_session_id' },
onPayment: (result) => console.log('Wallet payment:', result),
onCapability: (cap) => {
// Show/hide your express section based on device availability
document.getElementById('express-row').hidden =
!(cap.applePay || cap.googlePay || cap.paypal);
},
});
express.mountExpressButtons([
{ method: 'APPLE_PAY', elementSelector: '#apple-pay' },
{ method: 'GOOGLE_PAY', elementSelector: '#google-pay' },
{ method: 'PAYPAL', elementSelector: '#paypal' },
]);Notes:
formFields.formVariantLIST/RADIOare ignored withexpressButtons: false— the express/card switch lives in your page now.- After a successful wallet payment, disable or hide the card form — the session is completed and further card submits will fail.
Events — terminal vs per-attempt
The SDK fires two streams of events. Use the one that fits your use case:
onPayment — terminal (session-final)
Fires once when the checkout session reaches a final state. Use this for redirecting, marking the order done, sending receipts, and conversion-failure analytics.
onPayment(result: PaymentResult)Fires when:
- Payment succeeds →
status: 'PAYMENT_SUCCESS' - Session cannot continue (non-retryable decline, network error, SDK error, missing payment id, 3DS failure) →
status: 'PAYMENT_ERROR'
Does not fire for soft declines, hard declines with a retryable session, or buyer cancellations — the buyer can still try another card or method.
onEvent — per-attempt analytics
Fires for every payment attempt outcome, including ones where the session continues. Use for funnel analytics, retry tracking, A/B test instrumentation, fraud signals.
onEvent(event: CheckoutEvent)CheckoutEvent is a union of two event kinds, discriminated by type (the isAttemptEvent / isUIEvent guards are also exported):
PaymentAttempt—type: 'PAYMENT_ATTEMPT_EVENT', a payment attempt outcomePaymentUI—type: 'PAYMENT_UI_EVENT', card form entered/exited (LIST and RADIO variants)
Every event carries a timestamp (epoch ms) and the clientSessionId.
For attempt events, the event discriminator describes what happened at this step:
| event | When | Terminal onPayment follows? |
|---|---|---|
| 'INITIATED' | Buyer clicked Pay / Apple Pay / PayPal and validation passed | — |
| 'THREE_DS_INITIATED' | Authorize returned THREE_DS_INITIATED; 3DS challenge is opening | — |
| 'AUTHORIZED' | Authorize succeeded | ✅ PAYMENT_SUCCESS |
| 'SOFT_DECLINE' | Decline with decline_type: SOFT_DECLINE — buyer can retry | only if session is non-retryable |
| 'HARD_DECLINE' | Decline with decline_type: HARD_DECLINE — card permanently bad | only if session is non-retryable |
| 'CANCELLED' | Buyer dismissed Apple Pay sheet / closed PayPal popup / closed 3DS dialog | — |
| 'NETWORK_ERROR' | Authorize HTTP failure, SDK crashed, missing token, unknown status | ✅ PAYMENT_ERROR |
A terminal onPayment event always fires after the corresponding onEvent for AUTHORIZED / NETWORK_ERROR / non-retryable declines / 3DS failures.
For UI events (LIST and RADIO variants), view is the view that became active:
| event | When | view |
|---|---|---|
| 'FORM_ENTERED' | Buyer opened the card form ("Credit or Debit Card" button / "Card payment" radio) | 'CARD' |
| 'FORM_EXITED' | Buyer returned to express (back arrow / "Fast checkout" radio) | 'EXPRESS' |
Example funnel — soft decline → retry success:
onEvent → { event: 'INITIATED', method: 'PAYMENT_CARD' }
onEvent → { event: 'SOFT_DECLINE', method: 'PAYMENT_CARD', code: 'FNT-EC-2003', message: 'Insufficient funds.' }
// buyer retries with another card
onEvent → { event: 'INITIATED', method: 'PAYMENT_CARD' }
onEvent → { event: 'AUTHORIZED', method: 'PAYMENT_CARD', paymentId: 'pay_...' }
onPayment → { status: 'PAYMENT_SUCCESS', data: 'pay_...' }Example — Apple Pay buyer cancel (DEFAULT variant):
onEvent → { event: 'INITIATED', method: 'APPLE_PAY' }
onEvent → { event: 'CANCELLED', method: 'APPLE_PAY', message: 'Buyer dismissed Apple Pay' }
// no onPayment — session stays alive, buyer can pick another methodExample — PayPal popup → 3DS → success:
onEvent → { event: 'INITIATED', method: 'PAYPAL' }
onEvent → { event: 'THREE_DS_INITIATED', method: 'PAYPAL', paymentId: 'pay_...' }
onEvent → { event: 'AUTHORIZED', method: 'PAYPAL', paymentId: 'pay_...' }
onPayment → { status: 'PAYMENT_SUCCESS', data: 'pay_...' }(Every event also carries type, timestamp, and clientSessionId — omitted above for brevity.)
events (React) — accumulated log
The useFlintNPayment hook also exposes events: CheckoutEvent[] — every event since mount, useful for rendering a per-attempt UI without wiring onEvent manually:
import { isAttemptEvent } from 'flintn-checkout';
const { events } = useFlintNPayment({ ... });
return (
<ul>
{events.map((e, i) =>
isAttemptEvent(e) ? (
<li key={i}>[{e.method}] {e.event} {e.code && `— ${e.code}`}</li>
) : (
<li key={i}>[UI] {e.event} → {e.view}</li>
),
)}
</ul>
);The list resets when the checkout remounts (e.g. new clientSessionId).
Container Sizing
The widget auto-sizes to fit its content — you only need to set a width on the container. Height is managed by the SDK and updates automatically as the form changes state (loading, express view, card form, success).
<div ref={containerRef} style={{ width: '100%', maxWidth: 440 }} />Do not set a fixed height on the container — it will leave empty space below the widget when content is shorter than your hardcoded value, and the iframe never scrolls internally.
Configuration
| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| config | FlintNConfig | Yes | — | Checkout configuration |
| onPayment | (result: PaymentResult) => void | No | — | Terminal session result (success / error) |
| onEvent | (event: CheckoutEvent) => void | No | — | Per-attempt analytics signal |
| onReady | () => void | No | — | Widget loaded and ready |
| onError | (error: PaymentError) => void | No | — | SDK initialization error |
| debug | boolean | No | false | Enable console debug logs |
FlintNConfig
| Property | Type | Required | Description |
|----------|------|----------|-------------|
| clientSessionId | string | Yes | Client session ID from backend |
| formFields | FormFields | No | Form display options — layout via formVariant. Field visibility and requirements are controlled by the merchant session configuration, not SDK options |
| styles | FormStyles | No | Custom styles (colors, border radius, text) for the checkout form |
| placeholders | FormPlaceholders | No | Per-field placeholder text overrides (see FormPlaceholders) |
| successRedirectUrl | string | No | Redirect URL after successful payment |
| expressButtons | boolean | No | Default true — express buttons (Apple Pay / Google Pay / PayPal) render inside the iframe form. Set false to render the card form only and mount express buttons in your own page via createFlintNExpressButtons; formFields.formVariant LIST/RADIO are ignored in that case |
React Hook Return Values
| Value | Type | Description |
|-------|------|-------------|
| containerRef | RefObject<HTMLDivElement> | Ref to attach to container element |
| isReady | boolean | Widget is loaded and ready |
| paymentResult | PaymentResult \| null | Result after terminal onPayment event (card or wallet) |
| events | CheckoutEvent[] | Per-attempt event log (accumulated since mount) |
| error | PaymentError \| null | SDK error if any |
| applePayRef | RefObject<HTMLDivElement> | Apple Pay mount point — used with expressButtons: false |
| googlePayRef | RefObject<HTMLDivElement> | Google Pay mount point — used with expressButtons: false |
| payPalRef | RefObject<HTMLDivElement> | PayPal mount point — used with expressButtons: false |
| expressAvailable | boolean | At least one wallet is available on this device/session (expressButtons: false only) |
Hosted Fields
Individual PCI-compliant input fields rendered as separate iframes. You control the layout, labels, and error display — the SDK handles card data securely.
Each field (card number, expiry, CVV) is a separate iframe. Raw card data never touches your page.
When a card payment requires 3DS, the SDK shows the bank's challenge in a
modal dialog on your page. The buyer can dismiss it, which surfaces as a
THREE_DS_CANCELLED payment error. While the challenge is open, the
submit() promise stays pending with a 10-minute timeout.
Note: Hosted Fields emit the same per-attempt analytics as Iframe Checkout — the card flow and the express buttons both report through the top-level
onEventcallback (method: 'PAYMENT_CARD'for the card form), while terminal results arrive viaonPayment. See "Events — terminal vs per-attempt" above; UI events (FORM_ENTERED/FORM_EXITED) do not apply since your page owns the layout.
React
import { useState } from 'react';
import { useFlintNFields } from 'flintn-checkout/react';
function CheckoutForm() {
const [cardholderName, setCardholderName] = useState('');
const {
cardNumberRef,
expiryRef,
cvvRef,
isReady,
fieldErrors,
cardBrand,
paymentResult,
submit,
error,
} = useFlintNFields({
config: {
clientSessionId: 'your_client_session_id',
styles: {
inputBorderRadius: '8px',
inputBorderFocusColor: '#6366f1',
},
},
onPayment: (result) => {
if (result.status === 'PAYMENT_SUCCESS') {
console.log('Payment succeeded:', result.data);
}
},
debug: true,
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const result = await submit({ cardholderName });
console.log('Payment result:', result);
};
return (
<form onSubmit={handleSubmit}>
<label>Card Number {cardBrand && `(${cardBrand})`}</label>
<div ref={cardNumberRef} style={{ height: 40, marginBottom: 4 }} />
{fieldErrors['card-number'] && (
<span style={{ color: 'red' }}>{fieldErrors['card-number']}</span>
)}
<div style={{ display: 'flex', gap: 12 }}>
<div style={{ flex: 1 }}>
<label>Expiry</label>
<div ref={expiryRef} style={{ height: 40, marginBottom: 4 }} />
{fieldErrors['expiry'] && (
<span style={{ color: 'red' }}>{fieldErrors['expiry']}</span>
)}
</div>
<div style={{ flex: 1 }}>
<label>CVV</label>
{/* position/zIndex let the CVV helper panel overlay content below */}
<div
ref={cvvRef}
style={{ height: 40, marginBottom: 4, position: 'relative', zIndex: 1 }}
/>
{fieldErrors['cvv'] && (
<span style={{ color: 'red' }}>{fieldErrors['cvv']}</span>
)}
</div>
</div>
<label>Cardholder Name</label>
<input
value={cardholderName}
onChange={(e) => setCardholderName(e.target.value)}
placeholder="John Doe"
/>
<button type="submit" disabled={!isReady}>
{isReady ? 'Pay' : 'Loading...'}
</button>
{error && <div style={{ color: 'red' }}>{error.message}</div>}
{paymentResult?.status === 'PAYMENT_ERROR' && paymentResult.error && (
<div style={{ color: 'red' }}>{paymentResult.error.message}</div>
)}
</form>
);
}Vanilla JavaScript
import { createFlintNFields } from 'flintn-checkout';
const fields = createFlintNFields({
config: {
clientSessionId: 'your_client_session_id',
styles: {
inputBorderRadius: '8px',
inputBorderFocusColor: '#6366f1',
},
},
onReady: () => console.log('All fields ready'),
onPayment: (result) => console.log('Payment result:', result),
onChange: (event) => console.log(`${event.fieldType}:`, event),
debug: true,
});
const cardNumber = fields.createField('card-number', { placeholder: '4111 1111 1111 1111' });
const expiry = fields.createField('expiry', { placeholder: 'MM/YY' });
const cvv = fields.createField('cvv');
cardNumber.mount('#card-number');
expiry.mount('#expiry');
cvv.mount('#cvv');
// Validate
const validation = await fields.validate();
if (validation.isValid) {
const result = await fields.submit({ cardholderName: 'John Doe' });
}
// Cleanup (call when you're done with the form)
// fields.unmount();Hosted Fields Options (Vanilla JS)
| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| config | FlintNFieldsConfig | Yes | — | Fields configuration |
| onPayment | (result: PaymentResult) => void | No | — | Payment result callback |
| onReady | () => void | No | — | All fields loaded and ready |
| onChange | (event: FieldChangeEvent) => void | No | — | Field value changed |
| onFocus | (fieldType: TFieldType) => void | No | — | Field gained focus |
| onBlur | (fieldType: TFieldType, state: FieldState) => void | No | — | Field lost focus |
| onEvent | (event: AttemptEvent) => void | No | — | Per-attempt analytics for the card flow (method: 'PAYMENT_CARD') |
| onPostalCodeRequirement | (required: boolean) => void | No | — | Entered card BIN requires a postal code — see "Postal code (BIN-driven)" |
| onError | (error: PaymentError) => void | No | — | SDK initialization error |
| debug | boolean | No | false | Enable console debug logs |
FlintNFieldsConfig
| Property | Type | Required | Description |
|----------|------|----------|-------------|
| clientSessionId | string | Yes | Client session ID from backend |
| styles | FormStyles | No | Styles applied to all hosted field inputs |
React Hook Options
The useFlintNFields hook accepts config, onPayment, and debug from the options above. It manages onReady, onChange, onFocus, onBlur, and onError internally and exposes them as return values instead.
Additional React-only options:
| Option | Type | Description |
|--------|------|-------------|
| fields.cardNumber | FieldOptions \| false | Card number field options, or false to skip |
| fields.expiry | FieldOptions \| false | Expiry field options, or false to skip |
| fields.cvv | FieldOptions \| false | CVV field options, or false to skip |
| onChange | (event: FieldChangeEvent) => void | Optional additional callback |
| onFocus | (fieldType: TFieldType) => void | Optional additional callback |
| onBlur | (fieldType: TFieldType, state: FieldState) => void | Optional additional callback |
| onEvent | (event: AttemptEvent) => void | Per-attempt analytics — card flow (method: 'PAYMENT_CARD') and express buttons share this stream |
FieldOptions
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| placeholder | string | '1234 1234 1234 1234' / 'MM/YY' / '***' | Input placeholder text |
| disabled | boolean | false | Disable the input |
React Hook Return Values
| Value | Type | Description |
|-------|------|-------------|
| cardNumberRef | RefObject<HTMLDivElement> | Ref for card number container |
| expiryRef | RefObject<HTMLDivElement> | Ref for expiry container |
| cvvRef | RefObject<HTMLDivElement> | Ref for CVV container |
| applePayRef | RefObject<HTMLDivElement> | Ref for the Apple Pay button container (attaching it enables the wallet) |
| googlePayRef | RefObject<HTMLDivElement> | Ref for the Google Pay button container (attaching it enables the wallet) |
| payPalRef | RefObject<HTMLDivElement> | Ref for the PayPal button container (attaching it enables the wallet) |
| expressAvailable | boolean | True once wallet detection settles and at least one express button is renderable |
| isReady | boolean | All fields loaded and ready |
| fieldErrors | Partial<Record<TFieldType, string \| null>> | Per-field validation errors (populated after first submit) |
| fieldStates | Partial<Record<TFieldType, FieldState>> | Per-field state (always up to date) |
| cardBrand | string \| null | Detected card brand (e.g. "visa", "mastercard") |
| postalCodeRequired | boolean | The entered card BIN requires a postal code — see "Postal code (BIN-driven)" |
| paymentResult | PaymentResult \| null | Result after payment attempt |
| error | PaymentError \| null | SDK error if any |
| validate | () => Promise<FieldsValidationResult> | Validate all fields |
| submit | (options?: SubmitOptions) => Promise<PaymentResult> | Validate and submit payment |
| focusField | (fieldType: TFieldType) => void | Focus a field programmatically |
| clearField | (fieldType: TFieldType) => void | Clear a field |
| clearAll | () => void | Clear all fields |
Validation Behavior
Validation mirrors react-hook-form mode: 'onSubmit':
- Before first submit:
fieldErrorsis always{} - After first submit: errors update on every field change (revalidation)
- Card number changes trigger CVV revalidation (CVV length depends on card brand)
- All three fields (card number, expiry, CVV) are required — submitting with a missing field returns a
MISSING_FIELDSerror - The CVV field ships a built-in helper: tapping the card icon expands a “where to find CVV” panel below the input. The field iframe grows to fit it, so keep the CVV container at a fixed
heightwithposition: relative; z-index: 1— the open panel then overlays the content below (like a popover) instead of pushing your layout
Postal code (BIN-driven)
Some issuers require a postal code for online payments (currently US-issued
cards — the requirement is resolved server-side by BIN). Once the buyer has
typed enough digits, the card-number field checks the BIN against the payments
API and reports whether a postal code is required — postalCodeRequired
(React) / onPostalCodeRequirement (vanilla). Show your own postal code input
when it flips to true and pass the value at submit:
const { postalCodeRequired, submit } = useFlintNFields({ ... });
// render your postal input when postalCodeRequired === true, then:
await submit({
cardholderName,
billingAddress: { postalCode },
});If the BIN requires a postal code and billingAddress.postalCode is missing,
submit() resolves with PAYMENT_ERROR / POSTAL_CODE_REQUIRED without
calling the payments API.
Field Types
| Value | Description |
|-------|-------------|
| 'card-number' | Card number input with automatic formatting and brand detection |
| 'expiry' | Expiry date input (MM/YY format) |
| 'cvv' | CVV/CVC input (3 or 4 digits depending on card brand). Includes a built-in “where to find CVV” helper: tapping the card icon expands an explainer panel below the input |
Individual Field Methods (Vanilla JS)
| Method | Description |
|--------|-------------|
| field.mount(selector) | Mount field into a DOM element |
| field.unmount() | Remove field from DOM |
| field.focus() | Focus the field |
| field.clear() | Clear the field value |
| field.getState() | Get current field state |
| field.getFieldType() | Get the field type |
Express buttons (Apple Pay / Google Pay / PayPal)
Hosted Fields can also render fast checkout buttons so buyers can pay with a
wallet instead of typing card details. The buttons render directly into your
DOM — one element per wallet, no iframe. Your page's own layout sizes and
positions each button; wallet UI (the Apple Pay sheet or desktop QR modal, the
Google Pay sheet, the PayPal popup) overlays your page natively. Only encrypted
wallet tokens pass through the page; they are authorized against the
session-scoped payments API, so no API key ever reaches the browser. Terminal
results arrive through the same onPayment callback as the card submit().
React
import { useFlintNFields } from 'flintn-checkout/react';
function CheckoutForm() {
const {
cardNumberRef, expiryRef, cvvRef,
applePayRef, googlePayRef, payPalRef, // one container per wallet
expressAvailable, // true once ≥1 wallet button is renderable
submit,
} = useFlintNFields({
config: { clientSessionId: 'your_client_session_id' },
onPayment: (result) => {
if (result.status === 'PAYMENT_SUCCESS') console.log('Paid:', result.data);
},
onEvent: (e) => console.log('express attempt:', e.event, e.method),
});
return (
<form onSubmit={(e) => { e.preventDefault(); submit(); }}>
{/* attach a ref to enable that wallet; unavailable wallets render
nothing — collapse empty containers via CSS if they carry spacing */}
<div ref={applePayRef} />
<div ref={googlePayRef} />
<div ref={payPalRef} />
{expressAvailable && <div className="divider">or pay by card</div>}
<div ref={cardNumberRef} style={{ height: 40 }} />
<div ref={expiryRef} style={{ height: 40 }} />
<div ref={cvvRef} style={{ height: 40 }} />
<button type="submit">Pay</button>
</form>
);
}Vanilla JavaScript
import { createFlintNFields } from 'flintn-checkout';
const fields = createFlintNFields({
config: { clientSessionId: 'your_client_session_id' },
onPayment: (result) => console.log('Payment result:', result),
});
const express = fields.createExpressButtons({
onCapability: (cap) => console.log('available wallets:', cap),
onEvent: (attempt) => console.log('express attempt:', attempt),
});
await express.mountExpressButtons([
{ method: 'APPLE_PAY', elementSelector: '#apple-pay' },
{ method: 'GOOGLE_PAY', elementSelector: '#google-pay' },
{ method: 'PAYPAL', elementSelector: '#paypal' },
]);
// unmount a single wallet, or all of them
express.unmountExpressButton('APPLE_PAY');
express.unmountAllExpressButtons();
// card fields as usual
fields.createField('card-number').mount('#card-number');
fields.createField('expiry').mount('#expiry');
fields.createField('cvv').mount('#cvv');Notes:
- No
submit()for express. Wallet buttons self-trigger (the buyer taps Apple Pay and the sheet opens). Success/error come throughonPayment; per-attempt analytics through the top-levelonEvent(React) /createExpressButtons({ onEvent })(vanilla). - Availability. Buttons for methods that are unavailable on this
device/session are silently skipped at mount — their containers just stay
empty.
expressAvailable(React) / theonCapabilitycallback (vanilla) gate your "or pay by card" divider. - Which wallets appear is driven entirely by the merchant session config
(
payment_methods) intersected with device support — there is no client-side allow-list. - 3DS for wallet payments is handled automatically via a popup (make sure the buyer's click isn't intercepted by a popup blocker).
- Apple Pay domain registration. Your live domain must be registered with Apple (handled during merchant onboarding) — merchant validation runs against the domain the buttons render on. On non-Safari browsers Apple's JS SDK shows its QR-code modal over your page. Google Pay and PayPal have no such limits.
Shared Types
PaymentResult
interface PaymentResult {
status: 'PAYMENT_SUCCESS' | 'PAYMENT_ERROR' | 'PAYMENT_CANCELLED';
data?: string; // Payment ID on success
error?: {
code: string;
message: string;
};
}PaymentError
interface PaymentError {
code: string;
message: string;
}SubmitOptions (Hosted Fields)
interface SubmitOptions {
// Required by default — the API rejects card payments without it unless the
// merchant disabled the cardholder name field in the client session request.
cardholderName?: string;
email?: string;
billingAddress?: {
addressLine1?: string;
addressLine2?: string;
city?: string;
state?: string;
postalCode?: string; // required when the BIN demands it — see "Postal code (BIN-driven)"
country?: string;
};
}Error handling
Errors surface through three separate channels — don't conflate them:
| Channel | What lands there | Show as |
|---------|------------------|---------|
| error / onError | Infrastructure: SDK failed to initialize, a container is missing, the session config couldn't load | Page-level banner ("checkout unavailable / session expired") |
| paymentResult / onPayment with PAYMENT_ERROR | A payment attempt failed (card or wallet) | Inline payment error, offer retry |
| fieldErrors | Per-field validation (populated after the first submit()/validate()) | Messages next to each field |
Common code values:
| Code | Channel | Meaning |
|------|---------|---------|
| INIT_ERROR | error | SDK could not initialize (missing clientSessionId, bad origin) |
| MOUNT_ERROR | error | A field/button container was not found or failed to mount |
| SESSION_EXPIRED (and other API codes) | error or payment | Passed through from the payments API as-is |
| CONFIG_ERROR | error | Session config failed to load and the API gave no code |
| NETWORK_ERROR / HTTP_<status> / INVALID_RESPONSE | error or payment | Network failure / HTTP error without an API code / malformed response |
| VALIDATION_ERROR | payment | submit() blocked by field validation (details in fieldErrors) |
| POSTAL_CODE_REQUIRED | payment | The card BIN requires a postal code and billingAddress.postalCode was not passed to submit() |
| MISSING_FIELDS / NOT_INITIALIZED / SUBMIT_IN_PROGRESS / SUBMIT_TIMEOUT | payment | Card submit() preconditions / duplicate call / no response in time (30s) |
| THREE_DS_TIMEOUT | payment | A 3DS challenge stayed unresolved for 10 minutes |
| FNT-EC-* decline codes | payment | Declined — the FNT-EC decline code identifies the reason (e.g. FNT-EC-2003 insufficient funds). Sent for both immediate and post-3DS declines |
| PAYMENT_DECLINED | payment | Declined without a specific decline code (fallback) |
| PAYMENT_FAILED | payment | Non-declined failure (including unexpected 3DS outcomes) |
| APPLE_PAY_SDK_ERROR / GOOGLE_PAY_SDK_ERROR / PAYPAL_ERROR | payment | The wallet SDK failed mid-flow |
| PAYPAL_MISSING_PAYMENT_ID | payment | PayPal approved but no payment id was captured (anomaly) |
| THREE_DS_FAILED / THREE_DS_CANCELLED | payment | Challenge declined / dismissed by the buyer |
| THREE_DS_POPUP_BLOCKED | payment | Express buttons only — the wallet 3DS window was blocked by the browser |
Worth handling specifically: SESSION_EXPIRED (create a fresh session and remount),
THREE_DS_POPUP_BLOCKED (ask the buyer to allow popups), VALIDATION_ERROR
(highlight fields from fieldErrors). Everything else is safely generic.
CheckoutEvent
type CheckoutEvent = PaymentAttempt | PaymentUI;PaymentAttempt
interface PaymentAttempt {
type: 'PAYMENT_ATTEMPT_EVENT';
event:
| 'INITIATED'
| 'THREE_DS_INITIATED'
| 'AUTHORIZED'
| 'SOFT_DECLINE'
| 'HARD_DECLINE'
| 'CANCELLED'
| 'NETWORK_ERROR';
method:
| 'PAYMENT_CARD'
| 'GOOGLE_PAY'
| 'APPLE_PAY'
| 'PAYPAL'
| 'BANK_ACCOUNT'
| 'OTHER';
paymentId?: string; // Set when the backend has minted a payment id (auth response, 3DS, approve)
code?: string; // Processor decline code or internal error code (FNT-EC-*, FORBIDDEN, PAYPAL_ERROR, THREE_DS_FAILED, …)
message?: string; // Translated merchant-facing message for the code
timestamp: number; // Epoch ms, stamped at emit time
clientSessionId?: string; // Session the event belongs to
}PaymentUI
interface PaymentUI {
type: 'PAYMENT_UI_EVENT';
event: 'FORM_ENTERED' | 'FORM_EXITED';
view: 'EXPRESS' | 'CARD'; // The view that became active
timestamp: number;
clientSessionId?: string;
}Const-object accessors are also exported if you prefer typed comparisons over string literals:
import { PaymentAttemptResult, PaymentMethod, isAttemptEvent } from 'flintn-checkout';
onEvent: (e) => {
if (
isAttemptEvent(e) &&
e.event === PaymentAttemptResult.SOFT_DECLINE &&
e.method === PaymentMethod.PAYMENT_CARD
) {
analytics.track('soft_decline_card', { code: e.code });
}
}FieldState
interface FieldState {
isEmpty: boolean;
isValid: boolean;
isFocused: boolean;
error: string | null;
}FieldChangeEvent
interface FieldChangeEvent {
fieldType: 'card-number' | 'expiry' | 'cvv';
isEmpty: boolean;
isValid: boolean;
isFocused: boolean;
error: string | null;
cardBrand?: string; // Only for card-number
}FieldsValidationResult
interface FieldsValidationResult {
isValid: boolean;
errors: Partial<Record<'card-number' | 'expiry' | 'cvv', string | null>>;
}FormFields
interface FormFields {
formVariant?: 'DEFAULT' | 'LIST' | 'RADIO';
}Note: form field visibility and requirements (e.g. email, cardholder name) are controlled by the merchant's session configuration, not by SDK options. Use
formVariantto control the checkout layout.
FormStyles
interface FormStyles {
loaderColor?: string;
backgroundColor?: string;
lightLogos?: boolean;
expressButtonsSpacing?: string;
expressButtonsBorderRadius?: string;
inputBackgroundColor?: string;
inputBorderRadius?: string;
inputBorderColor?: string;
inputBorderHoverColor?: string;
inputBorderFocusColor?: string;
inputBorderErrorColor?: string;
inputTextColor?: string;
inputFontSize?: string;
inputHeight?: string;
placeholderColor?: string;
errorMessageColor?: string;
labelColor?: string;
labelFontSize?: string;
dividerColor?: string;
dividerTextColor?: string;
safeCheckoutAccentColor?: string;
safeCheckoutTextColor?: string;
radioOptionBorderColor?: string;
radioOptionBorderRadius?: string;
radioOptionBackgroundColor?: string;
radioOptionActiveBackgroundColor?: string;
radioOptionTitleColor?: string;
radioButtonColor?: string;
radioButtonActiveColor?: string;
fastCheckoutText?: string;
cardPaymentText?: string;
buttonColor?: string;
buttonHoverColor?: string;
buttonBorderRadius?: string;
buttonHeight?: string;
buttonFontSize?: string;
buttonText?: string;
cardButtonColor?: string;
cardButtonHoverColor?: string;
cardButtonText?: string;
}FormPlaceholders
interface FormPlaceholders {
email?: string;
cardNumber?: string;
expiry?: string;
cvv?: string;
cardholderName?: string;
addressLine1?: string;
addressLine2?: string;
city?: string;
state?: string;
postalCode?: string;
country?: string;
}Per-field placeholder text overrides for the iframe checkout form, passed as
config.placeholders (not under styles — placeholder text is field content, not
visual styling). Any field left undefined keeps its built-in default placeholder.
For hosted fields, set placeholder text per field via the placeholder option
on createField (or the fields prop of useFlintNFields) instead.
FormStyles Reference
| Property | Iframe Checkout | Hosted Fields | Description |
|----------|:-:|:-:|-------------|
| loaderColor | ✅ | — | Loading spinner color |
| backgroundColor | ✅ | — | Form background color |
| lightLogos | ✅ | — | Use light logo variants in the safe-checkout row (for dark backgrounds) |
| expressButtonsSpacing | ✅ | — | Spacing between express payment buttons. Not applicable to Hosted Fields — the buttons render into your own containers, so spacing is your page's layout |
| expressButtonsBorderRadius | ✅ | ✅ | Corner radius for express buttons — Apple Pay, Google Pay, PayPal, and the "Credit or debit card" button in the LIST variant (e.g. 8px). In Hosted Fields it applies to all wallet buttons at once |
| inputBackgroundColor | ✅ | ✅ | Input field background color |
| inputBorderRadius | ✅ | ✅ | Input field border radius |
| inputBorderColor | ✅ | ✅ | Default input border color |
| inputBorderHoverColor | ✅ | ✅ | Input border color on hover |
| inputBorderFocusColor | ✅ | ✅ | Input border color on focus |
| inputBorderErrorColor | ✅ | ✅ | Input border color in error state |
| inputTextColor | ✅ | ✅ | Input text (typed value) color |
| inputFontSize | ✅ | ✅ | Input text font size (e.g. 16px) |
| inputHeight | ✅ | ✅ | Total input field height, border included (e.g. 48px; default 40px) |
| placeholderColor | ✅ | ✅ | Input placeholder text color |
| errorMessageColor | ✅ | — | Error message text color |
| labelColor | ✅ | — | Field label text color |
| labelFontSize | ✅ | — | Field label font size (e.g. 14px) |
| dividerColor | ✅ | — | Divider line color ("or" + "safe checkout" dividers) |
| dividerTextColor | ✅ | — | "or" divider text color |
| safeCheckoutAccentColor | ✅ | — | "Safe" accent word color (defaults to theme success) |
| safeCheckoutTextColor | ✅ | — | "Checkout" word color in the safe-checkout divider |
| radioOptionBorderColor | ✅ | — | Radio option card border color (RADIO variant) |
| radioOptionBorderRadius | ✅ | — | Radio option card border radius (RADIO variant) |
| radioOptionBackgroundColor | ✅ | — | Radio option card background, inactive (RADIO variant) |
| radioOptionActiveBackgroundColor | ✅ | — | Radio option card background, active (RADIO variant) |
| radioOptionTitleColor | ✅ | — | Radio option title text color (RADIO variant) |
| radioButtonColor | ✅ | — | Radio button color, unselected (RADIO variant) |
| radioButtonActiveColor | ✅ | — | Radio button color, selected (RADIO variant) |
| fastCheckoutText | ✅ | — | Custom "Fast checkout" option label (RADIO variant) |
| cardPaymentText | ✅ | — | Custom "Card payment" option label (RADIO variant) |
| buttonColor | ✅ | — | Submit button background color |
| buttonHoverColor | ✅ | — | Submit button hover color |
| buttonBorderRadius | ✅ | — | Submit button border radius |
| buttonHeight | ✅ | — | Submit button height (e.g. 48px; default 40px) |
| buttonFontSize | ✅ | — | Submit button text font size (e.g. 16px; default 14px) |
| buttonText | ✅ | — | Custom submit button text |
| cardButtonColor | ✅ | — | Card button color (LIST variant) |
| cardButtonHoverColor | ✅ | — | Card button hover color (LIST variant) |
| cardButtonText | ✅ | — | Custom card button text (LIST variant) |
Form Variants
The iframe checkout supports three layout variants via formFields.formVariant.
Variants only apply when express buttons render inside the iframe — with
expressButtons: false the variant is ignored and the iframe always shows the
plain card form.
- DEFAULT — Express payment methods shown above the card form with dividers.
- LIST — Express payment methods shown first; users tap a "Credit or Debit Card" button to navigate to the card form, with a back arrow to return to the express view. Toggling emits
FORM_ENTERED/FORM_EXITEDUI events. - RADIO — Express payments and the card form are presented as two radio options ("Fast checkout" and "Card payment"). Selecting a radio expands its section and collapses the other. The card payment radio shows accepted card brand logos inline with its label. Selecting a radio emits
FORM_ENTERED/FORM_EXITEDUI events.
LIST and RADIO automatically fall back to the DEFAULT layout when no express payment methods are available, since there's nothing to toggle between (and no UI events fire). The cardButtonColor, cardButtonHoverColor, and cardButtonText style overrides apply only to the LIST variant's "Credit or Debit Card" button.
Cancel-message behavior across variants
Express buttons (Apple Pay, PayPal) and the 3DS dialog can all be cancelled by the buyer. When that happens, an inline cancel message is shown in the card form only when the card form is currently visible:
| Variant | Apple Pay / PayPal cancel | 3DS dialog close |
|---|---|---|
| DEFAULT | Inline message shown | Inline message shown |
| LIST (EXPRESS view) | No inline message (form hidden) | No inline message |
| LIST (CARD view, after toggle) | Not reachable — cancel originates from EXPRESS view | Inline message shown |
| RADIO (EXPRESS active) | No inline message | No inline message |
| RADIO (CARD active) | Not reachable — cancel originates from EXPRESS view | Inline message shown |
Every cancellation still emits a CANCELLED onEvent regardless of variant, so analytics is unaffected.
Supported Payment Methods
Iframe Checkout
- Credit/Debit Cards (Visa, Mastercard, Amex, Discover)
- Apple Pay
- PayPal
Hosted Fields
- Credit/Debit Cards (Visa, Mastercard, Amex, Discover)
- Apple Pay, Google Pay, PayPal (via the express buttons — see "Express buttons" above)
Debug Mode
Enable debug logs in console:
// Iframe Checkout
useFlintNPayment({
config: { clientSessionId: '...' },
debug: true,
});
// Hosted Fields
useFlintNFields({
config: { clientSessionId: '...' },
debug: true,
});Browser Support
- Chrome (latest)
- Firefox (latest)
- Safari (latest)
- Edge (latest)
License
MIT
