PT
Overview

Building JoBoEco, Part 3: Pix in practice

March 3, 2026
7 min read

Payments are different

There’s a fundamental difference between implementing a paper submission system and implementing a payment system. The first one, if it bugs out, you lose data and the user gets frustrated. The second one, if it bugs out, you lose money — the user’s, the organization’s, or both.

That awareness changes how you think about design. A lot.

In JoBoEco, payments appear in two contexts: event registration (participation fee) and paper submissions (per-submission fee, depending on the participant’s category and chosen area). Both use Pix as the method, via MercadoPago.

Why MercadoPago

In Brazil, if you need Pix as a payment method in a web application with an API, the real options are more limited than they seem.

Stripe doesn’t support Pix. Gerencianet/Efí does, but it requires an active CNPJ and the onboarding process is bureaucratic. PagSeguro has a Pix API, but the developer experience has historically been problematic.

MercadoPago has a reasonably good API, decent documentation in Portuguese, and onboarding that actually works. The processing fee for JoBoEco’s volume is acceptable. The choice was essentially by elimination.

Static QR vs Dynamic QR

Before writing any code, I needed to understand this distinction — and it matters more than it seems.

A Static QR links the Pix to a key (CPF, email, phone number). Any amount can be paid, the payer defines the amount, and there’s no way to automatically trace which transaction corresponds to which registration. Useless for an automated system.

A Dynamic QR generates a specific charge with a fixed amount, a defined expiration, and a unique identifier. You control exactly what’s being charged and can reconcile the payment with the entity in your database. It’s the only one that makes sense here.

MercadoPago exposes this via a payment with payment_method_id: "pix". The response includes point_of_interaction.transaction_data.qr_code_base64 (the QR image for display) and qr_code (the text payload for copy-and-paste).

src/lib/mercadopago.ts
import { MercadoPagoConfig, Payment } from 'mercadopago'
const client = new MercadoPagoConfig({
accessToken: process.env.MERCADOPAGO_ACCESS_TOKEN!,
})
export async function createPixPayment({
amount,
description,
externalReference,
payerEmail,
}: {
amount: number
description: string
externalReference: string
payerEmail: string
}) {
const payment = new Payment(client)
return payment.create({
body: {
transaction_amount: amount,
description,
payment_method_id: 'pix',
external_reference: externalReference,
payer: { email: payerEmail },
},
})
}

Simple on the outside. The external_reference field is the important detail — I’ll come back to it shortly.

The external_reference problem

When you have two distinct payment types (registration and submission), you need some way to know, upon receiving a webhook, which entity that payment corresponds to. MercadoPago sends back the external_reference you provided — but you need to parse it in the handler.

The naive approach would be to store a lookup table: payment_id → type + entity_id. It works, but creates a database dependency in the webhook hot path.

The approach I adopted was a typed format by convention in the external_reference itself:

order:{userId}:REGISTRATION → event registration
submission:{userId}:{orderId} → paper submission fee

In the webhook and in polling, the first step is parsing this format:

function parseExternalReference(ref: string) {
const parts = ref.split(':')
if (parts[0] === 'order') {
return { type: 'REGISTRATION' as const, userId: parts[1] }
}
if (parts[0] === 'submission') {
return {
type: 'SUBMISSION' as const,
userId: parts[1],
orderId: parts[2],
}
}
throw new Error(`Unknown external_reference format: ${ref}`)
}

This eliminates the lookup. You know the type and relevant IDs directly from the field MercadoPago sends back, without touching the database before deciding what to do.

Polling vs Webhooks — and why I use both

In an ideal world, you implement webhooks and that’s it. MercadoPago notifies you when the payment is confirmed, you update the database, the user sees the status change.

The real world has two problems with that approach:

Problem 1: Webhooks might not arrive. Network timeouts, your server down for a second, a race condition in the handler, a silent bug. If the webhook isn’t received, the payment stays stuck in PENDING forever from the system’s perspective. The user paid, but the system doesn’t know.

Problem 2: The user is waiting on screen. When someone completes the Pix and is staring at the screen, you need to update the status in quasi-real-time. Asking the user to reload the page is not an acceptable experience.

