API Reference
Complete catalogue of every public Moonip v1 endpoint. All paths are relative to the production base URL — https://api.moonip.io/v1. (A dedicated sandbox host is not yet available.)
Authenticate every request with your API key in the Authorization: Bearer <key> header — Authorization: Bearer <MOONIP_API_KEY> (ak_test_*/ak_live_*). The legacy apikey: <key> header is still accepted for compatibility. All bodies are JSON. All amounts are integers in the smallest currency unit.
The API is organised around a few resources:
- Charges (
/v1/charges) — create and read Pix charges:object: "charge",ch_…ids, headerIdempotency-Key,pixblock. This is where every integration starts. - Wallets (
/v1/wallets) — your account balance. - Payment operations (
/v1/payments) — cancellation, stats, receipts and other operations on a charge, addressed by its raw id (thech_prefix stripped). Refunds are issued from the admin panel, not from this API. - Webhooks (
/v1/webhooks) — register endpoints and inspect deliveries.
#Charges
/v1/charges.
Public contract from API conventions §3. Every response is built by an allowlist serializer — no internal id, PSP name, cost or raw provider payload can appear here.
#Create charge
POST /v1/charges — Pix-first shortcut: POST /v1/pix/charges fixes payment_method: "pix" in the body and returns the exact same Charge.
Headers
| Header | Required | Notes |
|---|---|---|
Authorization | yes | Bearer + your API key (ak_test_* / ak_live_*). The legacy apikey header is still accepted. |
Idempotency-Key | yes | Any non-blank string, scoped by (merchant, key). A financial write — missing it is a 400. |
Body
| Field | Type | Required | Description |
|---|---|---|---|
amount | integer | yes | Smallest currency unit, > 0. |
currency | string | no | Defaults to "BRL". Pix charges must use BRL. |
payment_method | string | yes | "pix" — the only value accepted today. |
description | string | no | Up to 500 chars; folded into metadata.description on read. |
expires_in | integer | no | Pix validity in seconds, 60–604800 (7 days). Outside that range the API answers 400 — the value is never capped. |
customer.name | string | no | |
customer.document | string | no | CPF or CNPJ. |
customer.email | string | no | |
customer.phone | string | no | |
expected_payer_tax_id | string | no | Locks the charge to a payer: checksum-valid CPF (11) or CNPJ (14) digits. If you also send customer.document, the two must match; if you set it, customer.name becomes required. |
metadata | object | no | Free-form; reserved/underscore-prefixed keys are stripped. |
Example
curl -X POST https://api.moonip.io/v1/pix/charges \
-H "Authorization: Bearer $MOONIP_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"amount": 1000,
"currency": "BRL",
"description": "Pedido #12345",
"customer": { "name": "Maria Silva", "document": "12345678901" }
}'Response 201 Created
{
"id": "ch_a1b2c3d4-0000-0000-0000-000000000009",
"object": "charge",
"amount": 1000,
"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": "12345" },
"created_at": "2026-07-23T14:30:00.000Z"
}pix.txid (when the provider bound one) and settlement.end_to_end_id (only once paid and settled) are contextual Pix data — see PIX. charge.id is never equal to charge.pix.txid.
Replaying the same Idempotency-Key with the same body returns the original 201 and sets the response header Idempotency-Replayed: true.
checkout_url is Moonip's hosted checkout for the charge — redirect the payer there instead of rendering your own Pix screen. It comes back on both create and GET /v1/charges/{id}, and can't be derived from charge.id (the checkout path drops the ch_ prefix).
Public status vocabulary: pending, processing, paid, failed, expired, cancelled, refunded, disputed.
#Get charge
GET /v1/charges/{id}
Accepts either the ch_…-prefixed id or the raw internal id. Scoped to the caller's account — a charge from another account 404s.
Response 200 OK — same Charge shape as creation.
#List charges
GET /v1/charges
GET /v1/charges lists the account's charges with cursor pagination ({ object: "list", data, has_more, next_cursor }) — ordering is createdAt desc, stable, no page/offset on this route.
GET /v1/charges?limit=25&starting_after=ch_01J…&status=paid| Param | Type | Default | Description |
|---|---|---|---|
limit | integer | 25 | 1–100. |
starting_after | string | — | A charge id (ch_…) of the same resource — cursor, not an offset. |
status | enum | — | Public status vocabulary (pending, paid, …). |
created_after | ISO 8601 | — | Inclusive lower bound on created_at. |
created_before | ISO 8601 | — | Inclusive upper bound on created_at. |
customer_id | string | — | Filters by payer document. |
{
"object": "list",
"data": [],
"has_more": false,
"next_cursor": null
}page/offset are never accepted on this cursor list — only starting_after. When has_more is true, pass next_cursor as starting_after to fetch the next page.
For a paged, filterable view of the same transactions, see GET /v1/payments.
#Wallets
/v1/wallets. Your account's balances. Read-only, scoped to your API key — the write routes under /v1/wallets/* are dashboard-only and answer 403 to an API key.
#Get balance
GET /v1/wallets/balance
The currency query param is optional. Without it (the recommended form) the response carries every currency on the account in balances[], plus the primary currency's fields (BRL by default) at the top:
curl "https://api.moonip.io/v1/wallets/balance" \
-H "Authorization: Bearer $MOONIP_API_KEY"Response 200 OK
{
"available": 880,
"pending": 0,
"total": 880,
"retained": 0,
"currency": "BRL",
"next_release_at": null,
"next_release_amount": null,
"balances": [
{ "currency": "BRL", "available": 880, "held": 0, "pending": 0, "total": 880 }
],
"primary": { "currency": "BRL", "available": 880, "held": 0, "pending": 0, "total": 880 }
}| Field | Description |
|---|---|
available | Spendable/payoutable balance of the primary currency, smallest unit (centavos for BRL). 880 = R$ 8,80. Signed — it goes negative when the account is in debt. |
held | Amount held (retained mid-flight) in balances[]/primary entries. Not present at the top level. |
pending | Settled but still held — not spendable yet (primary currency). Includes in-flight refund holds, so it can be greater than retained. |
total | available + held + pending of the primary currency. |
retained | Total under retention (PENDING holds) of the primary currency. Does not include refund holds. |
currency | Primary currency (ISO 4217). Defaults to BRL. |
next_release_at | ISO 8601 string of when the next pending tranche releases, or null when nothing is scheduled. |
next_release_amount | Amount of that next release (smallest currency unit), or null. |
balances[] | One entry per currency: { currency, available, held, pending, total }. |
primary | The primary currency (same fields as a balances[] entry). |
The top-level fields (available/pending/total/currency) are the primary currency — kept for backward compatibility. For multi-currency accounts, iterate over balances[].
How the numbers are computed (they are not interchangeable):
balances[]andprimarycount only activeOPERATIONALwallets, summed per currency:available = Σ(balance − heldBalance),held = Σ heldBalance,pending = Σ pendingBalance,total = available + held + pending.- This form never returns
404: with no wallet at all you get zeros andbalances: []. - A single payout draws from one wallet, and the flat
?currency=shape below reads the default wallet only — so for a currency with several wallets,/balance,?currency=and the payout check can show three differentavailablenumbers.
#Single currency (legacy shape)
GET /v1/wallets/balance?currency=BRL
Pass currency to get the flat single-currency shape:
curl "https://api.moonip.io/v1/wallets/balance?currency=BRL" \
-H "Authorization: Bearer $MOONIP_API_KEY"{
"available": 880,
"held": 0,
"pending": 0,
"currency": "BRL",
"next_release_at": null,
"next_release_amount": null
}currency is case-insensitive. This shape reads the account's default wallet for that currency only (not a sum), and an unknown currency returns zeros instead of an error. There is no total/retained here.
Auth & errors. Always send the API key. A missing or invalid key →
401(never404). Withoutcurrencythis route never returns404; with?currency=, a404not_foundmeans the account has no wallet at all (not a routing problem). A wrong path (/v1/wallets/balance, plural, with the/v1prefix) or an intermediary that doesn't forward/v1/wallets/*never reaches Moonip and surfaces as its own error.
#List wallets
GET /v1/wallets
Every wallet on the account — any walletType, active and inactive, several per currency possible — same auth. A bare JSON array, no pagination.
curl https://api.moonip.io/v1/wallets \
-H "Authorization: Bearer $MOONIP_API_KEY"Response 200 OK
[
{
"id": "a1b2c3d4-e5f6-4789-9abc-def012345678",
"currency": "BRL",
"label": "Operating wallet",
"walletType": "OPERATIONAL",
"isDefault": true,
"isActive": true,
"balance": 880,
"pendingBalance": 0,
"createdAt": "2026-04-25T15:42:11.000Z"
}
]balance/pendingBalance are in the smallest currency unit. They are not the same amounts GET /v1/wallets/balance surfaces as available/pending: balance already includes the held amount (available = balance − heldBalance on an active OPERATIONAL wallet), pendingBalance matches pending only in the single-wallet case, and this list carries no heldBalance — so available is not computable from it.
#Get wallet
GET /v1/wallets/{id}
One wallet by id (UUID). Any type, active or not, scoped to the caller's account.
curl https://api.moonip.io/v1/wallets/a1b2c3d4-e5f6-4789-9abc-def012345678 \
-H "Authorization: Bearer $MOONIP_API_KEY"Response 200 OK
{
"id": "a1b2c3d4-e5f6-4789-9abc-def012345678",
"currency": "BRL",
"walletType": "OPERATIONAL",
"label": "Operating wallet",
"isDefault": true,
"balance": 880,
"pendingBalance": 0,
"heldBalance": 0,
"isActive": true,
"createdAt": "2026-04-25T15:42:11.000Z",
"updatedAt": "2026-07-23T14:31:00.000Z"
}An unknown id, or one from another account, answers 404 not_found.
#Payment operations
/v1/payments. These routes operate on the same charge you created via /v1/charges, addressed by its raw id (the ch_ prefix stripped). They cover operations the /v1/charges surface doesn't expose yet — cancellation, stats, receipts, reconciliation — plus create/read/list in the transaction wire format. To create a charge, prefer Charges.
Refunds are not part of the merchant API: POST /payments/{id}/refund answers 403 to every API-key request (error.code "forbidden", message prefixed with the REFUND_ADMIN_ONLY marker). Refunds are issued from the Moonip admin panel — see Refunds below.
#Create payment
POST /payments
Headers
| Header | Required | Notes |
|---|---|---|
Authorization | yes | Bearer + your API key. |
Idempotency-Key | yes | Preferred channel. The body field idempotencyKey is accepted as an alias — if both are sent they must match, and the header value is what counts. |
Body
| Field | Type | Required | Description |
|---|---|---|---|
amount | integer | yes | Smallest currency unit, > 0. |
currency | string (3–8) | no | Defaults to "BRL". Examples: BRL, EUR, USD, USDT. |
paymentMethods | string[] | unless checkout | One or more of the 11 methods: PIX, CRYPTO, CREDIT_CARD, BOLETO, MBWAY, MULTIBANCO, IDEAL, BANCONTACT, SEPA, BIZUM, SWAP_NOW. With checkout it is optional and narrows the template's methods. |
checkout | string | no | Hosted-checkout template slug (1–80 chars). An unknown slug → 404. |
customerId | string | no | Your internal user id. Forwarded with the request only — not persisted, not echoed in responses or merchant webhooks. |
customerName | string | no | |
customerDocument | string | no | CPF, CNPJ, NIF, or other tax id. |
customerEmail | string | no | Must be a valid email address. |
customerPhone | string | no | |
customer | object | no | Nested { name?, document?, email?, phone? }. Flattened into the customer* fields; the flat fields win on conflict. |
expectedPayerDocument | string | no | Digits-only payer CPF/CNPJ lock. |
items | object[] | no | { name, quantity, unitPrice, totalPrice } — all integers > 0 except name. |
metadata | object | no | Free-form. returnUrl/cancelUrl keys inside it must be http(s) URLs, but the first-class returnUrl/cancelUrl fields below are the return URLs. |
webhookUrl | string | no | Per-transaction webhook URL override. |
country | string | no | 2–3 chars. |
riskTier | enum | no | LOW, MEDIUM or HIGH. |
environment | enum | no | SANDBOX, STAGING or PRODUCTION (default PRODUCTION). The key alone decides where the charge actually goes. |
returnUrl | string | no | Where the payer returns after paying: http(s) URL without fragment. |
cancelUrl | string | no | Where the payer goes on cancel: same URL rule. Requires returnUrl. |
expiresIn | integer | no | Pix validity in seconds, 60–604800. |
idempotencyKey | string | yes | Body alias for the Idempotency-Key header. Unique per intended request; replays return the original. |
Response 201 Created
{
"id": "a1b2c3d4-e5f6-4789-9abc-def012345678",
"status": "WAITING_PAYMENT",
"amount": 24900,
"currency": "BRL",
"paymentMethods": ["PIX"],
"customerName": "Maria Silva",
"customerDocument": "12345678909",
"customerEmail": "maria@example.com",
"expectedPayerDocument": "12345678909",
"actualPayerDocument": null,
"payerValidationStatus": "PENDING",
"metadata": { "orderId": "ORD-7821" },
"checkoutUrl": "https://checkout.moonip.io/c/a1b2c3d4-e5f6-4789-9abc-def012345678",
"createdAt": "2026-04-25T15:42:11.000Z"
}Method-specific fields (pixQrCode, mbEntity, boletoLine, cardRedirectUrl, …) are already in the creation response when the method produces them synchronously — Pix BR Code/QR, Multibanco entity/reference, boleto and card redirects all come back on 201. Only artefacts that genuinely arrive later need a follow-up GET /payments/{id}.
checkoutUrl is present on create and get, and absent on list rows and on the cancel response. The payer-validation group is expectedPayerDocument (what you locked), actualPayerDocument (what the provider reports) and payerValidationStatus (NOT_REQUIRED, PENDING, MATCHED, MISMATCH, UNVERIFIED).
#Get payment
GET /payments/{id}
Response 200 OK
{
"id": "a1b2c3d4-e5f6-4789-9abc-def012345678",
"status": "PAID",
"amount": 24900,
"currency": "BRL",
"paymentMethods": ["PIX"],
"paidWith": "PIX",
"pixQrCode": "data:image/png;base64,iVBOR...",
"pixCopyPaste": "00020126580014br.gov.bcb.pix...",
"pixExpiresAt": "2026-04-25T16:12:11.000Z",
"cardRedirectUrl": null,
"mbEntity": null,
"mbReference": null,
"mbExpiresAt": null,
"boletoBarcode": null,
"boletoLine": null,
"boletoPdfUrl": null,
"boletoExpiresAt": null,
"cryptoAddress": null,
"cryptoAmount": null,
"cryptoNetwork": null,
"cryptoCurrency": null,
"platformFee": 200,
"expectedPayerDocument": "12345678909",
"actualPayerDocument": "12345678909",
"payerValidationStatus": "MATCHED",
"metadata": { "orderId": "ORD-7821" },
"checkoutUrl": "https://checkout.moonip.io/c/a1b2c3d4-e5f6-4789-9abc-def012345678",
"createdAt": "2026-04-25T15:42:11.000Z",
"paidAt": "2026-04-25T15:43:08.000Z"
}Fields are null when not applicable to the chosen paymentMethods.
No /v1/payments response (create, get, list, cancel) says which acquirer served the charge: provider, providerAccountId, providerTransactionId, providerFeeSource, cardClientSecret and tenantId are not returned (removed on 2026-10-12), and neither is the acquirer's cost (providerFee, netAmount). A card or bank redirect always comes as the checkout's /c/{id} link.
#List payments
GET /payments
Query parameters
| Param | Type | Default | Description |
|---|---|---|---|
page | int | 1 | |
limit | int | 20 | Max 100. |
status | enum | — | See Status enum below. |
currency | string | — | Filter by currency. |
startDate | ISO 8601 | — | Inclusive lower bound on createdAt. |
endDate | ISO 8601 | — | Inclusive upper bound on createdAt. |
source | enum | — | API, PANEL, MANUAL, or INDEFINIDO (rows with no source). |
sortBy | string | createdAt | A transaction column name (createdAt, amount, status, paidAt, updatedAt, …). An unknown name is a 500, not a 400 — stick to real columns. |
sortOrder | enum | desc | asc or desc. |
Response 200 OK
{
"data": [ { "id": "a1b2c3d4-e5f6-4789-9abc-def012345678", "...": "..." } ],
"total": 142,
"page": 1,
"limit": 20
}List rows do not carry checkoutUrl. This is a paged envelope (total/page/limit) — cursor pagination lives on GET /v1/charges.
#Stats
GET /payments/stats?days=7
| Param | Type | Default | Description |
|---|---|---|---|
days | int | 7 | Business days in the timeZone, today included. |
from/to | ISO 8601 | — | Explicit window; overrides days when present. |
timeZone | string | America/Sao_Paulo | IANA time-zone name. An invalid name → 400. |
days=7 = today plus the six previous calendar days. Totals (totalTransactions, paidTransactions, successRate, volumeByCurrency, methodBreakdown) cover the whole window, whatever its length. dailyVolume has one point per calendar day and at most 366 points: for a longer window it holds the 366 most recent days. hourlyVolume is present only when the window is a single day — one point per business hour, keyed like dailyVolume but with the hour appended ("YYYY-MM-DDTHH"). methodBreakdownByCurrency has one flat row per (method, currency) pair. Sandbox/test charges are excluded.
Response 200 OK
{
"totalTransactions": 142,
"paidTransactions": 119,
"todayTransactions": 8,
"successRate": 84,
"volumeByCurrency": [
{ "currency": "BRL", "volume": 1245000, "count": 95 },
{ "currency": "EUR", "volume": 89400, "count": 24 }
],
"todayVolumeByCurrency": { "BRL": 24900, "EUR": 8990 },
"dailyVolume": [
{ "date": "2026-04-19", "currencies": { "BRL": 89000 } },
{ "date": "2026-04-20", "currencies": { "BRL": 124500, "EUR": 4500 } }
],
"hourlyVolume": [
{ "date": "2026-04-20T09", "currencies": { "BRL": 24900 } }
],
"methodBreakdown": [
{ "method": "PIX", "volume": 980000, "count": 78 },
{ "method": "CREDIT_CARD", "volume": 265000, "count": 34 },
{ "method": "MULTIBANCO", "volume": 89400, "count": 7 }
],
"methodBreakdownByCurrency": [
{ "method": "PIX", "currency": "BRL", "volume": 980000, "count": 78 },
{ "method": "CREDIT_CARD", "currency": "BRL", "volume": 265000, "count": 34 },
{ "method": "MULTIBANCO", "currency": "EUR", "volume": 89400, "count": 7 }
],
"statusCounts": { "PAID": 119, "REFUSED": 10 },
"avgPaySeconds": 42
}#Metrics
GET /payments/metrics?days=7¤cy=BRL
Conversion-funnel and volume metrics for your merchant account. days defaults to 7 (same window rule as Stats, no 366-point cap); currency is optional (filters to a single currency); timeZone works like in Stats. Scoped to your API key.
Response 200 OK
{
"days": 7,
"currency": "BRL",
"overall": { "created": 142, "paid": 119, "conversionRate": 83.8 },
"byMethod": [
{ "method": "PIX", "created": 90, "paid": 78, "conversionRate": 86.7 }
],
"refunds": { "count": 2, "volumeByCurrency": { "BRL": 4500 } },
"chargebacks": { "count": 0 }
}#Net volume
GET /payments/net-volume
Net settled volume (amount − platformFee, PAID only) for a window.
| Param | Type | Default | Description |
|---|---|---|---|
from/to | ISO 8601 | — | Window bounds. |
timeZone | string | America/Sao_Paulo | IANA time-zone name. |
Response 200 OK
{
"from": "2026-04-19T00:00:00.000Z",
"to": "2026-04-25T23:59:59.999Z",
"byCurrency": {
"BRL": { "netVolume": 1220500, "txCount": 95, "grossVolume": 1245000, "feeTotal": 24500 }
}
}#Conversion
GET /payments/conversion
Rows of { currency, status, count, volume } for the funnel by status.
| Param | Type | Default | Description |
|---|---|---|---|
from | ISO 8601 | — | Inclusive lower bound. An invalid value → 400. |
Response 200 OK
[
{ "currency": "BRL", "status": "PAID", "count": 95, "volume": 1245000 },
{ "currency": "BRL", "status": "REFUSED", "count": 4, "volume": 12000 }
]#Refunds (admin-panel-only)
POST /payments/{id}/refund
Not callable with an API key. Every merchant request is refused with 403, whatever the body is: error.code is "forbidden" and the message starts with the REFUND_ADMIN_ONLY marker (a message prefix, not a code — see Errors). Refunds (full or partial, PIX or card) are requested from the Moonip admin panel, which places the wallet hold first and then runs the refund. The status of a refund can be followed by the merchant on GET /payments/{id}/refunds and on the charge.refunded webhook.
The body documented for the internal caller — idempotencyKey, amount, reason, strategy (devolution/cashout), passFeeToTenant, destinationKey, refundId — is not a merchant contract; do not build against it.
#List refunds
GET /payments/{id}/refunds
Returns every refund issued against a transaction (newest first). The acquirer's refund id is not returned.
[
{
"id": "b2c3d4e5-f6a7-4890-9abc-def012345678",
"transactionId": "a1b2c3d4-e5f6-4789-9abc-def012345678",
"amount": 24900,
"currency": "BRL",
"reason": null,
"status": "REFUNDED",
"origin": "MERCHANT",
"idempotencyKey": "order-7821",
"refundedAt": "2026-04-25T15:50:00.000Z",
"createdAt": "2026-04-25T15:50:00.000Z",
"updatedAt": "2026-04-25T16:12:00.000Z"
}
]status is one of PENDING, IN_PROGRESS, PENDING_RECONCILIATION, REFUNDED, FAILED; origin is MERCHANT, AUTOMATIC_PAYER_RESTRICTION or REVIEW_REJECTION. reason is only filled for merchant-originated refunds.
#Cancel payment
POST /payments/{id}/cancel
Cancels an in-flight charge — valid only while WAITING_PAYMENT or PENDING. PROCESSING is refused with 409 payment_not_cancellable, and any other status with 400. Settled (PAID/APPROVED) charges must be refunded, not cancelled. Returns the updated transaction (no checkoutUrl) and publishes a status-changed event whose public name is charge.failed (the canonical twin is charge.failed), with status: "CANCELLED" and previousStatus.
#Resend webhook
POST /payments/{id}/resend-webhook
Re-emits the transaction's current status as a fresh webhook delivery — useful when your endpoint was down.
{ "resent": true, "status": "PAID" }#Reconcile
POST /payments/{id}/reconcile
Asks the platform to re-check the transaction with the provider and settle any disagreement.
Response 200 OK
{ "reconciled": true, "status": "PAID" }An already-terminal transaction answers { "reconciled": false, "status": "…" }. Internal reasons and provider statuses are not part of the merchant response.
#Confirm sandbox payment
POST /payments/{id}/confirm-sandbox
The merchant's self-test lever for webhook end-to-end: on a transaction created with a test key, this turns the simulated charge PAID and fires the normal webhooks. See Sandbox.
Response 200 OK
{ "confirmed": true, "status": "PAID" }An already-terminal charge answers { "confirmed": false, "status": "…", "reason": "ALREADY_TERMINAL" }. A charge that is not a sandbox/test transaction is refused with 400.
#Receipt
GET /payments/{id}/receipt
Moonip's own receipt (PDF) for a transaction with a final outcome — never the acquirer's document. In-flight transactions answer 400.
{ "contentType": "application/pdf", "base64": "JVBERi0xLjcK..." }#Webhooks
#Register endpoint
POST /webhooks/endpoints
{
"url": "https://merchant.example.com/hooks/moonip",
"events": ["charge.paid", "charge.failed", "charge.expired"]
}Event names (charge-related): charge.created, charge.paid, charge.failed, charge.expired, charge.refunded, payout.created, payout.approved, payout.paid, payout.failed, plus med_case.created and med_case.updated. These are delivered in the versioned evt_ envelope and signed X-Moonip-Signature: t=<unix>,v1=<hex> — see Webhooks. Subscribe to these. (med.resolved belongs to the same family but travels in the older { event, data } envelope with the sha256= signature.)
Only canonical charge and payout event names are accepted. Retired names return HTTP 400 LEGACY_WEBHOOK_EVENT with the canonical replacement. Existing subscriptions are migrated and deduplicated.
Response 201 Created
{
"id": "e5f6a7b8-c9d0-4123-9ef0-123456789012",
"url": "https://merchant.example.com/hooks/moonip",
"events": ["charge.paid", "charge.failed", "charge.expired"],
"secret": "b8f3a9c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1",
"status": "ACTIVE"
}secret is shown once — store it immediately. Outside sandbox/staging the URL must be https:// and may not point at a Moonip domain; a duplicate URL answers 409, and the account has a limited number of active endpoints.
#List endpoints
GET /webhooks/endpoints
{
"data": [
{
"id": "a1b2c3d4-e5f6-4789-9abc-def012345678",
"url": "https://...",
"events": ["payment.status_changed"],
"status": "ACTIVE",
"createdAt": "...",
"updatedAt": "..."
}
],
"access": { "canManage": true },
"httpsOnly": true
}Ids are UUIDs. access.canManage tells whether the caller may write endpoints; httpsOnly is whether the platform currently requires https:// URLs.
secret is never returned by this endpoint.
#Test endpoint
POST /webhooks/endpoints/{id}/test
Sends one synchronous canonical charge.paid sample with the versioned envelope and timestamped signature. The hourly test limit returns 429 WEBHOOK_TEST_RATE_LIMIT when exceeded.
{
"ok": true,
"statusCode": 200,
"durationMs": 143,
"errorKind": null,
"error": null,
"responseSnippet": "{\"received\":true}",
"eventType": "charge.paid",
"url": "https://merchant.example.com/hooks/moonip",
"sentHeaders": { "X-Moonip-Event-Type": "charge.paid" },
"sentPayload": { "id": "evt_...", "object": "event", "...": "..." }
}errorKind is timeout, connection or http when ok is false.
#Rotate secret
POST /webhooks/endpoints/{id}/rotate-secret
{ "id": "e5f6a7b8-c9d0-4123-9ef0-123456789012" }Response 200 OK
{ "id": "e5f6a7b8-c9d0-4123-9ef0-123456789012", "secret": "<64-char hex>" }The new secret is shown once. Rotation is an instantaneous server-side swap — there is no overlap window. Every webhook signed after this call uses the new secret, so make your verifier accept both the old and new secret across your deploy, then drop the old one (see webhooks.md).
#Update endpoint
PATCH /webhooks/endpoints/{id}
Update URL, subscribed events, or status (ACTIVE/INACTIVE). Send only the fields to change.
{ "url": "https://merchant.example.com/hooks/v2", "status": "ACTIVE" }Response 200 OK — { id, url, events, status }. Secret never returned.
#Delete endpoint
DELETE /webhooks/endpoints/{id}
Permanently removes the endpoint. Pending deliveries to it are retargeted to the account's other active endpoints; only when none remain are they cancelled. Delivery history is kept.
Response 200 OK — { "deleted": true }.
#Event catalog
GET /webhooks/event-catalog
Returns the full, current list of subscribable public event names with descriptions. This route is behind the same API-key auth as the rest of /v1/webhooks. Charge/payout-relevant excerpt:
[
{ "event": "charge.created", "description": "Cobrança criada (Pix/cartão gerado, aguardando pagamento)." },
{ "event": "charge.paid", "description": "Cobrança paga e confirmada (Pix/cartão liquidado)." },
{ "event": "charge.failed", "description": "Cobrança falhou ou foi cancelada." },
{ "event": "charge.expired", "description": "Cobrança expirou sem pagamento." },
{ "event": "charge.refunded","description": "Cobrança reembolsada." },
{ "event": "payout.created", "description": "Saque solicitado." },
{ "event": "payout.approved", "description": "Payout approved." },
{ "event": "payout.paid", "description": "Saque liquidado com sucesso." },
{ "event": "payout.failed", "description": "Saque falhou ou foi rejeitado." }
]#List deliveries
GET /webhooks/deliveries
| Param | Description |
|---|---|
status | PENDING, PROCESSING, DELIVERED, FAILED, CANCELLED |
eventType | Filter by event name (charge.paid, charge.refunded, charge.paid, …). |
endpointId | Filter by registered endpoint. |
limit | Default 25, max 100. |
offset | Default 0. |
{
"data": [
{
"id": "b2c3d4e5-f6a7-4890-9abc-def012345678",
"endpointId": "a1b2c3d4-e5f6-4789-9abc-def012345678",
"eventType": "charge.paid",
"status": "DELIVERED",
"attempts": 1,
"maxAttempts": 15,
"lastStatusCode": 200,
"lastError": null,
"nextRetryAt": null,
"deliveredAt": "2026-04-25T15:43:09.000Z",
"createdAt": "2026-04-25T15:43:08.000Z",
"updatedAt": "2026-04-25T15:43:09.000Z",
"payload": { "type": "charge.paid", "object": "event", "data": { "object": { "id": "ch_example", "object": "charge", "status": "paid" } } }
}
],
"total": 142,
"limit": 25,
"offset": 0
}Rows carry payload and lastError, not the response body — that lives on the delivery detail below.
#Get delivery
GET /webhooks/deliveries/{id}
The full delivery, including the response body and the attempt log.
{
"id": "b2c3d4e5-f6a7-4890-9abc-def012345678",
"endpointId": "a1b2c3d4-e5f6-4789-9abc-def012345678",
"eventType": "charge.paid",
"status": "FAILED",
"attempts": 3,
"maxAttempts": 15,
"lastStatusCode": 502,
"lastError": "HTTP 502",
"lastResponseBody": "Bad Gateway",
"logs": [
{
"id": "c3d4e5f6-a7b8-4901-9abc-def012345678",
"deliveryId": "b2c3d4e5-f6a7-4890-9abc-def012345678",
"attempt": 1,
"statusCode": 502,
"error": "HTTP 502",
"responseBody": "Bad Gateway",
"duration": 312,
"url": "https://merchant.example.com/hooks/moonip",
"createdAt": "2026-04-25T15:43:09.000Z"
}
],
"endpoint": { "url": "https://merchant.example.com/hooks/moonip", "status": "ACTIVE" }
}#Replay delivery
POST /webhooks/deliveries/{id}/replay
Queues one delivery again (resets attempts and schedule). A DELIVERED delivery is refused with 400, and one PROCESSING with 400 — wait and retry.
{ "id": "b2c3d4e5-f6a7-4890-9abc-def012345678", "status": "PENDING", "attempts": 0, "nextRetryAt": "2026-04-25T16:08:00.000Z" }#Replay deliveries in bulk
POST /webhooks/deliveries/replay-bulk
{ "status": "FAILED", "endpointId": "a1b2c3d4-e5f6-4789-9abc-def012345678", "limit": 50 }| Param | Description |
|---|---|
status | Target status. Default FAILED + CANCELLED. Unknown value → 400. |
endpointId | Restrict to one endpoint. |
limit | Default 50, max 500. |
Response 200 OK — { "requeued": 12 }.
#Pull events
GET /webhooks/events
Reconciliation view of the events, keyset-paginated: { data, nextCursor } (note the camelCase nextCursor). Rows are { eventType, payload, status, lastStatusCode, deliveredAt, createdAt } — no per-row id.
| Param | Description |
|---|---|
since/until | ISO 8601 bounds on createdAt. |
status | Delivery status filter. |
eventType | Event name filter. |
limit | Default 50, max 100. |
cursor | Opaque cursor from nextCursor. |
#Delivery stats
GET /webhooks/stats
{
"total": 1402,
"PENDING": 3,
"PROCESSING": 1,
"DELIVERED": 1380,
"FAILED": 12,
"CANCELLED": 6
}#Status enum
The table below is the detailed status enum returned by the /v1/payments transaction routes (and eventType/payload.data.status on their webhook deliveries). POST/GET /v1/charges never emit these values; they emit the smaller public vocabulary (pending, processing, paid, failed, expired, cancelled, refunded, disputed — see Getting Started §5), which the enum below maps onto.
| Transaction status | Description | Public charge.status |
|---|---|---|
PENDING | Internal — being created. Rarely surfaced. | pending |
WAITING_PAYMENT | Awaiting customer action. | pending |
PROCESSING | Card / 3DS in flight. | processing |
PAID | Settled — non-card methods. | paid |
APPROVED | Settled — card methods. | paid |
REFUSED | Acquirer or issuer declined. | failed |
CANCELLED | Cancelled before completion. | cancelled |
EXPIRED | Time window elapsed. | expired |
REFUNDED | Fully refunded. | refunded |
CHARGEBACK | Issuer raised a chargeback (cards). | disputed |
DISPUTE | Cardholder opened a dispute (cards). | disputed |
charge.paid represents a paid charge. charge.failed and charge.expired distinguish failure and expiry.
#Webhook payload schema
#Canonical envelope (charge.* / payout.*)
{
"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": {}
}
}
}| Field | Always present | Description |
|---|---|---|
id | yes | evt_… — stable per business event, identical across retries. Dedup on this. |
object | yes | Always "event". |
api_version | yes | Contract version, e.g. 2026-07-23. |
type | yes | charge.created | charge.paid | charge.failed | charge.expired | charge.refunded | payout.created | payout.paid | payout.failed | med_case.created | med_case.updated. |
payout.approved | Payout approved, awaiting settlement (processing). | |
data.object | yes | The public object for the event — same serializer and field names as the REST surface for charge/payout, with the event-specific reductions described in Webhooks. |
#Webhook headers
| Header | Description |
|---|---|
X-Moonip-Signature | charge.*/payout.* events: t=<unix>,v1=<hex> HMAC of "<t>.<rawBody>". KYC, account lifecycle and med.resolved events: sha256=<hex> HMAC of the raw body. |
X-Moonip-Delivery-Id | Unique per delivery — identical across every retry of that delivery; use for transport-level dedup. |
X-Moonip-Event-Type | Mirrors type / event. |
See webhooks.md for verification samples.
#HTTP status codes
| Code | Used for |
|---|---|
200 | Successful read |
201 | Resource created |
204 | No content (e.g. logout) |
400 | Validation error, or a financial write missing Idempotency-Key |
401 | Missing / invalid API key |
403 | Blocked by the fraud / velocity guard (fraud.*), an API-key refund attempt (message marker REFUND_ADMIN_ONLY, not a code), account_suspended, or the IP allowlist (account routes only — wallets, withdrawals, transactions, MED, api-keys; not payments/charges/webhooks) |
404 | Resource not found |
409 | Conflict — idempotency_key_reused, idempotency_key_in_flight, payment_not_cancellable |
422 | Business-rule rejection passed through from provider configuration (e.g. no PSP configured) |
429 | Rate limited |
500 | Server error — safe to retry (idempotency protects) |
503 | Upstream acquirer unavailable — retry with backoff |
See Errors for the full canonical error envelope (error.type/error.code/error.message, request_id, X-Request-Id).