Build a custom payment page
This guide covers the fully custom checkout path: you build the page UI, you control the surrounding UX (cart summary, shipping, copy, branding), and you embed frame-js's Card element to handle the raw card-input collection. Frame handles tokenization + PCI compliance; you handle everything else.
The trade-off vs payment links: more work, more control. Use this guide when your checkout flow needs to fit alongside your platform's existing UX (in-app checkout, complex multi-step flows, conditional fields, custom validation).
When to build vs use a hosted alternative
| Need | Path |
|---|---|
| Frame-hosted page, fixed price, share-a-URL workflow | Payment links |
| Frame-hosted page, customer enters amount (donations, tips) | Custom Payment Page (dashboard feature; see Custom Payment Page in the dashboard) |
| Embed in your own application UI; full control over the flow | This guide (frame-js Card element) |
| Generic accept-a-payment integration | Accept a payment — overlaps with this guide but lighter on customization detail |
Prerequisites
| Requirement | Details |
|---|---|
| Frame publishable key | For frame-js initialization. |
| Frame secret key | For server-side payment-method + transfer creation. |
| Frame.js loaded | Card element rendering requires frame-js on the client. |
| Sonar session active | Load frame-js site-wide (not just on checkout) for full fraud-signal coverage. See Sonar. |
1. Initialize frame-js on the client
Load frame-js from Frame's CDN — not a local copy. Local copies miss tracking-code updates that degrade fraud detection.
<script src="https://js.framepayments.com/v1/frame.js"></script>
Initialize with your publishable key (in your platform-init script, on every page if possible):
const frame = await Frame.init('pk_sandbox_your_publishable_key');
The init creates a Sonar session and stores its ID in localStorage under frame_charge_session_id. You'll pass this along with the charge later.
2. Mount the Card element
Add a container to your checkout page where the card input will render. Mount frame-js's Card element into it:
<form id="payment-form">
<div id="frame-card-element"></div>
<button type="submit">Pay</button>
</form>
const cardElement = await frame.createElement('card');
cardElement.mount('#frame-card-element');
The Card element renders Frame-controlled iframes for the actual card number, expiration, and CVC inputs. Your page never touches the raw card data — that stays inside Frame's iframes, which is what keeps you out of PCI scope.
You can style the Card element to fit your design via the options object passed to createElement — fonts, colors, spacing all map to standard CSS-style configuration. See frame-js's Card element reference for the full option set.
3. Capture the encrypted card payload on submit
The Card element emits a change event as the customer types. When payload.isComplete is true, the payload carries encrypted card.number and card.cvc (plus plain expiry and optional billingAddress) — safe to forward to your own server, since the sensitive fields never leave frame-js in plaintext. Hold the latest complete payload and send it on form submission:
let cardPayload = null;
cardElement.on('change', (payload) => {
cardPayload = payload.isComplete ? payload : null;
});
document.getElementById('payment-form').addEventListener('submit', async (e) => {
e.preventDefault();
if (!cardPayload) return; // card fields incomplete — surface validation instead
await fetch('/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
cardPayload,
sonarSessionId: localStorage.getItem('frame_charge_session_id'),
cart: getCart()
})
});
});
There is no client-side "create payment method" call — the payment method is created server-side in the next step, bound to the right account from the start.
4. Exchange the payload + create the charge server-side
Your backend exchanges the encrypted payload for a payment method via POST/v1/payment_methods, passing the encrypted fields through unchanged and account to bind it (see accept-a-payment for the full parameter table). Then create the charge:
curl --request POST \
--url https://api.framepayments.com/v1/transfers \
--header "Authorization: Bearer $FRAME_SECRET_KEY" \
--header 'Content-Type: application/json' \
--data '{
"amount": 5000,
"currency": "USD",
"account_id": "<account_id>",
"source_payment_method_id": "<payment_method_id from the exchange above>",
"sonar_session_id": "<sonar_session_id from step 3>",
"description": "Order #1042"
}'
If the customer is new (no Frame account yet), create the account first; if they're returning, look up by external_id or email and reuse the existing account. See accept-a-payment for the dedupe pattern.
5. Handle the response
For straightforward charges (no 3DS), the transfer settles synchronously and the response carries the final status. For 3DS-triggered charges, you'll need to redirect back to the client to complete authentication — see Handle 3D Secure for the pattern.
The most direct shape:
// After server returns the transfer
if (transfer.status === 'succeeded') {
showOrderConfirmation();
} else if (transfer.status === 'failed') {
showError(transfer.failure_code);
} else if (transfer.status === 'requires_3d_secure') {
// Complete 3DS via frame.confirmCardPayment — see handle-3d-secure guide
const { chargeIntent } = await frame.confirmCardPayment(transfer.client_secret);
// Handle final status
}
The requires_3d_secure path means Frame's engine decided to run 3DS; the client SDK handles the authentication UI, you handle the eventual outcome.
Styling and theming
frame-js's Card element accepts a styling configuration that maps to standard CSS-shaped properties:
const cardElement = await frame.createElement('card', {
style: {
base: {
fontSize: '16px',
color: '#1a1a1a',
fontFamily: 'Inter, sans-serif',
'::placeholder': { color: '#999' }
},
invalid: {
color: '#d32f2f'
}
}
});
A few rules of thumb:
- Match your platform's input styles (font size, padding, focus ring) so the Card element doesn't visually pop out.
- Use the
invalidstyle to surface validation errors that frame-js detects on bad card numbers / expirations. - Don't try to recreate native browser autofill UI — Frame's iframe handles autofill correctly out of the box.
Common variations
Multi-step checkout. Mount the Card element in the final "payment" step of a multi-step flow. Frame's Sonar session tracks the customer across all the prior steps, improving fraud signal quality at the actual charge moment.
Saved card on file. For returning customers, look up their previously-attached payment method by account_id and use its payment_method_id directly on the charge — skip the frame-js tokenization step entirely.
Pay-what-you-want. For donation flows, collect the amount in your own UI before tokenization, then pass it as amount on the charge. Or use Frame's dashboard Custom Payment Page feature if you don't need the surrounding UX control.
Apple Pay / Google Pay. Use the Payment Request Button element instead of (or alongside) the Card element. Both can coexist on the same page; the customer chooses.
Gotchas
Symptom: the Card element renders but pressing submit does nothing. Why: cardPayload is still null — the customer hasn't completed all card fields (payload.isComplete never went true), or the form's submit event isn't being prevented (e.preventDefault()). Fix: surface field-level validation from the change event so the customer sees what's missing, and confirm your handler prevents the default submission.
Symptom: card validation errors don't surface to the customer. Why: Frame's Card element emits change events with validation state, but you have to listen for them. Fix: attach a change listener to the cardElement and surface event.error.message to a visible UI element.
Symptom: charges work in your local dev but fail in production with sonar_session_required. Why: localhost domains don't fully initialize Sonar tracking. The production domain may have different cookie / localStorage behavior breaking the session retrieval. Fix: verify localStorage.getItem('frame_charge_session_id') returns a non-empty value on the production checkout page before submitting the charge.
Next steps
- Handle 3D Secure — completing authentication when triggered
- Use the payment request button — Apple Pay + Google Pay alternative to the Card element
- Sonar — making sure fraud signal quality is high on your custom integration