API de Integração do Mais PDV
Sincronize clientes, produtos, contas a receber e a pagar entre o Mais PDV e o seu sistema. Autenticação por chave, paginação por cursor, sincronização incremental e webhooks assinados.
Primeira integração em 5 passos
Do zero ao primeiro dado sincronizado. Cada passo funciona sozinho — dá para parar no 3 e já ter valor.
- Gere a chave no Mais PDV, em Empresa → Integrações → Nova chave. Ela aparece uma vez só.
- Confirme que autenticou com
GET /v1/me. - Suba seus clientes com
PUT /v1/customers/by-external/{seu_id}— pode rodar quantas vezes quiser. - Crie uma conta a receber com
POST /v1/finance/receivables. - Assine um webhook para saber das mudanças sem ficar perguntando.
curl https://maispdv.com/api/v1/me \
-H "Authorization: Bearer mpdv_live_sua_chave_aqui"
Autenticação
Toda chamada leva a chave no header Authorization, no formato Bearer.
A chave pertence à empresa, não a um usuário — trocar de funcionário não quebra a integração.
Authorization: Bearer mpdv_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Escopos
Cada chave carrega só o que você marcou. Faltou escopo, a resposta é 403 insufficient_scope dizendo qual falta.
| Escopo | Dá acesso a |
|---|---|
catalog:read / catalog:write | produtos, serviços e categorias |
customers:read / customers:write | clientes |
suppliers:read / suppliers:write | fornecedores |
finance:read / finance:write | contas a receber e a pagar |
sales:read | ler vendas e as formas de pagamento da empresa |
sales:write | registrar e cancelar vendas |
webhooks:manage | cadastrar endereços de webhook |
Formato das respostas
Item único vem em data. Lista vem em data + meta.
{
"data": [ { "id": 8871, "name": "Padaria do João" } ],
"meta": { "has_more": true, "next_cursor": "eyJpZCI6ODg3MX0", "limit": 50 }
}
Valor em dinheiro é sempre string decimal com 2 casas ("149.90"), nunca float — float com centavo dá diferença de arredondamento em toda linguagem.
Erros
Todo erro traz code, uma mensagem em português e um request_id. Guarde o request_id: é com ele que o suporte acha a sua chamada.
{
"error": {
"code": "validation_failed",
"message": "Campo não reconhecido: telefone. Confira a documentação...",
"details": { "unknown_fields": ["telefone"] },
"request_id": "01KZPJRGR7RQ7R7GEN7M2T9CJH"
}
}
| HTTP | code | O que fazer |
|---|---|---|
| 400 | malformed_json | o corpo não é JSON válido |
| 401 | missing_token / invalid_token | chave ausente, revogada ou expirada — gere outra no painel |
| 402 | plan_required | a conta não tem o adicional de API ativo |
| 403 | insufficient_scope | marque o escopo na chave (o erro diz qual) |
| 404 | not_found | não existe ou é de outra empresa — nunca dizemos qual dos dois |
| 409 | duplicate_resource | bateu num índice único; veja o 409 de cliente abaixo |
| 409 | idempotency_key_reused | a mesma Idempotency-Key com outro corpo |
| 409 | managed_by_pdv | registro mantido pelo sistema (conta gerada por venda, venda feita no balcão): leitura sim, escrita não |
| 409 | fiscal_document_issued | a venda tem nota autorizada — o cancelamento fiscal é feito pelo assinante, no sistema |
| 422 | validation_failed | campo inválido ou desconhecido |
| 422 | insufficient_stock | faltou estoque: a venda inteira foi recusada e nada foi debitado |
| 422 | payment_mismatch | a soma dos pagamentos não fecha com o total da venda |
| 429 | rate_limited | respeite o Retry-After |
telefone onde
esperamos phone, a resposta é 422 — e não um 200 que grava metade da sua base sem telefone.
Isso vale também dentro do /batch.
O 409 de cliente com nome repetido
O Mais PDV não aceita dois clientes com o mesmo nome na mesma empresa. É restrição antiga do produto, anterior à API. Quando bate, devolvemos o campo e o id de quem já existe, para você decidir:
{ "error": { "code": "duplicate_resource",
"message": "Já existe um cliente com este nome nesta empresa...",
"details": { "field": "name", "conflicting_id": 8871 } } }
A saída recomendada é usar PUT /v1/customers/by-external/{seu_id}, que atualiza em vez de tentar criar.
Paginação e sincronização incremental
A paginação é por cursor, não por página numerada: em base grande, OFFSET 50000
faz o banco varrer e jogar fora 50 mil linhas a cada chamada.
GET /v1/customers?limit=100
GET /v1/customers?limit=100&cursor=eyJpZCI6ODg3MX0
Para sincronizar só o que mudou, use updated_since com data ISO-8601:
GET /v1/customers?updated_since=2026-08-09T10:00:00-03:00
GET /v1/customers/deleted?since=… (existe também para catálogo, recebíveis e pagáveis).
Sem isso, um cliente apagado no Mais PDV fica vivo para sempre do seu lado.
Como não duplicar nada
Dois mecanismos, para dois problemas diferentes:
1. external_id — o id do seu sistema
Mande o seu identificador e use as rotas by-external. Rodar o mesmo script dez vezes
cria o registro uma vez e atualiza nas outras nove.
PUT /v1/customers/by-external/cli-4471
{ "name": "Padaria do João", "person_type": "J", "document": "12345678000190" }
2. Idempotency-Key — para dinheiro
Obrigatória na baixa de conta. Se a sua chamada cair depois de o servidor já ter processado, repetir com a mesma chave devolve a mesma resposta, sem baixar de novo.
POST /v1/finance/receivables/551/settle
Idempotency-Key: 3f8c2b7e-9a1d-4f6b-8c2e-5d9a1f3b7c4e
{ "paid_at": "2026-08-09T11:02:00-03:00", "payment_method": "pix", "amount": "149.90" }
A resposta fica guardada por 24 horas. Mesma chave com corpo diferente devolve 409: gere uma chave nova por operação.
Envio em lote
Até 500 itens por chamada, e o lote inteiro conta como uma requisição na cota.
POST /v1/customers/batch
{ "items": [ { "external_id": "cli-1", "name": "Cliente 1" }, ... ] }
A resposta é 207 quando há sucesso e falha juntos. Cada erro carrega o index
do item na sua lista — é assim que você acha a linha do seu arquivo sem reprocessar tudo.
{ "data": [ ... ],
"meta": { "succeeded": 498, "failed": 2,
"errors": [ { "index": 17, "external_id": "cli-18",
"code": "validation_failed", "message": "Campo não reconhecido: telefone." } ] } }
No lote, external_id é obrigatório — é ele que evita duplicar quando você roda de novo.
Todos os endpoints
Base: https://maispdv.com/api/v1
| Método | Rota | Escopo |
|---|---|---|
| GET | /me | — |
| GET | /catalog/items · /catalog/items/{id} · /catalog/items/deleted · /catalog/categories | catalog:read |
| POST | /catalog/items · /catalog/items/batch · /catalog/categories | catalog:write |
| PUT | /catalog/items/by-external/{external_id} | catalog:write |
| PATCH | /catalog/items/{id} | catalog:write |
| DEL | /catalog/items/{id} (desativa, não apaga) | catalog:write |
Clientes e fornecedores — mesmas operações em /customers e /suppliers | ||
| GET | /finance/receivables · /finance/payables · /{id} · /deleted | finance:read |
| POST | /finance/receivables · /batch · /{id}/settle | finance:write |
| PATCH | /finance/receivables/{id} | finance:write |
| GET | /finance/summary?from=&to= · /finance/payment-methods | finance:read |
| GET | /sales · /sales/{id} · /sales/by-external/{external_id} · /sales/payment-types | sales:read |
| POST | /sales · /sales/{id}/cancel | sales:write |
| GET | /webhooks · /webhooks/{id}/deliveries | webhooks:manage |
| POST | /webhooks · /webhooks/{id}/test | webhooks:manage |
Financeiro — o que você precisa saber antes
Três regras que evitam surpresa:
paid_atmanda. O painel do assinante trabalha em regime de caixa: uma baixa com data retroativa entra no mês certo. Baixa sempaid_atusa o momento atual.- Não existe baixa parcial. O
amountna baixa serve de conferência: se divergir do valor da conta, a resposta é 422. - Conta gerada por venda é intocável. Fiado e convênio nascem no caixa e são conciliados por lá. Você lista (vem
"origin": "sale"), mas PATCH, DELETE e settle respondem409 managed_by_pdv.
is_overdue é calculado (pendente com vencimento no passado) — não existe status overdue gravado. Para filtrar, use ?status=overdue.
Vendas
Duas mãos: ler o que foi vendido no balcão (para conciliar com o seu sistema) e registrar a venda que aconteceu no seu sistema — que é o que faz o estoque e o faturamento do assinante baterem com a realidade.
Antes de registrar a primeira venda
As formas de pagamento são cadastradas por cada assinante, então o id vem do próprio Mais PDV:
GET /v1/sales/payment-types
{ "data": [
{ "id": 18, "name": "Dinheiro", "is_cash": true, "fee_percentage": "0.00", "accepts_api": true },
{ "id": 32, "name": "Convênio", "accepts_api": false,
"reason": "Convênio é venda a prazo, e o v1 só registra venda quitada..." }
] }
Forma com accepts_api: false aparece de propósito, com o motivo: convênio (é fiado) e os
descontos internos do PDV (crédito de troca, promoção, resgate de cashback), que o próprio sistema gera.
Registrando a venda
POST /v1/sales
Idempotency-Key: 4b0c9f2e-7a13-4d8c-9f21-0b7e5a4c3d19
{ "external_id": "PED-1001",
"customer_external_id": "cli-4471",
"sold_at": "2026-08-12T10:32:00-03:00",
"delivery_fee": "5.00",
"items": [
{ "catalog_item_id": 341, "quantity": "2", "unit_price": "10.00" },
{ "catalog_item_id": 355, "quantity": "0.750" },
{ "description": "Taxa de montagem", "quantity": "1", "unit_price": "20.00" }
],
"payments": [ { "payment_type_id": 18, "amount": "45.00" } ] }
A venda entra já concluída. O que vale a pena saber antes de codar:
- Só venda quitada. A soma de
paymentsfecha com o total. Pagou a menos, é422 payment_mismatch; para saldo em aberto, lance um recebível emPOST /v1/finance/receivables. Sobra só é aceita se houver forma em espécie — vira troco. - Estoque barra a venda inteira. Faltando um item, nenhum é gravado e nada é
debitado. O erro traz
catalog_item_id,requestedeavailable. sold_atdefine o dia no dashboard e no DRE do assinante (regime de caixa). Ausente, vale o momento da chamada.- Preço é seu. Não aplicamos promoção, faixa de atacado nem cashback: o que você manda é
o que vale. Sem
unit_price, usamos o preço de tabela do catálogo. - Nada de fiscal. A API não emite NFC-e nem NF-e — isso continua sendo do assinante.
- Item avulso (sem produto do catálogo) usa
description+unit_pricee não mexe em estoque. Cada item usa um identificador:catalog_item_id,catalog_item_external_idoudescription. - O valor do pagamento é o bruto cobrado do cliente. Se a forma tem acréscimo, ele é extraído de dentro desse valor — não some por cima.
Idempotency-Key e o mesmo corpo devolve a mesma venda, sem duplicar. Perdeu a chave e mandou de
novo? O external_id repetido responde 409 duplicate_resource com o
conflicting_id — confirme em GET /v1/sales/by-external/PED-1001 e siga a vida.
Corrigir e cancelar
Venda finalizada não se edita (não existe PATCH /v1/sales/{id}) — errou,
cancela e registra de novo, como no balcão. O cancelamento devolve o estoque:
POST /v1/sales/4471/cancel
{ "reason": "Cliente desistiu na entrega" }
Venda já cancelada responde 200 com o estado atual (reprocessar sua fila não é erro). Venda que
não nasceu na API responde 409 managed_by_pdv, e venda com nota autorizada
responde 409 fiscal_document_issued.
Webhooks
Em vez de perguntar de minuto em minuto, receba o aviso na hora. Cadastre o endereço no painel ou por POST /v1/webhooks.
| Evento | Quando dispara |
|---|---|
customer.created / customer.updated | cliente criado ou alterado (por qualquer caminho) |
catalog_item.created / catalog_item.updated | produto ou serviço |
receivable.created | conta a receber lançada |
receivable.settled / payable.settled | conta baixada |
sale.completed / sale.cancelled | venda concluída ou cancelada |
O que chega no seu servidor
POST https://seusistema.com.br/webhooks/maispdv
X-MaisPDV-Event: receivable.settled
X-MaisPDV-Delivery: 4471
X-MaisPDV-Timestamp: 1786000920
X-MaisPDV-Signature: sha256=3f9a...
{ "event": "receivable.settled",
"occurred_at": "2026-08-10T16:35:12-03:00",
"origin": "pdv",
"data": { ...o mesmo objeto que o GET devolveria... } }
Conferindo a assinatura
A assinatura é HMAC-SHA256 de timestamp + "." + corpo_bruto, com o segredo do
endereço. Use o corpo cru, antes de qualquer parse — reserializar o JSON muda um byte e
a conta não fecha.
// Node.js (Express)
const crypto = require('crypto');
app.post('/webhooks/maispdv', express.raw({type:'application/json'}), (req, res) => {
const ts = req.header('X-MaisPDV-Timestamp');
const sig = req.header('X-MaisPDV-Signature');
const esperada = 'sha256=' + crypto.createHmac('sha256', SEGREDO)
.update(ts + '.' + req.body).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(esperada))) return res.sendStatus(401);
if (Math.abs(Date.now()/1000 - Number(ts)) > 300) return res.sendStatus(401); // anti-replay: 5 min
res.sendStatus(200); // responda 2xx ANTES de processar
});
// PHP
$ts = $_SERVER['HTTP_X_MAISPDV_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_MAISPDV_SIGNATURE'] ?? '';
$body = file_get_contents('php://input');
$esperada = 'sha256=' . hash_hmac('sha256', $ts . '.' . $body, $segredo);
if (! hash_equals($esperada, $sig) || abs(time() - (int) $ts) > 300) {
http_response_code(401); exit;
}
http_response_code(200);
POST /v1/customers, o webhook cadastrado pela sua própria chave não recebe o
customer.created daquele cliente — senão os dois sistemas ficariam se cutucando em loop.
Todo payload traz origin (api, pdv ou system) para você
decidir sozinho também.
updated_since. Servidor cai, rede falha, evento se
perde. Use os dois: webhook para reagir na hora, e uma varredura por updated_since de hora em
hora para reconciliar. Quem confia só no webhook um dia perde um recebimento.
Cliente PHP — um arquivo, zero dependências
Se o seu sistema é PHP, não precisa escrever cliente HTTP nenhum:
baixe o MaisPdv.php, jogue na pasta do
projeto e use. Ele cuida sozinho da paginação, das novas tentativas em erro temporário, da
Idempotency-Key das operações que mexem em dinheiro e da conferência de assinatura dos
webhooks.
require_once __DIR__ . '/MaisPdv.php';
$api = new MaisPdv('mpdv_live_sua_chave_aqui');
// 1) a chave funciona?
$api->eu();
// 2) subir clientes sem duplicar, quantas vezes quiser
$api->salvarClientePorExterno('cli-4471', [
'name' => 'Padaria do João', 'person_type' => 'J', 'document' => '12345678000190',
]);
// 3) ler só o que mudou — a paginação é feita sozinha
foreach ($api->todosClientes(['updated_since' => $ultimaSync]) as $cliente) { /* ... */ }
// 4) registrar a venda que aconteceu no SEU sistema
$venda = $api->registrarVenda([
'external_id' => 'PED-1001',
'items' => [['catalog_item_id' => 341, 'quantity' => '2', 'unit_price' => '10.00']],
'payments' => [['payment_type_id' => 18, 'amount' => '20.00']],
]);
echo $venda['id']; // métodos de UM registro devolvem o objeto direto;
// os de lista devolvem data + meta, porque você precisa do cursor
Os erros viram exceção com atalhos para o que você realmente trata diferente:
try {
$api->registrarVenda($dados);
} catch (MaisPdvErro $e) {
if ($e->ehEstoqueInsuficiente()) {
// nada foi gravado e nada saiu do estoque
$falta = $e->detalhes(); // catalog_item_id, requested, available
} elseif ($e->ehDuplicado()) {
$jaEntrou = $e->idConflitante(); // a venda já estava lá
} elseif ($e->ehTemporario()) {
// rede, 429 ou erro nosso: tente de novo mais tarde
}
error_log($e->getMessage() . ' [' . $e->requestId() . ']');
}
E o recebimento de webhook, com assinatura conferida, cabe em duas linhas:
$evento = MaisPdv::lerWebhook('segredo_do_endereco'); // 401 automático se não conferir
http_response_code(200); // responda 2xx ANTES de processar
Limites
| Requisições | 120 por minuto e 50.000 por dia, por empresa (somando todas as chaves) |
| Itens por lote | 500 — e o lote conta como 1 requisição |
| Itens por página | 50 por padrão, 200 no máximo |
| Tamanho do corpo | 1 MB |
| Chaves ativas | 5 por empresa |
Cada resposta traz X-RateLimit-Limit, X-RateLimit-Remaining e
X-RateLimit-Reset. No 429 vem também Retry-After, em segundos — espere esse tempo
em vez de tentar de novo na hora.
Referência completa
O contrato formal está em OpenAPI 3.1 — dá para gerar um client na sua
linguagem a partir dele. Dúvida que não está aqui:
fale com o suporte informando o request_id.