Esta página é um instantâneo do guia mantido junto ao código. A referência completa dos endpoints fica em Referência da API.
Guia de integração — Mahiru API v1
Fonte da verdade executável: GET /openapi.json (Swagger UI em /docs). Este guia resume o contrato e o fluxo.
Autenticação e cabeçalhos
| Header | Uso |
|---|---|
Authorization: Bearer sk_test_… / sk_live_… |
obrigatório em /v1/**. A chave determina o merchant e o modo (test/live). |
Idempotency-Key: <string ≤255> |
obrigatório em todo POST /v1/**. Mesma chave + mesmo body ⇒ mesma resposta (header Idempotent-Replayed: true); mesma chave + body diferente ⇒ 409 idempotency_key_reused. Validade 24 h. Sugestão: order:<id>:attempt:<n>. |
Request-Id |
opcional na requisição; sempre devolvido. Cite-o em suporte. |
Dinheiro sempre em unidades mínimas (19990 = R$ 199,90) + currency ISO 4217 ("BRL"; maiúsculas/minúsculas aceitas).
Recursos
Payment Intent
POST /v1/payment-intents
{ "amount": 19990, "currency": "BRL",
"payment_method": "card|credit_card|debit_card|pix|boleto", // "card" ≡ "credit_card"
"capture_method": "automatic|manual", // opcional, default automatic
"customer": { "id": "…", "email": "…", "name": "…", "document": "12345678909", // document = CPF/CNPJ
"address": { "street": "…", "number": "…", "complement": "…", "neighborhood": "…",
"city": "…", "state": "SP", "postal_code": "01001000" } }, // address obrigatório p/ boleto
"payment_token": "…", // opcional: token do SDK do provedor (fluxos server-side)
"installments": 1, // opcional (cartão, 1..24); ver nota abaixo
"metadata": { "order_id": "…", "order_number": "…" } } // ≤50 chaves string→string; ecoado em eventos
→ 201
{ "id": "pi_…", "object": "payment_intent", "status": "requires_action",
"amount": 19990, "currency": "BRL", "amount_refunded": 0,
"payment_method": "credit_card", "capture_method": "automatic", "installments": 1, "mode": "test", "provider": "stripe",
"client_secret": "…",
"next_action": { "type": "client_secret", "client_secret": "…", "publishable_key": "pk_test_…", "provider": "stripe" },
"failure_code": null, "failure_message": null,
"metadata": { "order_id": "…" }, "created_at": "…", "updated_at": "…" }
GET /v1/payment-intents/{id} · POST /v1/payment-intents/{id}/confirm {payment_token, installments?, customer?} ·
POST /v1/payment-intents/{id}/capture {amount?} · POST /v1/payment-intents/{id}/cancel (todos devolvem o payment intent).
Estados: created → requires_action → processing → authorized → paid → partially_refunded → refunded, e failed,
cancelled, disputed (contestação aberta — ver Disputes).
Um provedor pode recusar já na criação: a resposta é 201 com status: "failed" e failure_code normalizado
(card_declined, insufficient_funds, expired_card, incorrect_cvc, incorrect_number, fraud_suspected,
authentication_required, processing_error, provider_unavailable, provider_timeout, provider_unreachable
(conexão nunca estabelecida — ver fallback), rate_limited, invalid_request, expired (PIX/boleto venceu),
provider_configuration_error (credencial do provedor inválida — problema do merchant), unknown).
next_action — o que o frontend precisa fazer, independente do provedor:
type |
campos | quando |
|---|---|---|
client_secret |
client_secret, publishable_key, provider |
cartão client-side (Stripe): confirmar com o SDK do provedor (confirmPayment); resultado chega por webhook |
card_token |
public_key, provider, amount, currency |
cartão server-side (Mercado Pago): tokenizar o cartão com o SDK do provedor usando public_key e chamar POST /v1/payment-intents/{id}/confirm {payment_token, installments} — a resposta já traz paid/failed/processing |
pix_qr |
copy_paste (EMV "copia e cola"), qr_code (PNG base64), url, expires_at (RFC-3339 UTC) |
PIX (Mercado Pago) — mostrar QR; paid chega por webhook; vencido ⇒ failed/expired |
boleto |
url (PDF), barcode (44 dígitos), digitable_line (linha digitável), expires_at |
boleto (Mercado Pago) — exige customer.name, customer.document e customer.address; paid chega por webhook (compensação em 1–3 dias úteis); vencido ⇒ failed/expired |
none |
— | nada a fazer (pago, falhou, cancelado, aguardando webhook) |
Se você já tem o token ao criar (Card Payment Brick, por ex.), envie payment_token direto no POST /v1/payment-intents e pule o confirm.
Um confirm recusado deixa o intent em failed (crie um novo intent para tentar outro cartão).
Parcelamento. installments é persistido e devolvido na resposta. O roteamento só considera provedores que
cobram parcelado: se nenhum provedor habilitado para o método parcela, a criação falha com
400 parameter_invalid (param: installments) em vez de cobrar à vista — cobrar diferente do combinado com o
pagador seria pior do que recusar. Hoje o Stripe não parcela pelo Mahiru; o Mercado Pago sim.
Dados de cartão nunca passam pelo Mahiru nem pelo seu backend.
Refund
POST /v1/refunds { "payment_intent": "pi_…", "amount": 5000, "reason": "requested_by_customer" } // amount omitido = total restante
→ 201 { "id": "re_…", "object": "refund", "payment_intent": "pi_…", "status": "succeeded|pending|failed",
"amount": 5000, "currency": "BRL", "reason": "…", "failure_code": null, "created_at": "…" }
GET /v1/refunds/{id}
Equivalente: POST /v1/payment-intents/{id}/refund {amount?, reason?}.
Invoices (NF-e via provedor fiscal)
O Mahiru não calcula imposto: envie os códigos por item. Emissão é assíncrona (processing → issued|rejected por webhook/GET).
POST /v1/invoices
{ "payment_intent": "pi_…", // opcional (recomendado)
"type": "nfe", // default
"nature_of_operation": "Venda de mercadoria", // opcional (default do perfil fiscal)
"recipient": { "name", "document" (CPF/CNPJ), "email", "phone", "state_registration", "state_registration_indicator" (1|2|9),
"final_consumer": true, "address": { street, number, complement, neighborhood, city, state, postal_code } },
"items": [ { "code", "description", "ncm" (8), "cfop" (4), "cest", "unit", "quantity" ("2" ou "1.5"),
"unit_price", "total", "discount", "origin" (0-8),
"taxes": { "icms": { "cst"|"csosn", "rate" }, "pis": { "cst", "rate" }, "cofins": { "cst", "rate" }, "ipi": { "cst", "rate" },
"ibs_cbs": { "cst" (3 díg.), "c_class_trib" (6 díg.), "ibs_uf_rate", "ibs_mun_rate", "cbs_rate" },
"icms_difal": { "base", "fcp_base", "internal_rate", "interstate_rate", "fcp_rate", "share_rate" } } } ],
"freight": { "mode": 0-9, "amount" }, "discount", "other_charges",
"payments": [ { "method": "pix|credit_card|boleto|…", "amount", "installments" } ],
"additional_info": "…", "metadata": { … } }
→ 201 { "id": "inv_…", "object": "invoice", "type": "nfe", "status": "processing|issued|rejected|error|cancelled", "payment_intent": "pi_…",
"provider": "focusnfe", "access_key": "…44 dígitos…", "number", "series", "xml_url", "pdf_url",
"rejection": { "code", "message" }, "issued_at", "created_at" }
GET /v1/invoices/{id}
→ + "xml_archive": { "authorization": { "status": "pending|stored|unavailable", "url", "sha256",
"byte_size", "stored_at", "retain_until" }, "cancellation": { … } }
GET /v1/invoices/{id}/xml[?kind=authorization|cancellation] → o XML (application/xml)
POST /v1/invoices/{id}/cancel { "reason": "≥15 caracteres" } → status cancelled
Guarda do XML (5 anos). O emitente é obrigado a apresentar o XML autorizado durante toda a janela de
decadência, e xml_url aponta para o provedor — deixa de resolver quando você troca de provedor fiscal, quando
ele poda arquivos antigos ou quando a conta é suspensa. Por isso o Mahiru baixa e guarda a própria cópia, byte a
byte (a assinatura da nota é sobre esses bytes), e a serve em GET /v1/invoices/{id}/xml com
Content-Disposition nomeado pela chave de acesso e o header Mahiru-Content-Sha256 para conferência.
O download é assíncrono: logo após a autorização o provedor ainda não publicou o arquivo, então xml_archive
começa em pending e vira stored em segundos — enquanto isso, GET …/xml responde 409
invoice_xml_unavailable (retente; não é 404, que significaria "essa nota não é sua"). Uma nota cancelada guarda
duas peças: a autorização e o evento de cancelamento (kind=cancellation). O prazo em retain_until segue o
art. 173, I do CTN — 5 anos contados do 1º dia do exercício seguinte ao da emissão, não da data de emissão. O
Mahiru nunca apaga esses arquivos automaticamente, nem depois do prazo.
DIFAL (grupo taxes.icms_difal). Obrigatório na venda interestadual a consumidor final não contribuinte:
sem o grupo, a SEFAZ devolve a rejeição 694. Envie a base (base, em unidades mínimas) e as alíquotas
(internal_rate = interna da UF de destino, interstate_rate = interestadual); fcp_rate e fcp_base só quando
houver fundo de combate à pobreza — fcp_base existe para os estados de "base dupla", e omitido usa base.
share_rate é a partilha destinada ao destino e assume 100, que é a regra desde 2019.
O Mahiru faz apenas a aritmética da partilha (valor = base × (interna − interestadual) × partilha) e repassa o
resto: alíquota interna varia por UF e muda com frequência, e manter tabela tributária é justamente o que o
ADR-005 decidiu não fazer. Optante do Simples Nacional não recolhe DIFAL nessa operação (a ADI 5464 suspendeu
a cobrança e a LC 190/2022 não alcançou o Simples) — para esses merchants, omita o grupo. Confirme o
enquadramento com sua contabilidade.
Reforma tributária (IBS/CBS). O grupo taxes.ibs_cbs por item leva os campos da NT 2025.002 (grupo UB):
CST de 3 dígitos, classificação tributária de 6 dígitos e as alíquotas — em 2026, as de teste (ibs_uf_rate: "0.1",
cbs_rate: "0.9"; o destaque é informativo e não altera o total da nota). Como em todo o modelo fiscal, o Mahiru
não decide enquadramento: repassa os códigos e calcula base/valores pela mesma convenção do ICMS (valor da
operação). Obrigatório por lei desde 03/08/2026 para o regime regular; Simples Nacional e MEI entram em 01/2027 —
para eles o grupo é simplesmente omitido. Enviar cst sem c_class_trib (ou vice-versa) é 400 parameter_invalid
apontando o campo, em vez da rejeição críptica da SEFAZ.
Erros: fiscal_profile_missing / fiscal_provider_missing (422, onboarding incompleto), not_cancellable_invoice (409),
invoice_xml_unavailable (409, cópia ainda não arquivada), invoice_cancel_failed (502). Eventos: invoice.issued, invoice.rejected, invoice.cancelled, invoice.error (objeto = invoice).
Settlements e conciliação
POST /v1/settlements { "provider": "stripe", "provider_ref": "po_…", "currency": "BRL", "gross", "fees", "net", "paid_at",
"lines": [ { "kind": "payment|refund|fee|adjustment", "provider_ref": "<id no provedor>", "gross", "fee", "net" } ] }
→ 201 { "id": "se_…", "status": "reconciled|discrepancy", "lines": [ { …, "payment_intent": "pi_…", "matched": true|false, "note" } ] }
POST /v1/settlements/sync?provider=stripe&from=…&to=… // puxa payouts do provedor (Stripe) e concilia
GET /v1/settlements?from&to[&cursor&limit] → { object:"list", data:[…], has_more, next_cursor? } // limit default 100, máx 500
GET /v1/settlements/{id}
GET /v1/settlements/upcoming → { object:"list", data:[ { "provider":"stripe", "amount":1842000, "currency":"BRL",
"status":"pending|in_transit", "expected_arrival_date":"2026-08-22" } ] }
GET /v1/reports/reconciliation?from&to[¤cy] → { ledger: { sales, refunds, net_sales, cash, receivable_by_provider, fees_by_provider }, settlements: { count, gross, fees, net, with_discrepancy, effective_fee_rate } }
GET /v1/reports/ledger?from&to[&account] → extrato (partidas dobradas: sales, refunds, cash, receivable:<p>, fees:<p>)
O ledger é derivado automaticamente dos eventos payment.succeeded/refund.succeeded; os repasses movem receivable:<p> para cash + fees:<p>.
Sync automático: o Mahiru puxa sozinho os repasses dos provedores que os expõem (hoje o Stripe), uma vez por dia,
numa janela móvel dos últimos 7 dias — você não precisa chamar POST /v1/settlements/sync. O endpoint continua no ar
para forçar a atualização (fechamento de mês, conferência) e é idempotente por provider_ref: repetir a janela não
duplica repasse.
Payouts futuros (GET /v1/settlements/upcoming): o que o provedor já anunciou e ainda não creditou — é daqui que
sai a resposta para "quando o dinheiro cai?". É consulta síncrona ao provedor (não vem do ledger), por isso vive num
endpoint separado do relatório de conciliação. Provedor que não sabe prever (Mercado Pago — ADR-006, import manual)
simplesmente não aparece na lista, sem erro. Se algum provedor não responder, a resposta traz também
"unavailable": ["stripe"]: a lista está incompleta, e isso é diferente de "nada a receber". expected_arrival_date
é a data (UTC) que o provedor informa; a chave não vem quando ele ainda não anunciou a data (o valor a receber
continua na lista).
Paginação de settlements: siga next_cursor enquanto has_more for true; a chave next_cursor só aparece
quando existe próxima página. O cursor é de keyset (não offset), então repasses importados no meio da paginação não
fazem você pular nem repetir linhas. Cursor inválido é tratado como primeira página.
Disputes (contestações do portador)
Quando o portador contesta a cobrança no banco (chargeback), o intent vai para disputed e estorno é recusado
(409 payment_disputed): o provedor já está com o dinheiro retido, e estornar por cima devolveria em dobro.
GET /v1/disputes?payment_intent=pi_… → { object:"list", data:[ … ] }
GET /v1/disputes/{id}
→ { "id": "dp_…", "object": "dispute", "payment_intent": "pi_…",
"status": "needs_response|under_review|won|lost",
"amount": 10000, "currency": "BRL", "fee": 1500, // fee = taxa de contestação cobrada pelo provedor
"reason": "fraudulent", "funds_withdrawn": true, // se o provedor já retirou o valor
"provider": "stripe", "mode": "test",
"evidence_due_at": "…", "opened_at": "…", "closed_at": "…", "created_at": "…" }
Ganhar a disputa devolve o intent ao estado anterior (paid ou partially_refunded) e o estorno volta a ser
possível. Perder mantém o intent em disputed — o dinheiro saiu por decisão da bandeira, não por um estorno que
você concedeu, e tratar os dois como a mesma coisa distorce a conciliação.
A evidência de defesa é enviada pelo painel do provedor; o Mahiru ainda não intermedia esse envio.
Webhook endpoints
POST /v1/webhook-endpoints { "url": "https://…", "events": ["payment.succeeded", …] } (lista vazia/omitida = todos) → 201 com secret (whsec_…, mostrado uma vez). GET, GET /{id}, DELETE /{id} (desabilita).
Diagnóstico de entrega:
GET /v1/webhook-endpoints/{id}/deliveries[?limit] → tentativas deste endpoint, mais recentes primeiro
{ id, event_id, event_type, status: "pending|succeeded|failed|exhausted", attempt, http_status, last_error,
next_attempt_at, delivered_at, created_at }
POST /v1/webhook-deliveries/{id}/resend → recoloca na fila (até 3 novas tentativas)
Entrega ainda pending não é reenviável (409 delivery_not_resendable): ela já vai ser tentada sozinha. O reenvio
devolve tentativas em vez de zerar o contador, então um endpoint fora do ar não volta à fila com o ciclo
completo a cada clique.
Listagens
GET /v1/payment-intents[?status&payment_method&provider&from&to&cursor&limit]
GET /v1/refunds[?status&from&to&cursor&limit]
GET /v1/invoices[?status&from&to&cursor&limit]
→ { object: "list", data: [ … ], has_more, next_cursor? } // limit default 25, máx 100
Ordenadas do mais recente para o mais antigo, com cursor de keyset (siga next_cursor enquanto has_more for
true). Os itens trazem os mesmos campos do GET individual de cada recurso, menos os que só fazem sentido ao vivo
(client_secret, next_action).
Erros
Sempre { "error": { "type", "code", "message", "param", "request_id" } }.
| HTTP | type / code |
|---|---|
| 400 | invalid_request_error / parameter_invalid, parameter_missing, malformed_request, idempotency_key_required |
| 401 | authentication_error / missing_api_key, invalid_api_key |
| 403 | authentication_error / merchant_suspended — conta suspensa por inadimplência; só chaves live. Não rotacione a chave: veja as faturas no dashboard |
| 404 | invalid_request_error / resource_missing, unknown_route |
| 409 | idempotency_error / idempotency_key_reused; invalid_request_error / invalid_state_transition, not_confirmable, not_capturable, not_cancellable, not_refundable, refund_exceeds_amount, concurrent_modification, payment_disputed |
| 422 | invalid_request_error / no_provider_available, provider_account_missing |
| 429 | rate_limit_error / rate_limited — respeite Retry-After (segundos) e repita |
| 500 | api_error / internal_error — repita com a mesma Idempotency-Key |
Limite de requisições. Cada chave de API tem seu próprio teto em /v1/** (padrão: 20 req/s sustentadas, com
folga de 40 para picos). Toda resposta traz X-RateLimit-Limit, X-RateLimit-Remaining e X-RateLimit-Reset
(segundos até a folga voltar ao máximo); ao estourar, vem 429 com Retry-After. Chaves diferentes têm baldes
independentes — um job em laço numa chave não derruba o checkout que usa outra. Um 429 não consome sua
Idempotency-Key: pode repetir a requisição com a mesma chave.
Eventos (webhooks de saída)
Entrega at-least-once, ordem não garantida entre eventos diferentes; deduplique por id. Responda 2xx em ≤10 s;
outro status ⇒ retry exponencial (5 s → 1 h, 12 tentativas) e depois exhausted (reenvio manual).
Envelope:
{ "id": "evt_…", "object": "event", "api_version": "2026-01-01", "type": "payment.succeeded", "created": "2026-…Z",
"data": { "object": { "id": "pi_…", "object": "payment_intent", "status": "paid", "amount": 19990, "currency": "BRL",
"amount_refunded": 0, "payment_method": "credit_card", "capture_method": "automatic", "mode": "test",
"provider": "stripe", "failure_code": null, "metadata": { "order_id": "…" }, "created_at": "…" } } }
Tipos: payment.created, payment.requires_action, payment.processing, payment.authorized, payment.succeeded,
payment.failed, payment.cancelled, payment.refunded, payment.partially_refunded (objeto = payment intent);
refund.succeeded, refund.failed, refund.pending (objeto = refund, com payment_intent);
settlement.created (objeto = settlement); billing.invoice.opened, billing.invoice.paid (objeto =
merchant_invoice — a fatura que o Mahiru cobra de você, ver "Billing"). Eventos nunca contêm
client_secret nem segredos.
settlement.created sai quando um repasse entra no Mahiru (importado por você ou puxado pelo sync) e já conciliado —
é o sinal de "caiu na conta", para não depender de polling no relatório. O objeto vem sem as linhas do payout (são
centenas num payout real): para elas, GET /v1/settlements/{id}. Atenção a status: discrepancy significa que o
dinheiro entrou mas as linhas não bateram com o que o Mahiru conhece.
refund.pending sai quando o provedor não confirmou o estorno de forma conclusiva (timeout/5xx ou estorno assíncrono):
o refund fica pending e o desfecho chega depois em refund.succeeded/refund.failed, ou pela conciliação. Se você
assina uma lista explícita de eventos, inclua-o — senão o estorno some da sua visão até o desfecho.
Assinatura — header Mahiru-Signature: t=<unix_seconds>,v1=<hex>:
expected = HMAC_SHA256(secret, "<t>." + raw_body) // compare em tempo constante; rejeite se |now - t| > 300 s
Headers adicionais: Mahiru-Event-Id, Mahiru-Delivery-Attempt, User-Agent: Mahiru-Webhooks/1.0.
Onboarding (operador Mahiru)
POST /admin/merchants {name, slug}
POST /admin/merchants/{id}/api-keys {mode, name} → key (uma vez)
POST /admin/merchants/{id}/provider-accounts
{provider:"stripe", mode, credentials:{secret_key, webhook_secret, publishable_key}, priority}
{provider:"mercadopago", mode, credentials:{access_token, public_key, webhook_secret}, priority}
{provider:"focusnfe", mode, credentials:{token}} (fiscal)
→ webhook_url_path a cadastrar no provedor: /webhooks/<provider>/{merchantId}/{mode}
Credenciais são validadas antes de salvar: valores com espaço/aspas/`#` (linha copiada do .env) → 400 `invalid_provider_credentials`
(`param: credentials`); prefixo × modo (sk_test_ em live, TEST- em live) idem; e o Mahiru faz uma chamada autenticada barata ao
provedor (Stripe /v1/balance, MP /users/me, Focus GET nfe) → 422 `provider_credentials_rejected` com a mensagem do provedor.
`"verify": false` pula a chamada online. Provedor desconhecido → 400 `unknown_provider`.
PUT /admin/merchants/{id}/fiscal-profile {mode, cnpj, legal_name, trade_name, state_registration, tax_regime (1|2|3), address, default_series, default_nature_of_operation}
Roteamento: para cada pagamento, o Mahiru ordena os provedores habilitados do merchant que suportam o método —
primeiro pelas regras (POST /admin/merchants/{id}/routing-rules {mode, payment_method?, currency?, min_amount?, max_amount?, provider, priority};
a mais específica vence), depois pela priority da conta; provedores com circuito aberto (falhas recentes) vão para o fim.
Roteamento por desempenho (opt-in): PUT /admin/merchants/{id}/routing-settings {mode, strategy: "performance", window_days, min_sample, weight_approval, weight_cost, weight_latency}
faz o Mahiru ordenar os provedores sem regra pelo score aprovação − custo − latência calculado das últimas tentativas e repasses
(GET /v1/reports/providers?payment_method=card mostra stats e score). Regras explícitas continuam vencendo; sem amostra mínima vale a prioridade da conta.
Fallback (regra zero dupla cobrança): o Mahiru só tenta o próximo provedor quando é certo que o primeiro não processou
nada — provider_unreachable (conexão nunca estabelecida), rate_limited (429) ou provider_configuration_error (401/403).
Resultados ambíguos (provider_unavailable 5xx, provider_timeout, processing_error) e recusas do pagador nunca são
retentados em outro provedor: o intent fica failed e cabe ao merchant criar um novo intent. Cada tentativa fica em payment_attempts.
Com MAHIRU_PUBLIC_BASE_URL definido, o Mercado Pago recebe a notification_url automaticamente por pagamento.
Console (dashboard) — /console/**
Superfície separada de /v1 para pessoas (usuários do Mahiru, ADR-008). Auth: POST /console/auth/login {email, password} →
{token: "msess_…", expires_at, status, user, merchants:[{id, name, role}]}; depois Authorization: Bearer msess_… + X-Merchant-Id: <uuid> (+ X-Mode: test|live).
GET /console/auth/me, POST /console/auth/logout, POST /console/auth/password {current, next} (≥10 chars; revoga as outras sessões).
Segundo fator (TOTP). O login devolve status: ok, totp_required (a sessão nasce travada — o token só
serve para POST /console/auth/totp {code} até o código ser conferido) ou totp_enrollment_required (a conta
precisa de 2FA e ainda não cadastrou). O POST /console/auth/totp responde no mesmo formato do login
(expires_at, status: "ok", user, merchants), então o cliente dá ao cookie a mesma validade da sessão. Cadastro: POST /console/2fa/setup → {secret, otpauth_uri} para o QR;
POST /console/2fa/enable {code} ativa e devolve 10 códigos de recuperação mostrados uma única vez;
DELETE /console/2fa?code=… desliga (exige código válido). Códigos de recuperação são de uso único. Por padrão o
2FA é obrigatório para operadores do Mahiru (MAHIRU_REQUIRE_TOTP_FOR=operator|all|none).
Papéis: viewer (lê), admin (age/configura), owner (equipe). 5 falhas de login por e-mail travam 15 min (429 too_many_attempts).
GET /console/overview?from=&to= KPIs (por status/provedor/dia, ledger)
GET /console/payment-intents?status=&provider=&method=&q=&from=&to=&cursor=&limit=
GET /console/payment-intents/{id} + timeline {transitions, attempts, events} + refunds + invoices
POST /console/payment-intents/{id}/refund|cancel|capture (admin+; cancel sem provider_ref cancela só no Mahiru)
GET /console/refunds | /invoices[/{id}] (+ POST cancel) | /settlements | /events
GET /console/invoices/{id}/xml[?kind=] download do XML guardado (mesmo arquivo do /v1)
POST /console/invoices/{id}/xml/retry[?kind=] (admin+) reenfileira um XML marcado unavailable
GET /console/reports/reconciliation | /reports/providers?payment_method=
GET|POST /console/api-keys, DELETE /console/api-keys/{id} (a chave aparece uma vez)
GET|POST /console/webhook-endpoints, GET /console/webhook-endpoints/{id}/deliveries, POST /console/webhook-deliveries/{id}/resend
GET|POST /console/provider-accounts, PUT /console/provider-accounts/{id}/enabled {enabled} ({id} = uuid da conta; credenciais só de escrita)
GET|PUT /console/fiscal-profile
GET /console/routing, PUT /console/routing/settings, POST /console/routing/rules, DELETE /console/routing/rules/{id}
GET|POST /console/team {email, name, role} → invite_token (uso único, expira), DELETE /console/team/{userId} (owner)
GET /console/audit-log
Operador Mahiru: GET|POST /console/operator/merchants {name, slug, owner_email}, GET /console/operator/health, GET /console/operator/audit-log
GET|POST|DELETE /console/operator/merchants/{id}/crm-link {workspace_id, workspace_host, api_key, verify}
— liga o merchant ao workspace dele no CRM. A api_key é write-only (nunca volta numa resposta) e é
verificada no CRM antes de gravar, salvo verify=false. DELETE desliga sem apagar o que já foi sincronizado.
POST /console/operator/merchants/{id}/crm-anonymize {email}
— pedido de eliminação do titular: anonimiza no CRM do lojista a pessoa daquele e-mail.
Responde {anonymized, record_id}; anonymized=false quando nada foi sincronizado para o e-mail.
Primeiro operador: MAHIRU_BOOTSTRAP_EMAIL (só cria quando não há usuários; sem senha — o primeiro acesso é
um link de uso único, por e-mail ou no log do primeiro boot). CORS: MAHIRU_CONSOLE_ORIGINS.
Convite e redefinição de senha. Convidar cria um token de uso único (validade 48h) em vez de senha
temporária: o convidado escolhe a própria senha em POST /console/auth/accept {token, password} — rota pública,
já que ele ainda não tem sessão. Consumir um token derruba as sessões abertas daquele usuário.
A resposta traz email_sent. Com e-mail configurado (ADR-009), o link vai direto ao destinatário e o token
não aparece na resposta; sem e-mail — ou se o envio falhar — o token volta no corpo (invite_token /
reset_token) para entrega manual, e nenhum convite se perde por causa de um SMTP fora do ar.
Quem esqueceu a senha chama POST /console/auth/forgot {email} (resposta idêntica para e-mail existente ou não):
com e-mail ligado o link é enviado automaticamente; sem, um operador emite em
POST /console/operator/users/{id}/reset-token.
Prazo dos dados no CRM do lojista
O que o Mahiru escreve no CRM (nome e e-mail do comprador, e o pedido) tem prazo: cinco anos sem
atividade nova daquela pessoa, o mesmo prazo com que o RetentionJob anonimiza o contato em
payment_intents. Os dois lados envelhecem juntos de propósito — prazos diferentes deixariam uma janela
em que o CRM ainda identifica quem o Mahiru já esqueceu. Uma compra nova reinicia a contagem.
Vencido o prazo, a pessoa é anonimizada, não apagada: nome e e-mail viram um marcador e o pedido continua no histórico do lojista com valor, data e status. Dado anonimizado sai do alcance da LGPD (art. 12), então a finalidade se encerra sem destruir o histórico comercial que o lojista usa.
O crm-anonymize faz o mesmo sob demanda, para pedido de eliminação do titular, sem esperar o prazo.
Dois limites, que precisam constar na resposta ao titular:
- Alcança só o que o Mahiru escreveu. Telefone, endereço e anotações que o próprio lojista tenha preenchido naquele registro ficam — naquele CRM o controlador é ele. O pedido do titular tem que ser repassado ao lojista.
- Ligação desligada, sem alcance. Sem credencial ativa não há como escrever no CRM do lojista. Desligar a sincronização é ele assumir sozinho o que já está lá.
O audit_log registra o record_id no CRM, nunca o e-mail: guardar na trilha o dado que o titular pediu
para eliminar seria trocá-lo de tabela, não eliminá-lo.
Billing (tarifa da plataforma)
O que o Mahiru cobra do lojista pelo uso da plataforma (ADR-011) — não confundir com as taxas que o provedor
de pagamento cobra, que aparecem em fees:<provider> no relatório de conciliação. É assunto de console: /v1/**
não tem nem nunca terá rota de billing.
Preço híbrido: mensalidade + percentual (bps) e valor fixo por transação, por método. O modo test é
isento. A tarifa nasce como lançamento contábil no momento em que o pagamento é liquidado:
payment.succeeded (mode=live) → DR fees:mahiru round_half_up(amount × bps / 10000) + fixo
CR payable:mahiru
Não toca receivable/cash porque o dinheiro da venda cai na conta do próprio lojista no provedor — a tarifa é
despesa contra um passivo com a plataforma. SUM(payable:mahiru) é, portanto, quanto se deve ao Mahiru, e é
dessa soma que a fatura mensal é derivada. Estorno não devolve tarifa (padrão de mercado).
GET /console/billing/schedule o preço vigente hoje para o seu merchant (qualquer papel)
GET /console/billing/invoices[?status=] suas faturas · GET /console/billing/invoices/{id} traz as linhas
Operador: GET|POST /console/operator/billing/schedules[?merchant_id=]
GET /console/operator/billing/invoices[?status=&overdue=true]
GET /console/operator/billing/invoices/{merchantId}/{id}
POST /console/operator/billing/invoices/{merchantId}/{id}/mark-paid
POST /console/operator/billing/invoices/{merchantId}/{id}/void {reason}
POST /console/operator/billing/invoices/{merchantId}/{id}/recharge[?attempt=N]
POST /console/operator/billing/close?period=YYYY-MM
POST /console/operator/merchants/{id}/suspend {reason} | .../reactivate
A tabela de preço é append-only com vigência: mudar preço é criar outra linha com effective_from posterior
(hoje ou depois — data passada é recusada com 400 fee_schedule_in_the_past), nunca editar a vigente. Não existe
PUT nem DELETE: o ledger é imutável e o preço que o gerou também precisa ser. Um POST repetido para a mesma
data e escopo devolve 400 fee_schedule_exists.
Corpo do POST:
{ "merchant_id": null, "effective_from": "2026-09-01", "monthly_fee_cents": 9900, "currency": "BRL",
"free_transactions": 100, "grace_until": "2026-10-01", "notes": "…",
"rates": { "pix": { "percent_bps": 99, "fixed_cents": 30 },
"credit_card": { "percent_bps": 399, "fixed_cents": 39 } } }
merchant_id: null cria a tabela padrão da plataforma, usada por quem não tem tabela própria; com
merchant_id, a tabela daquele lojista, que vence a padrão. free_transactions isenta as N primeiras transações
tarifáveis do mês; grace_until isenta tudo até a data (exclusiva). Sem nenhuma tabela cadastrada, a tarifa é
zero — uma instalação nova não quebra por falta de configuração.
O dia e o mês de cada tarifa seguem um fuso único (MAHIRU_BILLING_ZONE, padrão America/Sao_Paulo), e não UTC:
um pagamento das 22h de 31/08 em São Paulo pertence a agosto, como no extrato do lojista.
Fatura mensal
No 1º dia do mês (MAHIRU_BILLING_CLOSE_CRON, 4h no fuso do billing) o Mahiru fecha o mês anterior de cada
lojista ativo. A fatura é montada a partir do ledger, nunca recalculada: as linhas de tarifa somam o que já
estava em fees:mahiru, e a mensalidade — que não nasce de nenhum pagamento — é lançada no ledger no próprio
fechamento, para que SUM(payable:mahiru) continue sozinho valendo como "quanto se deve".
Período [period_start, period_end) com fim exclusivo (1º dia do mês seguinte), para que nenhum pagamento
caia em duas faturas. Sem tarifa e sem mensalidade, não há fatura. UNIQUE (merchant_id, period_start) torna o
fechamento idempotente: rodar de novo devolve a fatura existente e não cobra o mesmo consumo duas vezes — a
resposta do fechamento manual separa invoiced (emitidas agora) de existing (já existiam).
Status: draft → open (apresentada e cobrável; linhas e total congelados) → paid | void. A baixa lança
DR payable:mahiru / CR cash, então o passivo zera junto com a fatura. Anular exige motivo e não vale para
fatura já paga (400 invoice_already_paid).
Eventos: billing.invoice.opened e billing.invoice.paid, objeto merchant_invoice
({id: "bill_…", status, total, currency, period_start, period_end, due_date, payment_intent, lines[]}) —
entregues pelos mesmos webhooks de sempre, para quem quiser automatizar o próprio contas-a-pagar.
Como a fatura é cobrada (self-billing)
O Mahiru cobra pelo Mahiru: um merchant interno da plataforma (MAHIRU_PLATFORM_MERCHANT_SLUG, criado no
bootstrap) emite um PIX contra o lojista, pelo mesmo caminho de pagamentos que qualquer integração usa — conta
de provedor própria, modo live, liberação de operador. O QR e o copia-e-cola ficam congelados na fatura
(next_action), então o console os mostra sem reconsultar o provedor.
Quando o PIX compensa, a baixa vem de um consumidor do próprio outbox, não de um webhook: o Mahiru não precisa apontar um endpoint para si mesmo, nem verificar a própria assinatura, nem depender de retry de rede para saber que o pagamento foi confirmado. O evento já está commitado junto com a mudança de status do intent.
Três guardas de circularidade, explícitas no código e no ADR-011: o merchant plataforma é isento de tarifa,
nunca se autofatura, e os pagamentos dele seguem gerando sales/receivable — isso é a receita do Mahiru,
e é para existir.
Nada disso trava o faturamento. A cobrança é anexada por uma varredura (MAHIRU_SELF_BILLING_ENABLED, a cada
5 min), que é também a retentativa: provedor fora do ar no dia 1º, flag ligada depois, merchant plataforma criado
mais tarde — em todos os casos a fatura abre open sem QR e é completada depois. Se nada disso resolver, o
fallback é sempre a baixa manual do operador (mark-paid). PIX expirado não é regenerado automaticamente no v1:
POST .../recharge cria outra cobrança (o attempt muda a chave de idempotência — sem ele o Mahiru devolveria o
intent vencido).
Inadimplência
Suspensão é ação explícita de operador, nunca automática: derrubar a operação de um lojista por causa de um
PIX que não compensou é caro demais para ser decidido por um cron. O motivo é obrigatório e fica no audit_log.
O efeito é deliberadamente estreito. Chaves sk_live_ passam a receber 403 merchant_suspended — código
próprio, e não invalid_api_key, para que o e-commerce distinga "conta suspensa" de "chave errada" e não saia
rotacionando credencial à toa. Continuam funcionando:
- chaves
sk_test_— quem está suspenso segue integrando e testando; barrar test puniria justamente quem está tentando resolver; - webhooks de provedor — pagamentos em voo precisam liquidar, senão a suspensão vira perda de dinheiro;
- o console — inclusive a fatura que causou a suspensão.
Reativar (POST .../reactivate) devolve o live na hora. Não há dunning automático no v1.
