Skip to content
MOONIPdocs
IntegrationManual
PTEN
Go to dashboard

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, header Idempotency-Key, pix block. 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 (the ch_ 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

HeaderRequiredNotes
AuthorizationyesBearer + your API key (ak_test_* / ak_live_*). The legacy apikey header is still accepted.
Idempotency-KeyyesAny non-blank string, scoped by (merchant, key). A financial write — missing it is a 400.

Body

FieldTypeRequiredDescription
amountintegeryesSmallest currency unit, > 0.
currencystringnoDefaults to "BRL". Pix charges must use BRL.
payment_methodstringyes"pix" — the only value accepted today.
descriptionstringnoUp to 500 chars; folded into metadata.description on read.
expires_inintegernoPix validity in seconds, 60–604800 (7 days). Outside that range the API answers 400 — the value is never capped.
customer.namestringno
customer.documentstringnoCPF or CNPJ.
customer.emailstringno
customer.phonestringno
expected_payer_tax_idstringnoLocks 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.
metadataobjectnoFree-form; reserved/underscore-prefixed keys are stripped.

Example

bash
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

json
{
  "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.

http
GET /v1/charges?limit=25&starting_after=ch_01J…&status=paid
ParamTypeDefaultDescription
limitinteger251–100.
starting_afterstring—A charge id (ch_…) of the same resource — cursor, not an offset.
statusenum—Public status vocabulary (pending, paid, …).
created_afterISO 8601—Inclusive lower bound on created_at.
created_beforeISO 8601—Inclusive upper bound on created_at.
customer_idstring—Filters by payer document.
json
{
  "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:

bash
curl "https://api.moonip.io/v1/wallets/balance" \
  -H "Authorization: Bearer $MOONIP_API_KEY"

Response 200 OK

json
{
  "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 }
}
FieldDescription
availableSpendable/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.
heldAmount held (retained mid-flight) in balances[]/primary entries. Not present at the top level.
pendingSettled but still held — not spendable yet (primary currency). Includes in-flight refund holds, so it can be greater than retained.
totalavailable + held + pending of the primary currency.
retainedTotal under retention (PENDING holds) of the primary currency. Does not include refund holds.
currencyPrimary currency (ISO 4217). Defaults to BRL.
next_release_atISO 8601 string of when the next pending tranche releases, or null when nothing is scheduled.
next_release_amountAmount of that next release (smallest currency unit), or null.
balances[]One entry per currency: { currency, available, held, pending, total }.
primaryThe 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[] and primary count only active OPERATIONAL wallets, 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 and balances: [].
  • 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 different available numbers.

#Single currency (legacy shape)

GET /v1/wallets/balance?currency=BRL

Pass currency to get the flat single-currency shape:

bash
curl "https://api.moonip.io/v1/wallets/balance?currency=BRL" \
  -H "Authorization: Bearer $MOONIP_API_KEY"
json
{
  "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 (never 404). Without currency this route never returns 404; with ?currency=, a 404 not_found means the account has no wallet at all (not a routing problem). A wrong path (/v1/wallets/balance, plural, with the /v1 prefix) 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.

bash
curl https://api.moonip.io/v1/wallets \
  -H "Authorization: Bearer $MOONIP_API_KEY"

Response 200 OK

json
[
  {
    "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.

bash
curl https://api.moonip.io/v1/wallets/a1b2c3d4-e5f6-4789-9abc-def012345678 \
  -H "Authorization: Bearer $MOONIP_API_KEY"

Response 200 OK

json
{
  "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

HeaderRequiredNotes
AuthorizationyesBearer + your API key.
Idempotency-KeyyesPreferred 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

FieldTypeRequiredDescription
amountintegeryesSmallest currency unit, > 0.
currencystring (3–8)noDefaults to "BRL". Examples: BRL, EUR, USD, USDT.
paymentMethodsstring[]unless checkoutOne 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.
checkoutstringnoHosted-checkout template slug (1–80 chars). An unknown slug → 404.
customerIdstringnoYour internal user id. Forwarded with the request only — not persisted, not echoed in responses or merchant webhooks.
customerNamestringno
customerDocumentstringnoCPF, CNPJ, NIF, or other tax id.
customerEmailstringnoMust be a valid email address.
customerPhonestringno
customerobjectnoNested { name?, document?, email?, phone? }. Flattened into the customer* fields; the flat fields win on conflict.
expectedPayerDocumentstringnoDigits-only payer CPF/CNPJ lock.
itemsobject[]no{ name, quantity, unitPrice, totalPrice } — all integers > 0 except name.
metadataobjectnoFree-form. returnUrl/cancelUrl keys inside it must be http(s) URLs, but the first-class returnUrl/cancelUrl fields below are the return URLs.
webhookUrlstringnoPer-transaction webhook URL override.
countrystringno2–3 chars.
riskTierenumnoLOW, MEDIUM or HIGH.
environmentenumnoSANDBOX, STAGING or PRODUCTION (default PRODUCTION). The key alone decides where the charge actually goes.
returnUrlstringnoWhere the payer returns after paying: http(s) URL without fragment.
cancelUrlstringnoWhere the payer goes on cancel: same URL rule. Requires returnUrl.
expiresInintegernoPix validity in seconds, 60–604800.
idempotencyKeystringyesBody alias for the Idempotency-Key header. Unique per intended request; replays return the original.

Response 201 Created

json
{
  "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

json
{
  "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

ParamTypeDefaultDescription
pageint1
limitint20Max 100.
statusenum—See Status enum below.
currencystring—Filter by currency.
startDateISO 8601—Inclusive lower bound on createdAt.
endDateISO 8601—Inclusive upper bound on createdAt.
sourceenum—API, PANEL, MANUAL, or INDEFINIDO (rows with no source).
sortBystringcreatedAtA transaction column name (createdAt, amount, status, paidAt, updatedAt, …). An unknown name is a 500, not a 400 — stick to real columns.
sortOrderenumdescasc or desc.

Response 200 OK

json
{
  "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

ParamTypeDefaultDescription
daysint7Business days in the timeZone, today included.
from/toISO 8601—Explicit window; overrides days when present.
timeZonestringAmerica/Sao_PauloIANA 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

json
{
  "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&currency=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

json
{
  "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.

ParamTypeDefaultDescription
from/toISO 8601—Window bounds.
timeZonestringAmerica/Sao_PauloIANA time-zone name.

Response 200 OK

json
{
  "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.

ParamTypeDefaultDescription
fromISO 8601—Inclusive lower bound. An invalid value → 400.

Response 200 OK

json
[
  { "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.

json
[
  {
    "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.

json
{ "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

json
{ "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

json
{ "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.

json
{ "contentType": "application/pdf", "base64": "JVBERi0xLjcK..." }

#Webhooks

#Register endpoint

POST /webhooks/endpoints

json
{
  "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

json
{
  "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

json
{
  "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.

json
{
  "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

json
{ "id": "e5f6a7b8-c9d0-4123-9ef0-123456789012" }

Response 200 OK

json
{ "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.

json
{ "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:

json
[
  { "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

ParamDescription
statusPENDING, PROCESSING, DELIVERED, FAILED, CANCELLED
eventTypeFilter by event name (charge.paid, charge.refunded, charge.paid, …).
endpointIdFilter by registered endpoint.
limitDefault 25, max 100.
offsetDefault 0.
json
{
  "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.

json
{
  "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.

json
{ "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

json
{ "status": "FAILED", "endpointId": "a1b2c3d4-e5f6-4789-9abc-def012345678", "limit": 50 }
ParamDescription
statusTarget status. Default FAILED + CANCELLED. Unknown value → 400.
endpointIdRestrict to one endpoint.
limitDefault 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.

ParamDescription
since/untilISO 8601 bounds on createdAt.
statusDelivery status filter.
eventTypeEvent name filter.
limitDefault 50, max 100.
cursorOpaque cursor from nextCursor.

#Delivery stats

GET /webhooks/stats

json
{
  "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 statusDescriptionPublic charge.status
PENDINGInternal — being created. Rarely surfaced.pending
WAITING_PAYMENTAwaiting customer action.pending
PROCESSINGCard / 3DS in flight.processing
PAIDSettled — non-card methods.paid
APPROVEDSettled — card methods.paid
REFUSEDAcquirer or issuer declined.failed
CANCELLEDCancelled before completion.cancelled
EXPIREDTime window elapsed.expired
REFUNDEDFully refunded.refunded
CHARGEBACKIssuer raised a chargeback (cards).disputed
DISPUTECardholder 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.*)

json
{
  "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": {}
    }
  }
}
FieldAlways presentDescription
idyesevt_… — stable per business event, identical across retries. Dedup on this.
objectyesAlways "event".
api_versionyesContract version, e.g. 2026-07-23.
typeyescharge.created | charge.paid | charge.failed | charge.expired | charge.refunded | payout.created | payout.paid | payout.failed | med_case.created | med_case.updated.
payout.approvedPayout approved, awaiting settlement (processing).
data.objectyesThe 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

HeaderDescription
X-Moonip-Signaturecharge.*/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-IdUnique per delivery — identical across every retry of that delivery; use for transport-level dedup.
X-Moonip-Event-TypeMirrors type / event.

See webhooks.md for verification samples.

#HTTP status codes

CodeUsed for
200Successful read
201Resource created
204No content (e.g. logout)
400Validation error, or a financial write missing Idempotency-Key
401Missing / invalid API key
403Blocked 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)
404Resource not found
409Conflict — idempotency_key_reused, idempotency_key_in_flight, payment_not_cancellable
422Business-rule rejection passed through from provider configuration (e.g. no PSP configured)
429Rate limited
500Server error — safe to retry (idempotency protects)
503Upstream acquirer unavailable — retry with backoff

See Errors for the full canonical error envelope (error.type/error.code/error.message, request_id, X-Request-Id).