PIX Payments
PIX is Brazil's instant-payment rail. Funds settle in seconds, 24/7. Moonip gives you a single endpoint that returns a BR Code (Pix Copia e Cola) and a QR Code artifact ready to render in your checkout — in the same response that creates the charge.
#Flow at a glance
1. POST /v1/charges ────────▶ Moonip returns a Charge: ch_…, status "pending", pix.br_code + QR
2. Render QR + Pix Copia e Cola in your UI
3. Customer scans / pastes in their bank app
4. Webhook charge.paid arrives at your endpoint
5. Mark order as paid#1. Create the charge
Endpoint POST /v1/charges (Pix-first shortcut: POST /v1/pix/charges fixes payment_method: "pix" and returns the exact same Charge)
Headers
Authorization: Bearer ak_live_…
Idempotency-Key: <unique per charge you intend to create>
Content-Type: application/jsonAuthorization: Bearer <key> is the canonical way to present the API key. Idempotency-Key is required on POST /v1/charges (400 without it): any non-blank string, scoped per merchant ((merchant, key)), replaying the same key and body returns the original charge.
Body
| Field | Type | Required | Notes |
|---|---|---|---|
amount | integer | yes | Cents. 1000 = R$ 10,00. Must be a positive integer. |
currency | string | no | Defaults to "BRL" — the only currency Pix charges accept. |
payment_method | string | yes | "pix" (the only value accepted today; the /v1/pix/charges shortcut fills it in for you). |
description | string | no | Up to 500 chars. Echoed back inside metadata.description. |
expires_in | integer | no | Seconds until the charge expires (the Pix QR time-to-live). 60–604800 (up to 7 days). A value outside the range is rejected with 400 — never capped. Omit it to use the account's default window (usually 3600 = 1 h). pix.expires_at in the response is always the authoritative expiry. |
customer.name | string | no | Shown on the bank statement when supported. |
customer.document | string | no | CPF or CNPJ. |
customer.email | string | no | Used for receipts. |
customer.phone | string | no | |
expected_payer_tax_id | string | no | ⚠️ LOCKS the charge to this CPF/CNPJ: with the lock active on your account, only this document can pay and a payment from any other is refused (or returned, depending on the provider). Do not send it if the payer may be someone else. Without the lock configured on your account, the field is ignored and the charge is created normally. See Payer-CPF lock below. Requires customer.name. Must be a valid CPF/CNPJ and, if you also send customer.document, must match it. |
metadata | object | no | Free-form key/value, returned verbatim (reserved/underscore-prefixed keys are stripped). |
Example
curl -X POST https://api.moonip.io/v1/charges \
-H "Authorization: Bearer $MOONIP_API_KEY" \
-H "Idempotency-Key: ORD-7821" \
-H "Content-Type: application/json" \
-d '{
"amount": 24900,
"currency": "BRL",
"payment_method": "pix",
"description": "Pedido #7821",
"expires_in": 1800,
"customer": { "name": "Maria Silva", "document": "12345678901" },
"metadata": { "order_id": "ORD-7821", "sku": "premium-monthly" }
}'Response 201 Created
{
"id": "ch_a1b2c3d4-0000-0000-0000-000000000009",
"object": "charge",
"amount": 24900,
"currency": "BRL",
"status": "pending",
"payment_method": "pix",
"customer": { "name": "Maria Silva", "document": "12345678901" },
"pix": {
"br_code": "000201BRCODEPIX",
"qr_code_url": "https://qr.example/img.png",
"expires_at": "2026-07-23T15:00:00.000Z"
},
"checkout_url": "https://checkout.moonip.io/a1b2c3d4-0000-0000-0000-000000000009",
"settlement": {},
"metadata": { "order_id": "ORD-7821", "sku": "premium-monthly", "description": "Pedido #7821" },
"created_at": "2026-07-23T14:30:00.000Z"
}| Field | What to do with it |
|---|---|
pix.br_code | The EMV "Pix Copia e Cola" payload. Render in a copy-to-clipboard button. |
pix.qr_code_url / pix.qr_code_base64 | The QR image, when the provider returns one: qr_code_url for an https:// URL (drop it into <img src="...">), otherwise qr_code_base64 (a PNG to decode). A provider that returns only br_code yields neither — always render br_code as the fallback. |
checkout_url | Moonip's hosted checkout for this charge — a ready-made payment page (QR, copy-paste, live status). Redirect the payer here instead of building your own screen. Also returned by GET /v1/charges/{id}, so you can fetch it later from a stored id. You can't build this URL yourself: the checkout expects the id without the ch_ prefix. |
pix.expires_at | When this charge expires. Show a countdown; after this, status moves to expired. |
pix.txid | BACEN TXID — optional, present only when the provider bound one. Never treat it as the charge id. |
settlement | Always present. settlement.end_to_end_id is usually empty until settle: it appears only after the charge reaches paid and has been settled/reconciled. |
The pix object is { br_code?, qr_code_url?, qr_code_base64?, expires_at?, txid? } and is absent when there is no br_code, QR image or expiry to show.
#Statuses
| Status | Meaning |
|---|---|
pending | Created; waiting for the payer. |
processing | Payment seen; not confirmed yet (e.g. held for review). |
paid | Paid and credited. |
failed | Payment refused. |
expired | expires_at passed without payment. |
cancelled | Cancelled before payment. |
refunded | Returned to the payer. |
disputed | Chargeback or dispute. |
#TXID e end_to_end_id
ch_… is always the Moonip charge id — the one you store, look up, and reconcile webhooks by. pix.txid and settlement.end_to_end_id are contextual Pix data defined by BACEN, surfaced only when applicable:
{
"id": "ch_a1b2c3d4-0000-0000-0000-000000000009",
"pix": { "txid": "BACEN-TXID-77" },
"settlement": {}
}charge.id !== charge.pix.txid, always — even when both are present. Never key your database on the TXID or the end_to_end_id; use ch_….
#Payer-CPF lock
Set expected_payer_tax_id to require that only one specific CPF/CNPJ pays a charge — for example, to guarantee the buyer pays from their own account and not a third party's.
⚠️ Sending this field LOCKS the charge. With the lock active on your account, only the given CPF/CNPJ can pay: anyone paying from another account gets the payment refused by the bank (or returned, depending on the provider). Send it only when you want that restriction — e.g. a bet or deposit that must come from the customer's own account. If the payer may be someone else (a parent paying for a child, a company paying for a partner), do not send it: use
customer.documentalone, which is informational and locks nothing.
Availability. The lock is off by default: it depends on the provider serving your account and only applies where support has configured it. On an account without the lock configured, a charge carrying
expected_payer_tax_idis created normally, without the lock — any payer can pay and the payment is credited. You can tell from the response: the charge does not includeexpected_payer_tax_id. When the lock applies, the field is echoed on the charge and the rules below apply. Want the lock? Talk to support.
curl -X POST https://api.moonip.io/v1/charges \
-H "Authorization: Bearer $MOONIP_API_KEY" \
-H "Idempotency-Key: ORD-7821" \
-H "Content-Type: application/json" \
-d '{
"amount": 24900,
"payment_method": "pix",
"customer": { "name": "Maria Silva", "document": "12345678901" },
"expected_payer_tax_id": "12345678901"
}'How it works:
- The charge is created and paid exactly like any other Pix charge.
- At settlement, Moonip reads the real payer's document from the bank and compares it to
expected_payer_tax_id. - Match → the charge becomes
paidand your balance is credited, as usual. - A different payer paid → depends on how the lock is enforced on the provider serving your account:
- Detective (fail-closed): the charge is NOT credited. Moonip automatically returns the money to whoever paid (a standard Pix devolução, addressed by the original
end_to_end_id); the charge sits inprocessinguntil that return settles, and only then becomesrefunded. You never receive third-party funds. - Preventive: the bank-side rule is expected to refuse the other payer before the payment lands. If a third party still manages to pay (the rule did not hold), the funds are credited anyway and the charge stays
paid, flagged as a payer mismatch — the money is yours.
- Detective (fail-closed): the charge is NOT credited. Moonip automatically returns the money to whoever paid (a standard Pix devolução, addressed by the original
- The bank did not disclose the payer's document → the charge is held in
processing(never credited) for manual review, rather than crediting an unverifiable payer.
Notes:
- The capture-and-return guarantee is scoped to the detective (fail-closed) path: the charge sits in
processinguntil the automatic return (devolução) settles and only then becomesrefunded, and your merchant balance is never credited for the non-matching payer. On that path the check is settlement-time, not a bank-level block. On the preventive path a payment that slips past the bank-side rule is credited anyway and the charge stayspaid(flagged as a payer mismatch). BACEN lets any payer scan the QR in either mode, so the payer's app may still show the charge. expected_payer_tax_idrequirescustomer.name(needed on the charge and for the return), must be a checksum-valid CPF (11 digits) or CNPJ (14 digits), and — if you also sendcustomer.document— must match it. Otherwise the request is rejected400.- The value is echoed back on the charge (
expected_payer_tax_id); the real payer's document is never exposed.
#2. Fetch the charge
Endpoint GET /v1/charges/{id}
curl https://api.moonip.io/v1/charges/ch_a1b2c3d4-0000-0000-0000-000000000009 \
-H "Authorization: Bearer $MOONIP_API_KEY"Accepts either the ch_…-prefixed id or the raw id. Once the Pix has been paid and settled, settlement.end_to_end_id appears and status becomes paid:
{
"id": "ch_a1b2c3d4-0000-0000-0000-000000000009",
"object": "charge",
"status": "paid",
"amount": 24900,
"currency": "BRL",
"payment_method": "pix",
"customer": { "name": "Maria Silva", "document": "12345678901" },
"settlement": { "end_to_end_id": "E-END-TO-END-99" },
"metadata": { "order_id": "ORD-7821" },
"created_at": "2026-07-23T14:30:00.000Z"
}#Polling vs webhooks
Rely exclusively on webhooks for fulfillment / database writes — webhooks are the source of truth. GET /v1/charges/{id} is for on-demand status checks (support tooling, a manual "check status" button), not a polling loop.
#3. List charges
Endpoint GET /v1/charges — cursor pagination (never offset: there is no page/offset).
| Query | Type | Default | Notes |
|---|---|---|---|
limit | int | 25 | 1–100. |
starting_after | string | — | Cursor: the ch_… id (or raw id) of the last charge you saw. |
status | string | — | One of the statuses above. |
created_after / created_before | ISO-8601 datetime | — | Strict UTC (…Z) — offsets are rejected. |
customer_id | string | — | Filters by the payer document. |
curl "https://api.moonip.io/v1/charges?limit=25&status=paid" \
-H "Authorization: Bearer $MOONIP_API_KEY"Response 200 OK — the cursor envelope:
{
"object": "list",
"data": [ { "id": "ch_a1b2c3d4-0000-0000-0000-000000000009", "object": "charge", "status": "paid" } ],
"has_more": true,
"next_cursor": "ch_a1b2c3d4-0000-0000-0000-000000000009"
}Walk the pages by passing next_cursor as starting_after until has_more is false. Ordering is newest first.
#4. Receive the webhook
When the payer pays, you get a signed POST to your registered webhook endpoint with the canonical event envelope:
{
"id": "evt_5f3a9b2c1d4e5f6a7b8c9d0e1f2a3b4c",
"object": "event",
"api_version": "2026-07-23",
"type": "charge.paid",
"created_at": "2026-07-23T14:31:00.000Z",
"data": {
"object": {
"id": "ch_a1b2c3d4-0000-0000-0000-000000000009",
"object": "charge",
"amount": 24900,
"currency": "BRL",
"status": "paid",
"payment_method": "pix",
"settlement": { "end_to_end_id": "E-END-TO-END-99" }
}
}
}See Webhooks for signature verification, retry semantics, and registration.
#Code samples
With the official SDK (same neutral package for every brand — configure it with the API base URL of this page, https://api.moonip.io):
| node | python | php | dotnet |
|---|---|---|---|
client.charges.create(input, { idempotencyKey }) | client.charges.create(input, idempotency_key) | $client->charges->create($input, $idempotencyKey) | client.Charges.CreateAsync(input, idempotencyKey) |
client.charges.retrieve(id) | client.charges.retrieve(id) | $client->charges->retrieve($id) | client.Charges.RetrieveAsync(id) |
client.charges.list(query) | client.charges.list(**query) | $client->charges->list($query) | client.Charges.ListAsync(query) |
charges.list returns the cursor envelope { object: "list", data, has_more, next_cursor }. The raw HTTP equivalents follow.
#Node.js (fetch)
const res = await fetch('https://api.moonip.io/v1/charges', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.MOONIP_API_KEY}`,
'Idempotency-Key': order.id,
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount: 24900,
currency: 'BRL',
payment_method: 'pix',
customer: { name: order.customer.name, document: order.customer.document },
metadata: { order_id: order.id },
}),
});
if (!res.ok) throw new Error(`Moonip ${res.status}: ${await res.text()}`);
const charge = await res.json();
// charge.id (ch_…) → store on the order; charge.pix.br_code / qr_code_url → render immediately#PHP
$ch = curl_init('https://api.moonip.io/v1/charges');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('MOONIP_API_KEY'),
'Idempotency-Key: ' . $order->id,
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'amount' => 24900,
'currency' => 'BRL',
'payment_method' => 'pix',
'customer' => ['name' => $order->customerName],
'metadata' => ['order_id' => $order->id],
]),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 201) throw new Exception("Moonip $status: $body");
$charge = json_decode($body, true);#Python (requests)
import os, requests
r = requests.post(
"https://api.moonip.io/v1/charges",
headers={
"Authorization": "Bearer " + os.environ['MOONIP_API_KEY'],
"Idempotency-Key": order.id,
},
json={
"amount": 24900,
"currency": "BRL",
"payment_method": "pix",
"customer": {"name": order.customer.name},
"metadata": {"order_id": order.id},
},
timeout=10,
)
r.raise_for_status()
charge = r.json()#Edge cases & FAQ
Q: pix.br_code / QR fields are missing from the response.
A: Pix-first creation returns them synchronously. If they're absent, status will usually already show failed — check the request for a validation error first (GET /v1/charges/{id} to re-read the stored charge).
Q: How long is the QR valid?
A: See pix.expires_at on the charge — it is set per charge. After expiry, status becomes expired and a new charge must be created.
Q: Can I refund a PIX charge?
A: Refunds are requested from the admin panel today — the API does not expose a merchant-usable refund endpoint (it refuses API-key callers). The same goes for /v1/charges/{id}/refund, which does not exist.
Q: Will I be charged fees if the customer never pays?
A: No. Fees apply only on paid charges, and are never itemized in the public charge response (see API conventions §3).
Q: My customer paid the wrong amount.
A: PIX is exact-match. Underpaid charges stay pending and the bank refunds the payer. Overpaid is rare but treated the same way.