Embedded Checkout (ninepay.js)
The embedded checkout puts the payment of a NinePay invoice inside your page. Your page has the title, the amount and the pay button; the NinePay iframe has only the payment methods, the card fields and the PIX QR code, styled to match your page.
The card goes from the iframe straight to NinePay: your page never sees it.
Before you integrate
- Register your site address. In the dashboard (user menu → Integrações → Checkout embutido), register the addresses that will open the checkout: only
https://domainorhttps://domain:port, without a path, up to five. Without any, the browser refuses the iframe. - Check the plans' methods. The iframe offers the invoice's
allowed_methods, in this order: card, PIX Automático, PIX and bank slip. Open Finance does not appear in the embedded checkout, because the bank does not open in an iframe. Subscription invoices inherit the plan's methods.
On your server
Everything that uses the API token stays on your server. The token never goes to the browser.
A one-off charge
Create the open invoice, without a card, with open: true and the methods the iframe may offer:
curl -X POST https://payments.cefis.com.br/api/v1/payments \
-H "Authorization: Bearer $NINEPAY_TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"customer": { "id": 123 },
"method": "credit",
"open": true,
"allowed_methods": ["credit", "pix", "billet"],
"value": 5000,
"due_date": "2026-11-10"
}'allowed_methods is required with open. Nothing is charged on creation: the invoice waits for payment in the iframe. Send the payment.uuid from the response to the page.
A subscription
Create the subscription with the method and without a card. It starts as new, with the first invoice open; that invoice is the one that goes to the iframe, and paying it activates the subscription.
curl -X POST https://payments.cefis.com.br/api/v1/subscriptions \
-H "Authorization: Bearer $NINEPAY_TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"customer": { "id": 123 },
"plan": { "id": 456 },
"method": "credit",
"allowed_methods": ["credit"]
}'- The response contains
customer,subscriptionandpayment: send thepayment.uuidto the page. - Without the
method, the subscription is created without any invoice, and there is nothing to pay in the iframe. allowed_methodsis optional: a subset of the plan's methods, for the first invoice only (later ones use the plan's). With["credit"], the iframe shows only the card.
After the payment
- Grant or suspend access only through the webhook, confirming with
GET /payments/{id}. Thepaidevent on the page is for updating the screen: a browser event can be forged. - A
newsubscription that is not paid is not canceled automatically. Cancel the subscription withPOST /subscription/canceland the body{ "subscription": { "id": 789 } }, and the open invoice withDELETE /payments/{id}. - A canceled invoice can no longer be paid. Cancel the invoices you no longer want.
On the page
The script always has the same address, https://checkout.ninepay.com.br/js/v1/ninepay.js. Fixes and new v1 features reach it without you changing anything; a breaking change gets a new address (/js/v2/), and v1 stays online. Ninepay.version reports the exact build.
The dashboard's Integrations screen generates this code with the look and options you choose.
<div id="pagamento"></div>
<button id="pagar" disabled>Pagar</button>
<script src="https://checkout.ninepay.com.br/js/v1/ninepay.js"></script>
<script>
const ninepay = Ninepay()
const elements = ninepay.elements({
payment: '<uuid da fatura>', // comes from your server
// Optional: without it, the look saved in the dashboard applies
appearance: {
theme: 'default', // or 'night' | 'flat'
labels: 'floating', // or 'above' | 'auto'
inputs: 'condensed', // or 'spaced'
variables: { colorPrimary: '#0057b8', fontFamily: 'Inter', borderRadius: 8 },
rules: { '.Tab--selected': { borderColor: '#0057b8' } }
}
})
const pagamento = elements.create('payment', {
layout: 'tabs', // or 'accordion' | 'steps'
paymentMethodOrder: ['credit', 'pix', 'billet'],
saveCard: 'ask' // or 'always' | 'never'
})
pagamento.mount('#pagamento')
const pagar = document.getElementById('pagar')
// The button is only enabled once the fields of the chosen method are complete
pagamento.on('change', event => {
pagar.disabled = !event.complete
})
pagamento.on('paid', () => {
// Paid on this screen (including PIX or bank slip paid later).
// Update the screen; access is granted through the webhook.
})
pagar.addEventListener('click', async () => {
pagar.disabled = true
const { error: invalido } = await elements.submit() // validates, without sending
if (invalido) {
pagar.disabled = false
return
}
const { error, payment } = await ninepay.confirmPayment({ elements })
if (error) {
pagar.disabled = false // the decline is already shown in the iframe
return
}
// payment.status: 'succeeded' (card approved) or 'requires_action'
// (the PIX QR code or the bank slip on screen, waiting for payment)
})
</script>Note
The iframe has no pay button. The button belongs to your page: it calls elements.submit() and then ninepay.confirmPayment({ elements }).
Changing the card
The same script, with the subscription uuid and mode: 'setup': only the card fields, and your page's button confirms.
const elements = ninepay.elements({ subscription: '<uuid da assinatura>', mode: 'setup' })
elements.create('payment').mount('#cartao')
botao.onclick = async () => {
const { error, subscription } = await ninepay.confirmSetup({ elements })
// subscription.status: 'succeeded' (the new card is now the subscription's card)
}Always use the subscription uuid, which comes from your server; never the numeric id.
Reference
| Call | What it does |
|---|---|
Ninepay(options?) | Creates the client. options.origin only for another environment (sandbox); without it, the origin the script was loaded from applies |
Ninepay.version | The exact build, 1.<commit> |
ninepay.elements({ payment, appearance? }) | The group for a payment. To change the card: { subscription, mode: 'setup', appearance? } |
elements.create('payment', options?) | The element. Options: layout, paymentMethods, paymentMethodOrder, texts, saveCard, title |
elements.update({ appearance }) | Changes the look; the mounted element follows |
elements.submit() | Validates and shows errors in the iframe, without sending: { selectedPaymentMethod } or { error } |
element.mount(target), unmount(), destroy() | Adds and removes the iframe; after destroy, it can no longer be mounted |
element.update(options) | Changes the element's options (does not change the invoice, the subscription or the mode) |
element.focus(), blur() | Focus on the first empty field, or out of the iframe |
element.clear() | Clears the card fields; the chosen method stays |
element.collapse() | Goes back to choosing the method (accordion and steps layouts) |
element.on/once/off(event, fn) | The events below; an unknown name throws an error |
ninepay.confirmPayment({ elements }) | Pays: { payment: { id, status, method } } or { error }. Always resolves |
ninepay.confirmSetup({ elements }) | Changes the card: { subscription: { id, status } } or { error } |
Element options
| Option | Values | What it does |
|---|---|---|
layout | 'tabs' (default), 'accordion', { type: 'accordion', defaultCollapsed?, radios?: 'always' | 'never', spacedAccordionItems? } or 'steps' | How the person chooses the method: tabs, a list that expands the chosen method, or a choice screen with "Trocar forma de pagamento" (change payment method) |
paymentMethods | list of methods | Only these methods appear, always within those the invoice accepts. Empty or missing, all of the invoice's methods |
paymentMethodOrder | list of methods | Only reorders the remaining methods |
texts | 'card.holder', 'card.number', 'card.expiry', 'card.cvc' | Change the card field labels (text, up to 40 characters) |
saveCard | 'ask' (default), 'always' or 'never' | The "Cobrar automaticamente os próximos pagamentos neste cartão" (charge future payments to this card automatically) checkbox, on subscription invoices. 'always' only applies when the subscription is already on card; the subscription's method never changes without the person agreeing |
title | text | The iframe title, for screen readers |
The methods are credit, pix_automatic, pix and billet. For example, on a one-off installment of a subscription with PIX Automático, paymentMethods: ['credit', 'pix', 'billet'] hides PIX Automático.
Events
All of them come with elementType: 'payment'.
| Event | Data | When |
|---|---|---|
loaderstart | — | Right after mount |
ready | status, payable, methods | Once per mount. payable is false when the invoice is paid, canceled or refunded, or has no available method |
change | empty, complete, collapsed, value: { type } | The person chooses or switches the method, or the fields become complete or empty |
focus, blur | — | Focus enters or leaves the iframe (also when switching browser tabs) |
escape | — | The person pressed Esc inside the iframe |
copy | value: { type } | The person copied the PIX code or the bank slip line (the code is not included). When you listen to copy, the "Código copiado!" notice is up to your page |
paid | — | The payment made on this screen, including PIX or bank slip paid later |
loaderror | error | The payment did not load, or the iframe did not open within 15 s (check the site address in the dashboard) |
resize | height | The content height changed |
Errors
Errors come as { type, code, message }, with message in Portuguese, ready to display:
type | code |
|---|---|
validation_error | incomplete, no_method |
card_error | card_declined |
invalid_request_error | not_ready, not_payable, in_progress, not_mounted, destroyed, wrong_mode |
rate_limit_error | rate_limited |
api_error | api_error, unexpected |
api_connection_error | timeout (no response within 65 s on confirm or 15 s on submit), load_timeout (the iframe did not open) |
Appearance
appearance accepts { theme?, labels?, inputs?, variables?, rules? }. Anything that does not follow the format is ignored, without an error, and the default applies.
theme:'default'(neutral),'night'or'flat', the starting point for the variables.labels:'auto'(default),'above'(the label above the field) or'floating'(inside the field, moving up as you type).inputs:'spaced'(default) or'condensed'(the card fields in a single block).
The company's default appearance. In Integrações → Checkout embutido, in the dashboard, you save a theme and the variables; the iframe uses this appearance when the page does not send appearance. What the page sends wins, variable by variable; a theme different from the saved one discards the saved variables. A change in the dashboard shows up within a minute.
Variables
| Variable | Format | Where it applies |
|---|---|---|
colorPrimary | #rgb or #rrggbb | The buttons (methods, copy code) |
colorOnPrimary | #rgb or #rrggbb | The button text |
colorText | #rgb or #rrggbb | The text and the base of the field borders |
colorTextSecondary | #rgb or #rrggbb | Supporting text |
colorTextPlaceholder | #rgb or #rrggbb | The placeholder text inside the fields |
colorBackground | #rgb or #rrggbb | The background of the iframe and the fields |
colorBorder | #rgb or #rrggbb | The field borders |
colorDanger | #rgb or #rrggbb | Field errors |
fontFamily | comma-separated font names, up to 120 characters | All text. System fonts or Inter, which the checkout serves (external fonts cannot be loaded) |
fontSizeBase | number, 12 to 20 (px) | The text size. Below 16, phones zoom in when a field gets focus |
borderRadius | number, 0 to 24 (px) | The corners of fields and buttons |
spacingUnit | number, 1 to 8 (px) | The padding inside the fields (default 2) |
Contrast
Contrast of the variables is the integrator's responsibility. For the fields to be legible and accessible, use text and background with at least 4.5:1, and border and background with 3:1 (WCAG 1.4.3 and 1.4.11).
Rules
rules applies CSS by public class: { ".Tab--selected": { "borderColor": "#47a472" } }. Each selector is a class, with at most one state and one pseudo-class, in that order (.Class--state:pseudo).
| Class | States | Pseudo-classes | Where |
|---|---|---|---|
.Tab, .TabIcon, .TabLabel | --selected | :hover :focus :active :disabled | The method tabs (tabs layout) |
.AccordionItem | --selected | :hover :focus-visible | Each method in the accordion layout |
.RadioIcon | --checked | — | The radio of each accordion method |
.StepOption | — | :hover :focus-visible | Each method in the steps layout choice |
.StepHint | — | — | The sentence under each method in steps |
.StepBack | — | :hover :focus-visible | The "Trocar forma de pagamento" (change payment method) link in steps |
.Label | --empty --invalid --focused --floating --resting | — | The label of each card field |
.Input | --empty --invalid --complete | :hover :focus :disabled :autofill ::placeholder ::selection | The card fields |
.Error | — | — | Each field's error and the card decline |
.Block | — | — | The box with the PIX code and the bank slip line |
.Text | — | — | The PIX, PIX Automático and bank slip texts |
.CopyButton | — | :hover :focus-visible | The copy buttons |
.Link | — | :hover :focus-visible | The "Acessar boleto" (open bank slip) link |
| Property | Format |
|---|---|
color | A color (as below) with alpha of 0.5 or higher |
backgroundColor, borderColor | #rgb, #rrggbb, #rrggbbaa, rgb()/rgba() or transparent |
borderWidth | Up to 4 lengths from 0 to 4px |
borderStyle | solid, dashed or none |
borderRadius | Up to 4 lengths from 0 to 999px |
boxShadow | none, or up to two shadows with offsets up to 24px, blur up to 24px and spread up to 4px |
padding | Up to 4 lengths from 0 to 32px |
fontFamily | Same as the variable |
fontSize | From 10 to 24px |
fontWeight | normal, bold or from 300 to 800 |
letterSpacing | From -2 to 4px |
lineHeight | From 1 to 2 unitless, or from 12 to 36px |
textTransform | none, uppercase, lowercase or capitalize |
transition | Up to 4 of color, background-color, border-color or box-shadow, with the duration in ms |
A length is 0 or a number in px, rem or em (rem and em count as 16px). Not accepted: url(), var(), !important, attribute, descendant or sibling selectors, or properties that change the layout (position, display, width, height, margin, transform and similar). Limits: 40 selectors, 12 properties per selector and 120 characters per value. A rule with color or backgroundColor contrast below 3:1 is discarded. In older browsers (before Safari 16.4), rules do not apply and only the variables are used.
Precautions
- Always load the script from
https://checkout.ninepay.com.br, never from a copy or a CDN: the iframe's origin is the one the script came from. For the sandbox, passNinepay({ origin: 'https://...' }); onlyhttpsis accepted (orhttp://localhost, for testing). - The page's CSP must allow the checkout:
script-srcandframe-srcwithhttps://checkout.ninepay.com.br. - Display any data that comes from the iframe as text, never in
innerHTML. - The script does not use SRI, because it receives fixes at the same address. If you need SRI, ask NinePay for a frozen copy.
- Do not mount the checkout inside another iframe. If your page puts
sandboxon a surrounding iframe, includeallow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox(the bank slip PDF opens in another tab). - The iframe limits confirmations (one at a time and five per minute), but protection against card testing is NinePay's, on the server.