Pix explained for global teams
Never shipped Pix before? Here is how it works from the payer's side, why it turns your checkout into a waiting room, which states and events to handle, and how to test the whole flow without moving money.
Written by KrokoPay teamPublished 7 min read
If your team has built checkouts for cards before, Pix will feel familiar for about five minutes and then very different. There is no card form, no number to validate and no authorization that comes back in the same HTTP response. Instead, the buyer leaves your page, opens their banking app and pushes the money themselves. Your job is to make that hand-off painless and to know, reliably, when it is done.
This article explains Pix from first principles for teams that have never used it, then walks through what it means for a checkout, the states a Pix charge goes through in KrokoPay, the events worth listening to and the small interface details that decide whether people finish paying.
Pix is Brazil's instant payment system. A payment is initiated by the payer, from the app of the institution where they keep their money. The merchant presents a payment request, either as a QR code to scan or as a copy-and-paste code (a long text string), and the payer confirms it inside their app. The money moves account to account; the merchant never sees an account number, password or card.
For someone used to cards, the key mental shift is the direction of the action. With a card, you pull: the buyer hands you credentials and you ask for authorization. With Pix, the buyer pushes: you publish a request and wait for them to fulfil it somewhere you cannot see.
That push model has four practical consequences for anyone designing or integrating a checkout.
- No payment form. There is nothing to type besides the buyer's basic details. The payment step is a QR code, a code to copy and clear instructions.
- The buyer leaves. On mobile they switch to the banking app; on desktop they pick up their phone. Your page must survive being backgrounded, reloaded or reopened.
- Confirmation is asynchronous. Creating the charge tells you nothing about payment. You learn the outcome later, through an event, and your page has to wait for it gracefully.
- Requests expire. A Pix code is valid for a limited window. After that the charge cannot be paid and the buyer needs a fresh code.
With KrokoPay you do not have to render any of this yourself. You create a charge on your server, redirect the buyer to its checkout_url, and the hosted checkout shows the QR code, the copy-and-paste code, the countdown and the waiting state. What you do own is the server side: knowing when the charge is paid and acting on it once.
A charge in KrokoPay starts as pending and ends in exactly one outcome. For Pix, the relevant paths are short: the buyer pays in time, or they do not.
| Status | What it means | Event |
|---|---|---|
pending | The charge exists. Once the buyer picks Pix, it gets an expires_at. | charge.created |
paid | The payer confirmed the Pix in their app and the payment was received. | charge.paid |
expired | Nobody paid before expires_at. The charge is closed. | charge.expired |
refunded / partially_refunded | You refunded all or part of a paid charge. | charge.refunded |
Notice what is missing: Pix has no failed path like a declined card. The payer either sends the money or does not, so the interesting non-happy outcome is expired. The full table, including card outcomes, is in Creating charges.
Subscribe your webhook endpoint to charge.paid, charge.expired and charge.refunded. Fulfil the order on charge.paid, release any reserved stock on charge.expired, and reverse access or update accounting on charge.refunded. A minimal handler looks like this:
// Called after the signature is verified and the event id is deduplicated.async function handleChargeEvent(event: { id: string; type: string; data: { object: Charge } }) { const charge = event.data.object; const order = await orders.findByChargeId(charge.id); if (!order) return; switch (event.type) { case "charge.paid": await orders.markPaid(order.id, charge.paid_at); await fulfil(order.id); // idempotent: safe to run twice break; case "charge.expired": await orders.markExpired(order.id); await stock.release(order.id); break; case "charge.refunded": await orders.recordRefund(order.id, charge.refunded_amount, charge.status); break; }}Warning: Do not trust the redirect
GET /v1/charges/{id} from your server) as the source of truth, never the browser.Because the buyer is doing the work in another app, the checkout's job is mostly guidance and reassurance. These are the details we design for in the KrokoPay checkout, and the ones to check if you build anything around it.
You cannot scan a QR code shown on the same phone you would scan it with. On small screens the copy button is the primary action, with clear feedback once the code is on the clipboard and a short reminder of where to paste it. On larger screens the QR code leads, because the phone is the scanner. Both stay available on every screen size: some people prefer the other one.
- Show how long the code remains valid, as a countdown derived from
expires_at, not from when the page loaded. - After the code is shown, display a calm waiting state that says the page will update by itself once the payment arrives. Announce changes politely to assistive technology.
- Never ask the buyer to press a "I have paid" button as the confirmation mechanism. At most, offer a way to check again.
- Keep the page reload-safe: reopening the checkout should show the same pending charge and the remaining time, not create a new one.
When the countdown reaches zero, say so plainly and offer the next step. An expired charge does not accept another attempt, so the way forward is a new charge: send the buyer back to the payment link it came from, or create a new charge with a new idempotency key on your server. Keep the cart intact so trying again takes one tap.
You can exercise the whole flow without a banking app or real money. In the test environment, the checkout shows a fictional QR code and copy-and-paste code together with a control that simulates the payer confirming the payment. In test mode a Pix code expires 15 minutes after it is generated; the production window is still to be defined.
- Create a charge with your test key (
kp_test_sk_…) andmethods: ["pix"], as shown in Creating charges. - Open its
checkout_url, choose Pix and simulate the payment. Check that your endpoint receivescharge.paidand fulfils the order exactly once. - Create another charge and let the countdown run out. Check that
charge.expiredreleases what you reserved and that the buyer can start over. - Refund a paid test charge and confirm your records follow
charge.refunded.
Then try the uncomfortable paths: close the tab right after paying, reload during the wait, open the same checkout on two devices. If your records still end up correct, your integration is ready for the way people actually pay.
- Pix is pushed by the payer from their banking app, via QR code or copy-and-paste code.
- Your checkout becomes a guided hand-off plus a waiting room; confirmation arrives later.
- Handle
pending → paidandpending → expired, and treat webhooks as the truth. - Lead with copy on mobile and QR on desktop, show a real countdown, and make expiry recoverable.
Want to see it before writing code? The Pix page has a live demo of the checkout flow.
