API de Resgate Pertroca
Aceite crédito Pertroca como pagamento no seu delivery, cardápio digital ou PDV. Leitura de 15 minutos; integração típica de 2 a 4 horas.
1 · COMO FUNCIONA
Trinta segundos de contexto
- O cliente tem crédito na carteira Pertroca (ex.: R$ 100 no CER Gastronomia).
- Na hora de pagar, ele toca em Código pra pagar no app → código de 6 dígitos, válido por 5 minutos.
- Ele cola no campo cupom do seu sistema (ou fala pro caixa).
- Seu sistema chama
POST /validate→ recebe o saldo → mostra o desconto. - Com o pedido confirmado, chama
POST /redeem→ a Pertroca debita. - Custa mais que o saldo? Você cobra a diferença normalmente.
Pedido: R$ 120,00
Crédito Pertroca: −R$ 100,00
A pagar (cartão/Pix): R$ 20,00
Regra de ouro da conciliação: o número que vale é sempre o debited da resposta — nunca o que você pediu, nunca o que mostrou na tela.
2 · ANTES DE COMEÇAR
Peça e guarde a chave
No painel Pertroca da loja: Integrações → Solicitar acesso à API (canal, unidade, sistema, contato técnico). A Pertroca aprova; a loja gera a chave no painel. Limite de 10 chaves por empresa — revogue as que saíram de uso.
Uma chave = um canal de uma unidade (delivery + 3 PDVs = 4 chaves). Códigos amarrados a um pedido ficam presos à chave que validou — não valide no Delivery e resgate no PDV.
prt_live_K7M2P9XR_9fJ2kL8mNq3RtV5wXy7Za1Bc4De6Fg8H
────┬───
identificador público (aparece nos logs)
A chave é segredo. Exibida uma única vez; guarde em variável de ambiente/cofre.
Nunca no front-end. Site estático (Cloudflare Pages etc.)? A chamada passa por um backend seu (Worker/Function). A API bloqueia navegador de propósito (sem CORS).
Vazou/perdeu? Regenerar no painel — a antiga morre na hora (troque em horário de baixo movimento).
Formatos
amount: número (ou string numérica), > 0, máximo 2 casas decimais, em reais.1.005→400.orderId:A-Z a-z 0-9 . _ : -, 1 a 64 caracteres, sensível a maiúsculas. Um por pedido, nunca reutilizado.code: string de exatamente 6 dígitos. Tire espaços antes de enviar ("472 913"→"472913").
3 · TESTE A CHAVE
/pingcurl -X POST https://pertroca.com.br/api/v1/ping \
-H "Authorization: Bearer $PERTROCA_KEY"
{ "ok": true, "keyId": "K7M2P9XR",
"company": { "id": "…", "name": "CER Gastronomia" },
"label": "Delivery Chez Rejala", "restaurant": "CHEZ REJALA DELIVERY" }
4 · VALIDAR
/validate— não mexe em dinheirocurl -X POST https://pertroca.com.br/api/v1/validate \
-H "Authorization: Bearer $PERTROCA_KEY" -H "Content-Type: application/json" \
-d '{ "code": "472913", "orderId": "PED-2026-000481", "amount": 120.00 }'
| Campo | ||
|---|---|---|
code | obrigatório | |
orderId | recomendado | amarra o código a este pedido por 30 min (§7) |
amount | opcional | total que você quer abater; a resposta devolve applicable/shortfall prontos |
Código válido:
{ "valid": true, "balance": 100.00, "applicable": 100.00, "shortfall": 20.00,
"company": { "id": "…", "name": "CER Gastronomia" },
"customer": { "firstName": "Victor" },
"code": { "expiresAt": "2026-09-02T19:02:00.000Z", "boundTo": "PED-2026-000481" },
"requestId": "…" }
code.expiresAté a validade efetiva pra você: comorderId, já reflete os 30 min da amarração.- Re-validar o mesmo pedido é permitido e não estende o prazo.
- Guarde
applicable— é o desconto acordado que você manda no/redeem. - Sempre mande o
orderId. Sem ele, um código já amarrado a outro pedido seu ainda respondevalid: true— e o/redeemdepois recusa. ComorderId, você descobre no validate (bound_to_other_order) e não promete desconto que não vai sair. customer.firstNametraz só o primeiro nome, pra conferência no balcão — é tudo que a API expõe do cliente.
Código inválido (HTTP 200 — resultado normal, não erro):
{ "valid": false, "reason": "expired",
"message": "Código expirado. Peça ao cliente para gerar outro na carteira." }
| reason | Significa |
|---|---|
invalid_code | não existe |
expired | validade venceu (5 min livre / 30 min amarrado), ou o crédito trocou de dono |
used | já resgatado |
superseded | o cliente gerou outro código depois |
wrong_company | crédito de outro estabelecimento |
bound_to_other_order | já amarrado a outro pedido |
no_balance | carteira sem saldo |
not_supported | tipo de voucher fora desta API |
message já vem em português, pronta pra tela. reason desconhecido (o enum pode crescer) → trate como falha genérica de cupom.
"Peça outro código" não é infinito: a carteira permite cerca de 10 gerações a cada 10 minutos por cliente/loja. Num atendimento normal sobra folga, mas não construa um fluxo que peça código novo em laço.
5 · DEBITAR
/redeemChame com o pedido confirmado — e envie como amount o desconto acordado (min(total, applicable) do validate), não o total do pedido:
curl -X POST https://pertroca.com.br/api/v1/redeem \
-H "Authorization: Bearer $PERTROCA_KEY" -H "Content-Type: application/json" \
-d '{ "code": "472913", "orderId": "PED-2026-000481",
"amount": 100.00, "expectedDebit": 100.00 }'
| Campo | ||
|---|---|---|
code, orderId, amount | obrigatórios | amount = valor a abater |
expectedDebit | opcional | tudo-ou-nada: saldo mudou e não cobre esse valor → nada é debitado, vem reason:"balance_changed" |
{ "success": true, "redemptionId": "K7M2P9XR_PED-2026-000481",
"requested": 100.00, "debited": 100.00, "shortfall": 0.00,
"balanceAfter": 0.00, "alreadyProcessed": false,
"requestId": "9b1b66c2-3f68-4d1e-839f-679438cb7bc5" }
⚠ O orderId tem que ser único pra sempre — nunca reaproveite.
O orderId é a identidade da transação: o servidor guarda um recibo por chave + orderId e, se você mandar o mesmo de novo na mesma chave, ele devolve o recibo antigo em vez de debitar. Isso é o que torna o retry seguro — e é também a armadilha:
Se o seu PDV usa número de mesa, comanda ou senha (MESA-7, 101) e esse número se repete amanhã, o segundo cliente recebe success: true com debited: 40.00 e o crédito dele não é tocado. Seu sistema concede o desconto, a Pertroca não debitou ninguém: prejuízo direto.
Use um identificador único de verdade — o id interno do pedido, ou algo como 2026-09-02-MESA-7-a3f1. Se o mesmo pedido pode ser pago em partes por pessoas diferentes, cada pagamento é um orderId diferente.
O cliente lê esse texto. O orderId aparece na notificação de débito no celular dele ("Pedido <orderId> · Saldo restante: …"). Use algo que ele reconheça como o pedido dele; nunca dado interno sensível. Aceita letras, números e . _ : -, até 64 caracteres.
E trate alreadyProcessed: true com cuidado: ele significa "esse orderId já tinha sido liquidado". Isso é esperado em qualquer retry seu (inclusive o retry após 5xx que o §9 manda fazer) — nesse caso o débito É seu: registre o pagamento normalmente. Só é colisão quando chega na primeiríssima chamada de um orderId que seu sistema nunca tinha visto: aí o número colidiu com um pedido antigo e o desconto não deve ser concedido. Para separar os dois casos você precisa de duas informações locais: o orderId era inédito? e houve retry nesta execução? (veja o §11).
O recibo vale por chave. Se a loja tem várias chaves (delivery + PDVs), repetir o mesmo orderId em outra chave debita de novo — a proteção não atravessa canais. Retry é sempre pela mesma chave que fez a primeira chamada; nunca refaça a venda em outro canal reaproveitando o orderId.
Garantias (atômicas no servidor)
- Débito, invalidação do código e recibo do
orderIdacontecem na mesma transação. Não existe estado intermediário. - Retry é seguro em qualquer cenário, inclusive após
500/timeout: reenvie o mesmoorderIde receba a resposta original comalreadyProcessed: true— que pode chegar sem você nunca ter visto um 2xx antes. É o comportamento esperado. success:falsenão consome o pedido: o mesmoorderIdpode tentar de novo (ex.: código novo apóssuperseded).reason:"used"quase sempre significa que o cliente gastou esse mesmo código em outro lugar (no balcão da loja, ou em outro canal) — o crédito saiu, mas não pra você. Trate como recusa: cobre o valor cheio com aviso (§8). A exceção é virusedlogo depois de um5xxno mesmoorderIde a consultaGET /redemptions?orderId=devolver404: aí sim guarde orequestIde abra suporte antes de cobrar.- Depois do sucesso o código está morto. Pedido novo = código novo. Nunca guarde códigos pra reutilizar.
6 · PDV / BALCÃO
O caso simples
"Tem crédito Pertroca?" → cliente gera e dita: "472 913"
Operador digita → POST /redeem { code, orderId: <id da venda>, amount: <total> }
→ debited: 100.00, shortfall: 20.00 → cobra R$ 20 no cartão
Tem leitor 2D no PDV? O app também mostra o código como QR: bipe e o leitor digita os 6 dígitos sozinho no campo — sem app da Pertroca, sem digitação.
No balcão pode mandar o total direto como amount — o pagamento é na hora e shortfall diz o que falta. Lance debited como forma de pagamento "Pertroca" no PDV (não como "dinheiro", senão o fechamento do operador não bate).
Neste fluxo o código vale 5 minutos — sem /validate não há amarração, e é a amarração que estica pra 30 min (§7). Numa fila, o código ditado no início pode vencer antes de chegar a vez: se vier expired, é só o cliente tocar em Código pra pagar de novo. Se o seu PDV mostra o desconto antes de fechar a venda, chame /validate com o orderId da venda — aí você ganha os 30 min e descobre problemas antes de prometer desconto.
7 · DELIVERY
Código amarrado ao pedido
Entre "aplicou o cupom" e "pedido confirmado" existe um intervalo — por isso a amarração: /validate com orderId prende o código a este pedido por 30 minutos (prazo fixo, não renova). Nenhuma outra chave ou outro pedido resgata esse código pela API.
Amarrar não é reservar saldo. O código continua valendo no caixa físico da loja — a amarração vale entre canais integrados, não contra o balcão. E ela estende a vida do código: sem amarração ele morre em 5 min; amarrado, segue utilizável no balcão pelos 30 min. O crédito também pode ser transferido ou gasto pelo cliente nesse intervalo (§8).
- cliente cola
472913no carrinho POST /validate { code, orderId, amount: total }→ mostra "−R$ 100"- cliente paga a diferença (cartão/Pix)
- pedido confirmado
POST /redeem { code, orderId, amount: 100, expectedDebit: 100 }success:false→ §8. Não confirme desconto que não foi debitado.
O passo 3 acontece antes do passo 5 — e é aí que mora o prejuízo. Você já recebeu do cliente só a diferença (R$ 20) quando descobre, no /redeem, que os R$ 100 não saíram. Aja assim:
Não libere o pedido pra produção/entrega antes do /redeem voltar com success: true. Chame o /redeem imediatamente após o pagamento do restante aprovar — de preferência na mesma rotina, antes de confirmar o pedido pro cliente. Deu success:false: o pedido ainda não saiu, então você cobra a diferença que faltou (§8) ou cancela, em vez de entregar comida já paga a menor.
Quanto menor a janela entre validate e redeem, menor a chance do §8.
Pedido cancelado depois do redeem: a loja estorna no painel Pertroca (Histórico do crédito → Estornar). Sem endpoint de estorno no v1.
8 · CASOS QUE EXIGEM ATENÇÃO
O crédito continua vivo entre validate e redeem
O cliente pode gastar no caixa físico, transferir, ou o saldo pode vencer. Sem "reserva" — por desenho. Consequências possíveis no /redeem:
| O que veio | Por quê | O que fazer |
|---|---|---|
used | o código já foi resgatado — em geral pelo próprio cliente no balcão, ou por outro canal | recuse o desconto: cobre cheio com aviso. Exceção (5xx + 404 na consulta) no §5 |
superseded | cliente gerou outro código | recuse o desconto: cobre cheio com aviso, ou peça o código novo |
expired | a validade venceu: 5 min se você não amarrou o código com /validate, 30 min se amarrou | idem |
bound_to_other_order | o código está amarrado a outro pedido — clássico: o cliente aplicou o cupom no delivery, desistiu e veio ao balcão. Dura até 30 min | não cobre cheio direto: peça ao cliente pra tocar em "Gerar novo" na carteira e use o código novo — ou finalize pelo caixa Pertroca, que aceita o código amarrado (§7) |
no_balance | saldo acabou no intervalo | idem |
balance_changed | usou expectedDebit e o saldo mudou | idem — nada foi debitado |
success:true com debited menor | saldo caiu parcialmente (sem expectedDebit) | desconto parcial: cobre a diferença com consentimento ou estorne via loja |
Enviando amount = applicable + expectedDebit, os dois últimos casos viram um só e binário. É o fluxo recomendado pro delivery.
9 · ERROS DE REQUEST
HTTP ≠ 200
| HTTP | error | O que fazer |
|---|---|---|
| 400 | bad_request | details diz o campo. Inclui código malformado, amount/orderId fora do formato. Não repita sem corrigir |
| 401 | invalid_key | chave errada, regenerada, revogada ou ainda não emitida |
| 403 | key_not_active | status no corpo. Se vier suspended, a chave foi suspensa e não volta sozinha — veja a escalada abaixo |
| 403 | key_blocked | bloqueio temporário anti-chute; blockedUntil no corpo. Esse expira sozinho — mas escala, veja abaixo |
| 403 | company_inactive | loja desativada |
| 404 | not_found / redemption_not_found | rota errada / GET ?orderId sem registro |
| 405 | method_not_allowed | use POST/GET conforme a rota |
| 429 | rate_limited | espere Retry-After e repita — não é recusa: nunca cobre o cliente cheio por causa de um 429. Limites: 30/min (validate+redeem), 60/min (ping+GET) |
| 500 | internal | repita com backoff reaproveitando o mesmo orderId; guarde o requestId |
2xx = a Pertroca respondeu; olhe valid/success. 4xx = a chamada não foi aceita e nada mudou. 5xx é diferente: o débito pode ter acontecido — reenvie o mesmo orderId (§5) ou consulte GET /redemptions?orderId= antes de decidir qualquer coisa. Nunca cobre o cliente por conta própria depois de um 5xx sem consultar.
requestId vem no corpo de TODA resposta (200, 4xx e 5xx). Grave junto do pedido: é a chave de rastreio que o suporte da Pertroca vai pedir.
Proteção anti-chute: o que realmente conta. Validar ^\d{6}$ antes de chamar economiza viagem, mas não protege sua chave: código malformado vira 400 e nem entra na conta.
O que conta são os 6 dígitos bem-formados que dão invalid_code ou wrong_company — e esse segundo é rotineiro: é o cliente colando um código Pertroca legítimo de outro estabelecimento. 20 dessas falhas em 10 minutos bloqueiam a chave. Um /validate bem-sucedido zera o contador.
Escalada: os bloqueios dobram (15 → 30 → 60 min) e o 4º em 24 h suspende a chave. Aí o erro vira 403 key_not_active com status: "suspended", sem blockedUntil, e não expira: nem a loja consegue regenerar — só a Pertroca reativa. Monitore 403 em série e pare de chamar antes disso.
10 · CONCILIAÇÃO
/redemptionscurl "https://pertroca.com.br/api/v1/redemptions?orderId=PED-2026-000481" \
-H "Authorization: Bearer $PERTROCA_KEY"
curl "https://pertroca.com.br/api/v1/redemptions?date=2026-09-02" \
-H "Authorization: Bearer $PERTROCA_KEY"
As duas formas devolvem envelopes DIFERENTES. Não reaproveite o mesmo parser.
?orderId= — consulta de UM pedido. Resposta plana, sem count e sem array:
{ "redemptionId": "K7M2P9XR_PED-2026-000481", "orderId": "PED-2026-000481",
"requested": 100.00, "debited": 100.00, "shortfall": 0.00, "balanceAfter": 0.00,
"createdAt": "2026-09-02T18:41:07.312Z",
"requestId": "…" }
Sem registro pra esse orderId → 404 redemption_not_found (nunca count: 0). É a consulta certa pra "será que aquele débito passou?" depois de um 5xx.
?date= — dia inteiro (data no horário de Brasília). Resposta com envelope:
{ "count": 12, "redemptions": [
{ "redemptionId": "K7M2P9XR_PED-2026-000481", "orderId": "PED-2026-000481",
"requested": 100.00, "debited": 100.00, "shortfall": 0.00, "balanceAfter": 0.00,
"createdAt": "2026-09-02T18:41:07.312Z" } ] }
Teto de 500 registros por dia, sem paginação no v1. Passando disso, o corte não é cronológico e não há aviso no corpo: você perde registros em silêncio. Trate count === 500 como possível truncamento e feche o dia pelo painel da loja. Para um pedido específico use sempre ?orderId=, que não tem esse limite.
Escopo: somente a sua chave — resgates feitos no caixa físico da Pertroca ou por outra integração não aparecem aqui. A soma de debited é o bruto em crédito Pertroca do seu canal; estornos feitos no painel não aparecem no v1. Datas sempre ISO-8601 UTC com milissegundos; o parâmetro date é a exceção (YYYY-MM-DD, fuso de Brasília).
11 · EXEMPLO COMPLETO
Node.js
const BASE = 'https://pertroca.com.br/api/v1';
const KEY = process.env.PERTROCA_KEY; // nunca no front-end
async function pertroca(path, body, metodo = 'POST', tentativa = 0) {
const r = await fetch(BASE + path, { method: metodo,
headers: { 'Authorization': `Bearer ${KEY}`, 'Content-Type': 'application/json' },
body: metodo === 'GET' ? undefined : JSON.stringify(body) });
const json = await r.json().catch(() => ({}));
if (r.status === 429 && tentativa < 1) { // espera e tenta 1x — só uma
await new Promise(s => setTimeout(s, (Number(r.headers.get('retry-after')) || 60) * 1000));
return pertroca(path, body, metodo, tentativa + 1);
}
if (!r.ok) throw Object.assign(new Error(json.error || `http_${r.status}`),
{ detail: json, status: r.status, requestId: json.requestId });
return json;
}
// orderId ÚNICO PRA SEMPRE — nunca número de mesa/comanda que se repete
const orderId = pedido.id; // id interno, jamais reciclado
// 1) cupom no carrinho
const digits = (cupom || '').replace(/\D/g, '');
if (!/^\d{6}$/.test(digits)) return mostrarErro('Código inválido');
const v = await pertroca('/validate', { code: digits, orderId, amount: pedido.total });
if (!v.valid) return mostrarErro(v.message);
const acordado = v.applicable; // ex.: 100.00
mostrarDesconto(acordado, v.shortfall); // "−R$100 · a pagar R$20"
// 2) …cliente paga o restante, pedido confirmado…
// 3) débito — amount = acordado, tudo-ou-nada.
// Marque o orderId ANTES, com insert-if-absent atômico (create() no Firestore,
// INSERT ... ON CONFLICT DO NOTHING no SQL): `true` só se ele nunca existiu aqui.
const orderIdInedito = await marcarTentativaRedeem(orderId);
let houveRetry = false; // virou true se repetimos a chamada
const corpo = { code: digits, orderId, amount: acordado, expectedDebit: acordado };
let d;
try {
d = await pertroca('/redeem', corpo);
} catch (e) {
// 429 NÃO é recusa: é "tente de novo". Nunca cobre cheio por causa dele —
// no pico do almoço isso viraria cliente com crédito pagando a conta inteira.
if (e.status === 429) return tentarDeNovoMaisTarde(pedido, orderId);
// 401/403 = problema da CHAVE (revogada, bloqueada, loja inativa), não do cupom
// deste cliente. key_blocked se cura sozinho em 15–60 min (veja `blockedUntil`).
// Cobrar cheio aqui puniria todo cliente com crédito durante o bloqueio.
if (e.status === 401 || e.status === 403)
return segurarPedidoEAlertar(pedido, orderId, e.requestId);
// Demais 4xx = recusado, NÃO repita (repetir não muda nada e mascara o erro).
// Corpos 4xx trazem `error` (código técnico), não `message`.
if (e.status && e.status < 500) return descontoRecusado(pedido, e.detail?.error);
// 5xx/rede = incerto: o débito PODE ter acontecido. Repita o MESMO orderId…
houveRetry = true;
try { d = await pertroca('/redeem', corpo); }
catch (e2) {
// …e se ainda assim não souber, PERGUNTE em vez de adivinhar:
try { d = { ...await pertroca(`/redemptions?orderId=${encodeURIComponent(orderId)}`, null, 'GET'), success: true }; }
catch (e3) {
// 404 aqui NÃO prova que não debitou: pode ser corrida (o débito ainda
// não apareceu). Espere e pergunte de novo antes de concluir.
if (e3.status === 404) {
await new Promise(s => setTimeout(s, 3000));
try { d = { ...await pertroca(`/redemptions?orderId=${encodeURIComponent(orderId)}`, null, 'GET'), success: true }; }
catch (e4) {
if (e4.status === 404) return descontoRecusado(pedido, 'credito_nao_confirmado');
return segurarPedidoEAlertar(pedido, orderId, e4.requestId);
}
} else return segurarPedidoEAlertar(pedido, orderId, e3.requestId);
}
}
}
if (!d.success) {
// `used` = o crédito saiu, mas não pra você (cliente gastou no balcão/outro canal)
if (d.reason === 'used') return descontoRecusado(pedido, 'codigo_ja_usado'); // §5, §8
return descontoRecusado(pedido, d.message); // §8 — sem desconto fantasma
}
// alreadyProcessed depois de um retry nosso = replay esperado, siga em frente.
// alreadyProcessed na PRIMEIRÍSSIMA chamada de um orderId inédito = colisão (§5):
// esse número já foi liquidado antes, o débito de agora não existe.
if (d.alreadyProcessed && orderIdInedito && !houveRetry)
return alertarColisaoDeOrderId(pedido, d);
registrarPagamentoPertroca(pedido, d.debited); // concilie por ESTE número
12 · TESTE DE PONTA A PONTA
Sem sandbox — ensaio real barato
- A loja emite um crédito de R$ 1,00 para o CPF de alguém da equipe (painel → Novo Crédito).
- Essa pessoa abre a carteira → Código pra pagar.
- Rode
/validatee/redeemcomamount: 1.00e umorderIdnovo a cada execução — ex.:TESTE-2026-09-02-1911. ReaproveitarTESTE-001faz a segunda rodada devolver o recibo da primeira (alreadyProcessed: true) sem debitar nada, e você "testa" um sucesso falso. - Confira o débito no painel (Histórico) e em
GET /redemptions?orderId=<o que você usou>. - Estorne pelo painel pra fechar o ciclo.
13 · CHECKLIST DE HOMOLOGAÇÃO
Antes de ir pra produção
- Chave em variável de ambiente; nenhuma chamada sai do navegador
^\d{6}$validado no seu lado antes de chamar a API/pingresponde a loja certa- orderId único pra sempre — nunca número de mesa/comanda reciclado (§5)
alreadyProcessed: truena primeiríssima chamada de umorderIdinédito dispara alerta (colisão); depois de um retry seu, é replay esperado e o pagamento é registrado- 4xx não é re-tentado; 5xx re-tenta com o MESMO
orderIde, na dúvida, consulta?orderId= requestIdgravado junto de cada pedido/redeemsó com pedido confirmado, comamount = applicableeexpectedDebit(delivery)- Retry do mesmo
orderId→alreadyProcessed: true, sem débito duplo - Todos os
success:falsedo §8 tratados sem desconto fantasma debited < acordadotratado (se optar por não usarexpectedDebit)debitedlançado como forma de pagamento "Pertroca"429esperaRetry-After;500re-tenta com o mesmoorderIdorderIdúnico por pedido; códigos nunca armazenados pra reuso
Dúvidas: pertrocabr@gmail.com · v1.1 — setembro/2026. Contrato aditivo: campos e valores de reason novos podem surgir; nada existente muda de nome, tipo ou semântica dentro da v1. Trate valores desconhecidos genericamente.