Install
openclaw skills install @jbryant/finix-jsFinix.js integration for accepting card and ACH bank payments on the web. Embed the hosted payment form, tokenize card/bank data in the browser, and claim tokens server-side to create Buyer Identities, Payment Instruments, and Transfers/Authorizations via the Finix Payments API. Use this skill when users want to accept payments on their website with Finix, set up a Finix checkout, tokenize card/bank details, or wire up the server-side token-exchange and charge flow.
openclaw skills install @jbryant/finix-jsThis document teaches how to integrate Finix.js into a customer website to accept card and bank (ACH) payments via Finix. It covers the Finix APIs involved, the account/application setup required, and the steps to embed and use the library.
Finix.js is a client-side, hosted payment form. It injects an iframe into the merchant's page that collects sensitive PCI/NACHA data (card number, expiration, security code, bank account/routing numbers) and exchanges it directly with the Finix Payments API for a single-use token. The merchant's backend then uses that token to create a reusable Payment Instrument and charge the customer via Transfer or Authorization API calls. Because raw card/bank data never hits the merchant's servers, this flow keeps cards in SAQ-A PCI scope, and is the only supported pattern for Finix web integrations.
Before writing any code, the merchant must have:
https://finix.payments-dashboard.com/).APxxxxxxxxxxxxxxxxxxxxxx). This identifies the platform/application that owns the tokens. It is passed to Finix.PaymentForm(...). Application IDs are environment-scoped — sandbox and production IDs are different.MUxxxxxxxxxxxxxxxxxxxxxx). This represents the entity being paid. Needed server-side when creating Authorizations/Transfers, and client-side if using Finix.Auth for fraud session tracking.| Environment string | Payments API host |
|---|---|
prod / production / live | https://finix.live-payments-api.com |
sandbox / sb | https://finix.sandbox-payments-api.com |
Always build/test in sandbox first. Switching to prod is a one-line change (environment string + Application ID).
finix.js and calls Finix.PaymentForm(...), which mounts an iframe into a container <div>./applications/:app_id/tokens and receives a single-use token (TKxxxx...).fraud_session_id from Finix.Auth and any checkout context) is POSTed to the merchant's backend.Finix.js handles steps 1–3. Steps 4–5 are the merchant's responsibility.
<div id="finix-form"></div>
<script src="https://js.finix.com/v/2/finix.js"></script>
<script>
const form = Finix.PaymentForm(
"finix-form", // container: id string OR an HTMLElement
"sandbox", // environment
"APgPDQrLD52TYvqazjHJJchM", // Application ID
{
onSubmit: (error, response) => {
if (error) {
console.error(error);
return;
}
const token = response.data.id; // "TKxxxx..."
// POST token to your backend to finish the charge.
fetch("/api/charge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...paymentData, token }), // include other checkout data (amount, currency, buyer info, cart id, etc.)
});
},
}
);
</script>
The library attaches window.Finix on load and exposes:
Finix.PaymentForm(element, env, applicationId, options) — renders card + bank form (default is cards only; enable bank via paymentMethods: ['card', 'bank']).Finix.Auth(env, merchantId, callback) — initializes Sift fraud session tracking (see "Fraud Session" below).Finix.TokenForm, Finix.CardTokenForm, Finix.BankTokenForm all delegate to PaymentForm.element may be either an element ID (string) or an HTMLElement — the latter is required when mounting inside a Shadow DOM / web component.
PaymentForm returns an instance with:
form.submit((error, response) => {...}) — trigger tokenization from a custom button. See "Custom submit button" below.form.theme(themeName) — swap theme at runtime.Pass as the 4th argument to PaymentForm. All are optional. This section is the longest because options are where most integrations get stuck — read carefully before customizing.
| Option | Type | Default | Purpose |
|---|---|---|---|
paymentMethods | ('card' | 'bank')[] | ['card'] | Which payment method tabs to render. |
showAddress | boolean | false | Show the billing address section. |
showLabels | boolean | true | Render field <label> elements above inputs. |
labels | Record<field, string> | {} | Override default label text per field. |
showPlaceholders | boolean | true | Render input placeholders. |
placeholders | Record<field, string> | {} | Override placeholder text per field. |
hideFields | string[] | [] | Hide specific fields (deep dive below). |
requiredFields | string[] | [] | Force normally-optional fields to be required (deep dive below). |
requireSecurityCode | boolean | true | Require CVV/CVC on cards. |
confirmAccountNumber | boolean | true | Require bank account number to be typed twice. |
hideErrorMessages | boolean | false | Suppress inline error text (fields still validate). |
errorMessages | Record<field, string> | {} | Custom inline error copy per field. |
hidePotentialIssueMessages | boolean | false | Hide soft "are you sure?" warnings (e.g. unusual card length). |
defaultValues | Record<field, string> | {} | Prefill non-sensitive fields (deep dive below). |
enableDarkMode | boolean | false | Respect prefers-color-scheme: dark. |
theme | string | 'finix' | Brand preset (deep dive below). |
styles | StylesCfg | {} | Per-state CSS overrides (deep dive below). |
fonts | Array<{fontFamily,url,format}> | [] | Inject custom @font-face rules (deep dive below). |
submitLabel | string | 'Submit' | Built-in submit button label. |
plaidLinkSettings | { displayName, language, countries } | — | Enable Plaid embedded bank linking (see "Plaid Bank Linking" below). |
onLoad | () => void | — | Fires when the iframe has mounted and initialized. |
onUpdate | (state, binInfo, hasErrors) => void | — | Fires on every field change. |
onSubmit | (error, response) => void | — | Fires after tokenization completes. |
Every option that takes a field-keyed record (labels, placeholders, hideFields, requiredFields, errorMessages, defaultValues) uses the same set of canonical field names. Use these exactly — arbitrary keys are silently ignored.
| Section | Field name | Notes |
|---|---|---|
| Card | card_holder_name | Cardholder name. Optional by default. |
| Card | number | Card number. Always required when card is active. |
| Card | expiration_date | MM/YY. Always required when card is active. |
| Card | security_code | CVV/CVC. Required unless requireSecurityCode: false or hideFields: ['security_code']. |
| Bank (USA + CAN) | account_holder_name | Account holder name. Required by default when bank is active. |
| Bank (USA + CAN) | account_number | Account number. Required when bank is active. |
| Bank (USA + CAN) | account_type | PERSONAL_CHECKING | PERSONAL_SAVINGS | BUSINESS_CHECKING | BUSINESS_SAVINGS. Required when bank is active. |
| Bank (USA) | bank_code | US ABA routing number (9 digits). Required when country is USA. |
| Bank (CAN) | transit_number | 5-digit transit. Required when country is CAN; replaces bank_code. |
| Bank (CAN) | institution_number | 3-digit institution. Required when country is CAN; replaces bank_code. |
| Address | address_line1 | Street. |
| Address | address_line2 | Apt/suite. |
| Address | address_city | City. |
| Address | address_region | State/province. Dropdown is auto-populated for USA/CAN. |
| Address | address_country | Dropdown; dictates bank field set and postal-code format. |
| Address | address_postal_code | ZIP (US: 12345 or 12345-6789) / postal code (CAN: A1A 1A1). |
paymentMethodspaymentMethods: ['card'] // card only (default)
paymentMethods: ['bank'] // ACH/bank only
paymentMethods: ['card', 'bank'] // tabs for both
When both are present, a tab bar appears and the user chooses. The active method determines which required fields are enforced. Bank fields vary by address_country — selecting CAN swaps bank_code for transit_number + institution_number.
hideFields (deep dive)Removes a field from the DOM and from validation. The tokenization payload simply omits it.
Fields that are safe to hide:
card_holder_name — optional for cards.account_holder_name — hideable for bank; if hidden the default-required check is skipped for it.security_code — equivalent to setting requireSecurityCode: false; produces a token without CVV, but many issuers will decline the downstream charge. Only hide if your Finix account/use-case permits card-on-file without CVV.address_* field — all address fields are optional at the tokenization layer. To hide the entire address section at once, prefer showAddress: false (or simply omit it — false is the default).Fields you should NOT hide (will break tokenization):
number, expiration_date — required to mint a card token.account_number, account_type, and bank_code (USA) / transit_number+institution_number (CAN) — required to mint a bank token.Common hide patterns:
// Show address but skip the optional second line
hideFields: ['address_line2'],
// CVV-less card-on-file
hideFields: ['security_code'],
// Drop name fields (name captured elsewhere in your checkout)
hideFields: ['card_holder_name', 'account_holder_name'],
If the user hides a field that was also listed in requiredFields, the hide wins — the field does not render and is not validated.
requiredFields (deep dive)What it does: promotes a normally-optional field to required. It does not add fields that aren't already rendering — you still need showAddress: true (or no matching hideFields entry) for address fields to appear.
Fields that are already required by default (no need to list them):
number, expiration_date, security_code (unless requireSecurityCode: false).account_holder_name, account_number, bank_code, account_type.account_holder_name, account_number, transit_number, institution_number, account_type.Fields you can add to requiredFields:
card_holder_nameaccount_holder_name (redundant for bank — already required by default)name — legacy alias that maps to card_holder_name or account_holder_name depending on the active payment method. Prefer the explicit names in new code.address_line1, address_line2, address_city, address_region, address_country, address_postal_codeAnything else is silently dropped (only the list above is honored).
Interactions to be aware of:
requireSecurityCode: false + requiredFields: ['security_code'] — the requiredFields entry is not honored for security_code. Use requireSecurityCode as the switch.hideFields + requiredFields for the same field — hide wins.confirmAccountNumber: true (default) implicitly requires account_number_confirmation when bank is active; you don't list it manually.Typical billing-address collection for AVS/fraud checks:
AVS itself only evaluates address_line1 + address_postal_code, but most merchants collect the full address for records and fraud scoring:
showAddress: true,
requiredFields: [
'address_line1',
'address_city',
'address_region',
'address_postal_code',
// address_country is a dropdown with a default, so it's not strictly needed here
],
For a minimal AVS-only setup:
showAddress: true,
hideFields: ['address_line2', 'address_city', 'address_region', 'address_country'],
requiredFields: ['address_line1', 'address_postal_code'],
defaultValues (deep dive)Prefills non-sensitive fields so the buyer doesn't retype data your checkout already has.
Allowed fields:
card_holder_name, account_holder_nameaddress_line1, address_line2, address_city, address_region, address_country, address_postal_codeNever prefill (the iframe ignores these even if passed):
number, expiration_date, security_code — would break PCI scope.account_number, bank_code, transit_number, institution_number — would break NACHA scope.Format requirements:
address_country → ISO-3 alpha codes ('USA', 'CAN', 'GBR', …). The dropdown's list comes from countryNamesOptions and is ACH-restricted to USA/CAN when bank is the active method.address_region → two-letter state/province abbreviation for US ('CA', 'NY') and CAN ('ON', 'BC'). Other countries: free text.address_postal_code → already-formatted string ('94105', '94105-1234', 'M5V 3L9'). The formatter will re-normalize input, but passing the canonical form avoids confusing the buyer.Example:
defaultValues: {
card_holder_name: 'Ada Lovelace',
address_line1: '50 Beale St',
address_line2: 'Suite 600',
address_city: 'San Francisco',
address_region: 'CA',
address_country: 'USA',
address_postal_code: '94105',
},
theme vs stylestheme is a preset that changes the whole form's look (colors, borders, button). styles is a targeted override on top of whichever theme is active. Use theme for brand-alignment, then nudge with styles.
Built-in themes: finix (default), amethyst, sapphire, topaz, ruby, emerald, midnight, elevated. Switch at runtime via form.theme('ruby').
styles (deep dive)styles is an object with two top-level branches — default (always applied) and dark (applied only when enableDarkMode is on and the user/browser is in dark mode; merged over default).
Under each branch, each target element exposes a set of states. States are also applied additively in this order: default → success (if valid) → error (if invalid & errors shown) → focused (if focused).
Full shape:
styles: {
default: {
form: {
default: { /* container around entire form */ },
},
section: {
default: { /* each section wrapper (card/bank/address) */ },
},
sectionHeader: {
default: { /* section heading text */ },
focused: { /* when a field inside is focused */ },
},
input: {
default: { /* all inputs */ },
success: { /* input with valid value */ },
error: { /* input with validation error and showErrors=true */ },
focused: { /* input while focused */ },
},
submitButton: {
default: { /* built-in submit button */ },
disabled: { /* when the form has errors */ },
},
},
dark: {
// same tree — these get merged on top of `default` when dark is active
input: { default: { /* ... */ } },
// ...
},
},
Values are camelCased CSS strings (same as React/inline style objects), not CSS declarations. Use string values everywhere, including numeric-looking ones:
input: {
default: {
border: '1px solid #7D90A5',
borderRadius: '12px',
padding: '12px 14px',
fontSize: '16px',
fontFamily: '"Effra", sans-serif',
},
focused: {
border: '1px solid #3B82F6',
boxShadow: '0 0 0 3px rgba(59,130,246,0.2)',
},
error: {
border: '1px solid #DC2626',
color: '#DC2626',
},
success: {
border: '1px solid #16A34A',
},
},
State precedence example. An input that is both focused and in error ends up with the merge default → error → focused. So if both define border, focused wins. To force error styling to trump focus, re-declare border inside the error block of the dark/default tree, or move the style onto outline so they don't collide.
Dark mode notes. dark only applies when you set enableDarkMode: true. Everything inside dark is a patch over default — you don't have to redeclare unchanged rules.
What you can't do. You can't inject arbitrary CSS, pseudo-classes (:hover), or selectors. If you need hover styles or animation, do it by adjusting default/focused or filing a feature request.
fontsInjects @font-face rules into the iframe so the CSS fontFamily values in styles actually resolve. Each font object:
fonts: [
{
fontFamily: 'Effra',
url: 'https://cdn.example.com/fonts/effra.woff2',
format: 'woff2',
},
],
Constraints enforced by the iframe:
url must be served over https:// — anything else is rejected with a console warning.format must be one of woff, woff2, truetype, opentype, embedded-opentype, svg.fontFamily is sanitized (strips anything that isn't word/space/comma/hyphen) to prevent CSS injection.After the font is loaded, reference it in styles via the sanitized family name: fontFamily: '"Effra", sans-serif'.
Two independent switches:
hideErrorMessages: true — hides inline error text. Fields still validate and the form still blocks submission; you just don't show the red copy.errorMessages: { field: 'custom string' } — per-field override. Still only shown when hideErrorMessages is false. The keys match the canonical field names listed in "The complete field name list" above.hidePotentialIssueMessages: true suppresses the soft warnings like "This Visa number is valid, but Visa typically issues 13 or 16 digit cards…" without affecting hard validation.
onLoad: () => { /* iframe mounted & initialized */ },
onUpdate: (state, binInfo, hasErrors) => {
// state — { [fieldName]: { value, errors, isDirty, isFocused, ... } }
// binInfo — { cardBrand, bin } once enough digits are entered
// hasErrors — true while any required/invalid field blocks submission
// Typical use: enable/disable your custom submit button.
},
onSubmit: (error, response) => { /* see "The onSubmit Response" below for shape */ },
Callbacks live on the parent and are invoked in response to iframe messages — they are stripped from the options object before it crosses the postMessage boundary.
Omit the built-in button by providing your own and calling form.submit:
<div id="finix-form"></div>
<button id="pay-btn" disabled>Pay</button>
<script>
const form = Finix.PaymentForm("finix-form", "sandbox", "APxxx...", {
onUpdate: (_state, _bin, hasErrors) => {
document.getElementById("pay-btn").disabled = hasErrors;
},
});
document.getElementById("pay-btn").addEventListener("click", () => {
form.submit((error, response) => {
if (error) return handleError(error);
sendTokenToBackend(response.data.id);
});
});
</script>
Note: do not wrap #finix-form in a <form> element whose submit you also handle — the library manages its own submission via postMessage to the iframe.
onSubmit: (error, response) => {
if (error) {
// error.data?.message — human-readable
// error.data?._embedded?.errors — per-field validation errors from Finix
return;
}
const tokenData = response.data;
// tokenData.id → "TKxxxxxxxxxxxxxxxxxxxxxx" (single-use, 30 days)
// tokenData.fingerprint → stable hash of the payment method
// tokenData.instrument → "PAYMENT_CARD" | "BANK_ACCOUNT"
// tokenData.expires_at → ISO timestamp
const token = tokenData.id; // what you forward to your backend
};
Tokens are single-use and expire in ~30 days. The merchant's server should exchange the token for a persistent Payment Instrument immediately after receiving it (see "Server-Side: Claiming the Token" below).
Instead of (or in addition to) manual routing + account entry, merchants can enable Plaid Link for ACH. Add plaidLinkSettings to options and make sure paymentMethods includes 'bank':
Finix.PaymentForm("finix-form", "sandbox", "APxxx...", {
paymentMethods: ['card', 'bank'],
plaidLinkSettings: {
displayName: 'Acme Corp',
language: 'en',
countries: ['USA', 'CAN'],
},
onSubmit: (err, res) => { /* ... */ },
});
When the user successfully links an account via Plaid, the library posts a third_party_token payload instead of raw bank fields. The returned token is used exactly like a normal token on the server side.
For card and bank payments Finix recommends initializing a Sift fraud session tied to the Merchant ID. This produces a sessionKey (fraud_session_id) that must be forwarded to the backend and attached to the Authorization/Transfer request.
const auth = Finix.Auth("sandbox", "MUxxxxxxxxxxxxxxxxxxxxxx", (sessionKey) => {
// Stash it; attach to the payload when creating an Authorization/Transfer server-side.
window.__finixFraudSessionId = sessionKey;
});
Important: Finix.Auth takes the Merchant ID, not the Application ID. The supported environment strings are the same ones in the prerequisites table above (sandbox/sb and prod/production/live).
Everything above this section happens in the browser. Once the merchant's backend receives the token (TKxxxx...), it has 30 days to claim it. The backend does this in three steps:
IDxxxx...PIxxxx... (this claims the token)source and the Merchant ID as merchant.Transfers are the most common path (one-step sale). Authorizations follow the same pattern but require a separate capture call afterwards.
All requests use HTTP Basic Auth with a server-side API key pair and must include the Finix-Version header.
Skip this step if you already have an Identity for this buyer — Identities are reusable and can be updated later via PUT.
You can create a fully-populated Identity, or an empty one with just identity_roles and type — both are valid. Populating it up front avoids asking the buyer for the same info later.
curl -i -X POST \
-u USfdccsr1Z5iVbXDyYt7hjZZ:313636f3-fac2-45a7-bff7-a334b93e7bda \
https://finix.sandbox-payments-api.com/identities \
-H 'Content-Type: application/json' \
-H 'Finix-Version: 2022-02-01' \
-d '{
"entity": {
"phone": "7145677613",
"first_name": "John",
"last_name": "Smith",
"email": "finix_example@finix.com",
"personal_address": {
"line1": "741 Douglass St",
"line2": "Apartment 7",
"city": "San Mateo",
"region": "CA",
"postal_code": "94114",
"country": "USA"
}
},
"identity_roles": ["BUYER"],
"tags": { "key": "value" },
"type": "PERSONAL"
}'
Response contains "id": "IDxxxx..." — keep it for step 8b.
This is the step that claims the token. The token becomes a persistent, reusable Payment Instrument tied to the buyer's Identity. Once claimed, the original TK... is consumed; future charges use the returned PI....
curl -i -X POST \
-u USfdccsr1Z5iVbXDyYt7hjZZ:313636f3-fac2-45a7-bff7-a334b93e7bda \
https://finix.sandbox-payments-api.com/payment_instruments \
-H 'Content-Type: application/json' \
-H 'Finix-Version: 2022-02-01' \
-d '{
"token": "TKiMxe323RE5Dq3wLVtG8kSW",
"type": "TOKEN",
"identity": "IDjvxGeXBLKH1V9YnWm1CS4n"
}'
Example response (card):
{
"id": "PImmCg3Po7oNi7jaZcXhfkEu",
"application": "APgPDQrLD52TYvqazjHJJchM",
"identity": "IDgWxBhfGYLLdkhxx2ddYf9K",
"instrument_type": "PAYMENT_CARD",
"brand": "MASTERCARD",
"card_type": "DEBIT",
"last_four": "8210",
"expiration_month": 12,
"expiration_year": 2029,
"fingerprint": "FPRiCenDk2SoRng7WjQTr7RJY",
"enabled": true,
"currency": "USD",
"address": { "line1": "900 Metro Center Blv", "city": "San Francisco", "region": "CA", "postal_code": "94404", "country": "USA" }
}
The id (PImmCg3Po7oNi7jaZcXhfkEu) is the source you'll use in step 8c.
This actually moves the money. amount is in the currency's minor units (cents for USD).
curl -i -X POST \
-u USfdccsr1Z5iVbXDyYt7hjZZ:313636f3-fac2-45a7-bff7-a334b93e7bda \
https://finix.sandbox-payments-api.com/transfers \
-H 'Content-Type: application/json' \
-H 'Finix-Version: 2022-02-01' \
-d '{
"amount": 662154,
"currency": "USD",
"merchant": "MUmfEGv5bMpSJ9k5TFRUjkmm",
"source": "PI6iQcTtJNCS8GZAVKYi5Ueb",
"tags": { "test": "Sale" }
}'
Fields:
merchant — the Merchant ID being paid (the same MU... used in Finix.Auth).source — the Payment Instrument ID from step 8b.amount — integer in minor units. 662154 = $6,621.54.fraud_session_id — (recommended) the sessionKey returned by Finix.Auth in the browser (see "Fraud Session" above).tags — arbitrary metadata for your own reporting.Identical structure to Transfers, but posted to /authorizations. They place a hold on funds without capturing. Call POST /authorizations/:id/capture (or void) to finish. Use this for workflows like "authorize at checkout, capture on ship." For most use cases — e-commerce sales, one-click pay — a Transfer is what you want.
| Concern | Location | Credential |
|---|---|---|
| Collect card/bank | Browser (iframe) | Application ID (public) |
Tokenize (TK...) | Browser → Finix | Application ID |
| Create Identity, Payment Instrument, Transfer/Authorization | Server only | API username + password |
| Webhooks (dispute, settlement, etc.) | Server | Verify signature |
Never put the API username/password into finix.js or any browser-delivered bundle. The US...:... credentials shown above are public Finix sandbox test keys — safe to copy for local experiments, but replace with your own for live traffic.
finix.js script tag added; container <div> present in the DOM before the script runs.PaymentForm called with correct environment + Application ID.onSubmit (or custom-button form.submit) wired to a backend endpoint.Finix.Auth initialized with the Merchant ID; sessionKey forwarded to backend and included as fraud_session_id.4111 1111 1111 1111) and test bank routing numbers before swapping to live environment + live Application ID.