Accept a payment with frameOS
Build a payment integration on frameOS by charging a payment method against an Account using the Transfers API. This guide covers both client-side and server-side code to create a checkout that accepts card payments through frameOS.
On the legacy Customers + ChargeIntents stack? See Accept a payment for the frame.confirmCardPayment(clientSecret) flow. The frameOS guide below uses Accounts + Transfers and is the current recommendation for new integrations.
Prerequisites
Before creating a transfer the following must be in place. See Transfers for the full list.
| Requirement | Details |
|---|---|
| Active billing agreement | The merchant must have an active billing agreement for the charge category. |
| Processing fee plan | A transfer fee plan configured with a billable_event_type that matches the transfer type — transfer.charge.card for card charges, transfer.charge.ach for ACH. |
| Account capability | The account must have an active card_send (cards) or bank_account_send (ACH) capability. |
A new sandbox merchant has neither a fee plan nor a billing agreement by default. Following this guide without setting them up will return 422 No active charge billing agreement found for this account or merchant. on step 5. Run the two requests in Sandbox setup once before your first transfer.
Sandbox setup
Run these two requests once per merchant + environment to make charge transfers possible. Skip this section if your merchant already has a fee plan and billing agreement configured.
Create a transfer fee plan scoped to card charges:
curl --request POST \
--url https://api.framepayments.com/v1/transfer_fee_plans \
--header 'Authorization: Bearer API_KEY' \
--header 'Content-Type: application/json' \
--data '{
"name": "Card charge fees",
"fee_application_mode": "deduct",
"items": [
{
"fee_type": "flat_plus_percentage",
"amount_fixed_cents": 30,
"amount_percentage": 2.9,
"billable_event_type": "transfer.charge.card"
}
]
}'
The billable_event_type must match the transfer being billed: transfer.charge.card for card charges, transfer.charge.ach for ACH. The default transfer.created will not match a card charge and the transfer will fail.
Create a transfer billing agreement that activates the fee plan for the charge category:
curl --request POST \
--url https://api.framepayments.com/v1/transfer_billing_agreements \
--header 'Authorization: Bearer API_KEY' \
--header 'Content-Type: application/json' \
--data '{
"transfer_fee_plan_id": "d4e5f6a7-b8c9-0123-def4-567890abcdef",
"category": "charge",
"effective_date": "2026-01-01"
}'
Omit account_id to apply the agreement platform-wide, or provide one to scope it to a single account.
1. Set up Frame
In the Frame Dashboard, create a publishable key and a secret API key. Use the secret key on your server to authenticate API requests, and use the publishable key with Frame.js on the client to initialize your payment form.
2. Create an account
Every frameOS payment is anchored to an Account. The account represents the end user (or merchant) you're transacting with — it's where Frame stores identity information, payment methods, verification state, and the Sonar signals collected by Frame.js.
Create an individual account with the card_send capability so it can be charged for a card payment. For ACH, request bank_account_send instead.
Parameters
Use individual for end users and sole proprietors, or business for companies.
Include card_send to accept card payments from the account holder, or bank_account_send to accept ACH payments. The _send suffix refers to the account holder sending payment instructions; the funds flow inbound to the merchant.
Identity information for the account holder. At least one identity object (profile.individual or profile.business, matching type) is required at account creation. Provide whatever you already have; Frame collects the rest during hosted onboarding.
Optional identifier from your own system. Useful for linking Frame accounts to records in your platform.
curl --request POST \
--url https://api.framepayments.com/v1/accounts \
--header 'Authorization: Bearer API_KEY' \
--header 'Content-Type: application/json' \
--data '{
"type": "individual",
"capabilities": ["card_send"],
"profile": {
"individual": {
"email": "marcia@example.com",
"name": { "first_name": "Marcia", "last_name": "Longo" }
}
},
"external_id": "user_12345"
}'
{
"id": "c1a4a27f-d6e4-46e2-9557-fd5faaa31e7d",
"object": "account",
"type": "individual",
"status": "active",
"external_id": "user_12345",
"livemode": false,
"created": 1773398080
}
For a low-code path, use hosted onboarding to collect any remaining account details and payment methods directly from the account holder.
3. Collect payment details
Use Frame.js to collect payment details on the client. Frame.js uses an iframe to securely send card data to Frame over HTTPS, and — separately — collects the device and behavioral signals that power Sonar fraud protection.
Set up Frame.js
Include the Frame.js script in the <head> of your checkout page. Always load Frame.js directly from js.framepayments.com to remain PCI compliant.
<head>
<title>Checkout</title>
<script src="https://js.framepayments.com/v1/index.js"></script>
</head>
Initialize Frame with your publishable key:
const frame = await Frame.init("<PUBLISHABLE_KEY>");
When Frame.js initializes, a Sonar session is created in the browser and stored in local storage. Once the account holder has interacted with your site, frameOS associates these Sonar signals with their account, and the most recent session is attached to every transfer you create — no extra parameters required.
Add the Payment Element to your checkout page
Create a container for the Payment Element and mount it:
<form id="payment-form">
<div id="payment-card-element">
<!-- Frame.js will create form elements here -->
</div>
<button id="pay" type="submit">Pay now</button>
<div id="messages"></div>
</form>
const theme = frame.themes("clean");
const card = await frame.createElement("card", { theme: theme });
card.mount("#payment-card-element");
Capture the card payload
The card element emits a change event whenever the customer edits a field. When payload.isComplete is true, the payload's encrypted card fields are ready to send to your server. Hold onto the most recent complete payload and forward it on form submission.
let cardPayload = null;
card.on("change", (payload) => {
cardPayload = payload.isComplete ? payload : null;
});
document.getElementById("payment-form").addEventListener("submit", async (e) => {
e.preventDefault();
if (!cardPayload) return;
await fetch("/api/charge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cardPayload, amount: 5000 }),
});
});
The cardPayload contains encrypted card.number and card.cvc strings, plain card.expiry.month and card.expiry.year, and an optional billingAddress if your element collects one. The card number and CVC never leave Frame.js in plaintext, so it's safe to forward cardPayload to your own server.
4. Create a payment method
On your server, exchange the card payload for a Payment Method bound to the account. Pass the encrypted card fields through unchanged and include account so the resulting payment method is attached to the frameOS account you created in step 2.
Parameters
Use card for card payments or ach for bank charges.
The frameOS account ID to attach the payment method to. Required for the frameOS charge flow — without it, the transfer in step 5 fails with Customer or account is required to use existing payment method.
The encrypted card number from cardPayload.card.number. Forward as-is.
The encrypted CVC from cardPayload.card.cvc. Forward as-is.
Two-digit expiration month from cardPayload.card.expiry.month.
Two-digit expiration year from cardPayload.card.expiry.year.
Billing address. Map from cardPayload.billingAddress if present.
curl --request POST \
--url https://api.framepayments.com/v1/payment_methods \
--header 'Authorization: Bearer API_KEY' \
--header 'Content-Type: application/json' \
--data '{
"type": "card",
"account": "c1a4a27f-d6e4-46e2-9557-fd5faaa31e7d",
"card_number": "<cardPayload.card.number>",
"cvc": "<cardPayload.card.cvc>",
"exp_month": "<cardPayload.card.expiry.month>",
"exp_year": "<cardPayload.card.expiry.year>"
}'
{
"id": "d0a736d3-0dba-4eda-99f6-dfe27849f282",
"object": "payment_method",
"type": "card",
"status": "active",
"account_id": "c1a4a27f-d6e4-46e2-9557-fd5faaa31e7d",
"card": {
"brand": "visa",
"exp_month": "12",
"exp_year": "29",
"last_four": "4242"
},
"livemode": false,
"created": 1773410000
}
5. Create a transfer
The Transfer object represents the movement of funds. In the charge flow you provide a source_payment_method_id (the card or bank account being charged) and the account_id the transfer is associated with.
destination_payment_method_id | Funds destination |
|---|---|
| Omitted | Funds go to the merchant (platform) |
| Provided | Funds are routed to the account that owns that payment method |
Parameters
Amount in the smallest currency unit (e.g. 5000 for $50.00).
The frameOS account associated with the transfer.
The payment method to charge. Required for the charge flow.
ISO currency code. Defaults to USD.
Optional. When provided, funds are routed to the account that owns the payment method.
Arbitrary description for your records.
Always decide how much to charge on the server, never on the client. This prevents customers from choosing their own price.
curl --request POST \
--url https://api.framepayments.com/v1/transfers \
--header 'Authorization: Bearer API_KEY' \
--header 'Content-Type: application/json' \
--data '{
"amount": 5000,
"account_id": "c1a4a27f-d6e4-46e2-9557-fd5faaa31e7d",
"source_payment_method_id": "d0a736d3-0dba-4eda-99f6-dfe27849f282",
"currency": "USD",
"description": "Order #12345"
}'
{
"id": "f47a2dd3-e0b1-55ec-b633-23c1bbc101ec",
"object": "transfer",
"status": "pending",
"amount": 5000,
"currency": "USD",
"net_amount": 4789,
"frame_fee": 211,
"description": "Order #12345",
"charge_intent": "5b8a1c0e-2f3a-4d8c-b1ab-9c7d5e2a6f01",
"source_payment_method": {
"id": "d0a736d3-0dba-4eda-99f6-dfe27849f282",
"object": "payment_method",
"type": "card",
"status": "active"
},
"destination_payment_method": null,
"billing_agreement": "b37da62b-c233-4358-967a-7910656fb94f",
"livemode": false,
"created": 1773415510
}
6. Fraud protection
Sonar evaluation is automatic for frameOS transfers. When you create a transfer, frameOS:
- Looks up the most recent Sonar session for that account (collected by Frame.js on the client) and attaches it to the intent.
- Runs fraud and geo-compliance checks before the charge is authorized.
Sonar accuracy improves the more signal it has. Load Frame.js on every page of your site (not just checkout) and capture as much profile data on the account as possible. See Sonar fraud protection for best practices.
7. Handle post-transfer events
frameOS emits webhooks as the transfer progresses. Subscribe to these events to update your order state, notify the customer, or trigger downstream processes.
| Event | Triggered when |
|---|---|
transfer.created | The transfer is successfully created |
transfer.completed | The transfer reaches a terminal success state |
transfer.failed | The transfer fails. The failure_reason field explains why |
See the Webhooks documentation for setup details.