Mahiru

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 (processingissued|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[&currency]   → { 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:

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: draftopen (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:

Reativar (POST .../reactivate) devolve o live na hora. Não há dunning automático no v1.