The solution was combining both approaches:

  • On the frontend: polling every 3 seconds to an endpoint that checks the payment status directly with MercadoPago
  • On the backend: MercadoPago webhook updates the database when the event arrives, as a durability guarantee
src/app/api/payments/status/[paymentId]/route.ts
export async function GET(
_request: Request,
{ params }: { params: Promise<{ paymentId: string }> },
) {
const { paymentId } = await params
// Fetch current status directly from MercadoPago
const mpPayment = await getPaymentById(paymentId)
if (mpPayment.status === 'approved') {
// Persist to database if not yet approved
await syncPaymentStatus(paymentId, 'PAID')
}
return Response.json({ status: mpPayment.status })
}

If the user closes the tab before confirmation, the webhook will still update the database when it arrives. If the webhook fails for some reason, when the user returns, polling will find the updated status in MercadoPago and sync it. Both paths complement each other.

The webhook handler

The MercadoPago webhook sends a POST with the payment id. You then fetch the details from the API and process them.

One thing the documentation doesn’t make sufficiently clear: MercadoPago can send the same event multiple times — automatic retries on timeout in your response, or simply platform behavior. Your handler needs to be idempotent.

src/app/api/webhooks/mercadopago/route.ts
export async function POST(request: Request) {
const body = await request.json()
// MP sends different notification types; we only want payments
if (body.type !== 'payment') {
return Response.json({ ok: true })
}
const paymentId = body.data?.id
if (!paymentId) return Response.json({ ok: true })
const payment = await getPaymentById(String(paymentId))
if (payment.status !== 'approved') {
return Response.json({ ok: true })
}
// Idempotency: check if this payment has already been processed
const existing = await getOrderPaymentByMpId(String(paymentId))
if (existing?.status === 'PAID') {
return Response.json({ ok: true }) // already processed, respond 200 silently
}
const ref = parseExternalReference(payment.external_reference!)
await processApprovedPayment(ref, payment)
return Response.json({ ok: true })
}

Note that the handler always returns 200, even in cases where there’s nothing to do. This is deliberate: if you return a 4xx or 5xx, MercadoPago will retry. If the “error” is something that won’t resolve with a retry (like an external_reference in an invalid format), you want to log the problem but not enter a retry loop.

The problem of forgotten payments

There’s an edge case I only noticed when a user complained: they created the Pix, didn’t pay right away, closed the tab, and came back hours later to complete the payment. The QR was still valid (24h default), the payment was confirmed via webhook — but when they tried to access the system, they didn’t appear as registered.

The problem: there were two records for them in the database — the original orderPayment in PENDING (created when they generated the QR) and… nothing else. The webhook had arrived and processed the payment, but mapped to a userId that didn’t match the current session due to a parsing bug.

The fix was simple, but the lesson was important: test the full flow with the payment made outside the main window. The happy path (user pays immediately, webhook arrives, everything matches) works easily. Edge cases surface when the user acts non-linearly.

I also added a check at checkout entry: if there’s already an orderPayment in PENDING for that user at the current event, instead of creating a new QR, the system displays the original one that’s still active. This prevents duplicate charges and improves the experience for anyone who interrupted the flow.

The main lesson

Treat payments as a state machine, not a linear flow. An orderPayment can be in PENDING, PAID, EXPIRED, or REFUNDED. Always persist the current state and state transition timestamps. When something goes wrong in production — and it will — you’ll want the complete history to understand what happened.

-- The table that controls the payment lifecycle
orderPayments {
id, userId, eventId,
mpPaymentId, -- ID in MercadoPago
externalReference, -- "order:userId:REGISTRATION"
status, -- PENDING | PAID | EXPIRED | REFUNDED
amount,
pixQrCode, -- payload for copy and paste
pixQrCodeBase64, -- QR image
createdAt, paidAt, expiresAt
}

In the next part, I get into what was the biggest design challenge in the project: the eligibility engine for submissions. That’s where the business rules get complex enough to justify a dedicated service layer — and where I learned that truly isolating business logic is harder than it looks.