API v1 · REST · JSON

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.

  1. Gere a chave no Mais PDV, em Empresa → Integrações → Nova chave. Ela aparece uma vez só.
  2. Confirme que autenticou com GET /v1/me.
  3. Suba seus clientes com PUT /v1/customers/by-external/{seu_id} — pode rodar quantas vezes quiser.
  4. Crie uma conta a receber com POST /v1/finance/receivables.
  5. 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
Nunca mande a chave na URL. Só no header. Query string aparece em log de proxy, de navegador e de CDN — e é assim que chave vaza. Por isso a API responde 403 em HTTP em vez de redirecionar para HTTPS: o redirect já teria vazado o token.

Escopos

Cada chave carrega só o que você marcou. Faltou escopo, a resposta é 403 insufficient_scope dizendo qual falta.

EscopoDá acesso a
catalog:read / catalog:writeprodutos, serviços e categorias
customers:read / customers:writeclientes
suppliers:read / suppliers:writefornecedores
finance:read / finance:writecontas a receber e a pagar
sales:readler vendas e as formas de pagamento da empresa
sales:writeregistrar e cancelar vendas
webhooks:managecadastrar 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"
  }
}
HTTPcodeO que fazer
400malformed_jsono corpo não é JSON válido
401missing_token / invalid_tokenchave ausente, revogada ou expirada — gere outra no painel
402plan_requireda conta não tem o adicional de API ativo
403insufficient_scopemarque o escopo na chave (o erro diz qual)
404not_foundnão existe ou é de outra empresa — nunca dizemos qual dos dois
409duplicate_resourcebateu num índice único; veja o 409 de cliente abaixo
409idempotency_key_reuseda mesma Idempotency-Key com outro corpo
409managed_by_pdvregistro mantido pelo sistema (conta gerada por venda, venda feita no balcão): leitura sim, escrita não
409fiscal_document_issueda venda tem nota autorizada — o cancelamento fiscal é feito pelo assinante, no sistema
422validation_failedcampo inválido ou desconhecido
422insufficient_stockfaltou estoque: a venda inteira foi recusada e nada foi debitado
422payment_mismatcha soma dos pagamentos não fecha com o total da venda
429rate_limitedrespeite o Retry-After
Campo desconhecido é recusado de propósito. Se você mandar 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
O que foi apagado não aparece em lista nenhuma. Para descobrir exclusões, chame 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étodoRotaEscopo
GET/me
GET/catalog/items · /catalog/items/{id} · /catalog/items/deleted · /catalog/categoriescatalog:read
POST/catalog/items · /catalog/items/batch · /catalog/categoriescatalog: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} · /deletedfinance:read
POST/finance/receivables · /batch · /{id}/settlefinance:write
PATCH/finance/receivables/{id}finance:write
GET/finance/summary?from=&to= · /finance/payment-methodsfinance:read
GET/sales · /sales/{id} · /sales/by-external/{external_id} · /sales/payment-typessales:read
POST/sales · /sales/{id}/cancelsales:write
GET/webhooks · /webhooks/{id}/deliverieswebhooks:manage
POST/webhooks · /webhooks/{id}/testwebhooks:manage

Financeiro — o que você precisa saber antes

Três regras que evitam surpresa:

  • paid_at manda. O painel do assinante trabalha em regime de caixa: uma baixa com data retroativa entra no mês certo. Baixa sem paid_at usa o momento atual.
  • Não existe baixa parcial. O amount na 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 respondem 409 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 payments fecha com o total. Pagou a menos, é 422 payment_mismatch; para saldo em aberto, lance um recebível em POST /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, requested e available.
  • sold_at define 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_price e não mexe em estoque. Cada item usa um identificador: catalog_item_id, catalog_item_external_id ou description.
  • 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.
Reenviou e não sabe se entrou? Repetir com a mesma 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.

EventoQuando dispara
customer.created / customer.updatedcliente criado ou alterado (por qualquer caminho)
catalog_item.created / catalog_item.updatedproduto ou serviço
receivable.createdconta a receber lançada
receivable.settled / payable.settledconta baixada
sale.completed / sale.cancelledvenda 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);
Responda 2xx rápido e processe depois. Tudo que não for 2xx conta como falha e entra na fila de reentrega: 1 min → 5 min → 30 min → 2 h → 6 h. Esgotadas as tentativas, o endereço fica marcado como "sem resposta" no painel do assinante, e 24 h assim ele é desligado automaticamente (com e-mail avisando).
O evento não volta para quem o causou. Se você criar um cliente por 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.
Webhook não substitui o 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
Requisitos: PHP 8.0+ com cURL. Não usa Composer nem nenhuma biblioteca externa — é um arquivo só, de propósito, para caber em qualquer servidor. Se preferir montar o seu cliente, o OpenAPI gera um pronto na linguagem que você quiser.

Limites

Requisições120 por minuto e 50.000 por dia, por empresa (somando todas as chaves)
Itens por lote500 — e o lote conta como 1 requisição
Itens por página50 por padrão, 200 no máximo
Tamanho do corpo1 MB
Chaves ativas5 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.