PAPI de Resgate · v1

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.

BASE  https://pertroca.com.br/api/v1  ·  Authorization: Bearer <chave>  ·  JSON

1 · COMO FUNCIONA

Trinta segundos de contexto

  1. O cliente tem crédito na carteira Pertroca (ex.: R$ 100 no CER Gastronomia).
  2. Na hora de pagar, ele toca em Código pra pagar no app → código de 6 dígitos, válido por 5 minutos.
  3. Ele cola no campo cupom do seu sistema (ou fala pro caixa).
  4. Seu sistema chama POST /validate → recebe o saldo → mostra o desconto.
  5. Com o pedido confirmado, chama POST /redeem → a Pertroca debita.
  6. 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

3 · TESTE A CHAVE

POST/ping
curl -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

POST/validate— não mexe em dinheiro
curl -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
codeobrigatório
orderIdrecomendadoamarra o código a este pedido por 30 min (§7)
amountopcionaltotal 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": "…" }

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." }
reasonSignifica
invalid_codenão existe
expiredvalidade venceu (5 min livre / 30 min amarrado), ou o crédito trocou de dono
usedjá resgatado
supersededo cliente gerou outro código depois
wrong_companycrédito de outro estabelecimento
bound_to_other_orderjá amarrado a outro pedido
no_balancecarteira sem saldo
not_supportedtipo 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

POST/redeem

Chame 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, amountobrigatóriosamount = valor a abater
expectedDebitopcionaltudo-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)

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).

  1. cliente cola 472913 no carrinho
  2. POST /validate { code, orderId, amount: total } → mostra "−R$ 100"
  3. cliente paga a diferença (cartão/Pix)
  4. pedido confirmado
  5. POST /redeem { code, orderId, amount: 100, expectedDebit: 100 }
  6. 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 veioPor quêO que fazer
usedo código já foi resgatado — em geral pelo próprio cliente no balcão, ou por outro canalrecuse o desconto: cobre cheio com aviso. Exceção (5xx + 404 na consulta) no §5
supersededcliente gerou outro códigorecuse o desconto: cobre cheio com aviso, ou peça o código novo
expireda validade venceu: 5 min se você não amarrou o código com /validate, 30 min se amarrouidem
bound_to_other_ordero 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 minnã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_balancesaldo acabou no intervaloidem
balance_changedusou expectedDebit e o saldo mudouidem — nada foi debitado
success:true com debited menorsaldo 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

HTTPerrorO que fazer
400bad_requestdetails diz o campo. Inclui código malformado, amount/orderId fora do formato. Não repita sem corrigir
401invalid_keychave errada, regenerada, revogada ou ainda não emitida
403key_not_activestatus no corpo. Se vier suspended, a chave foi suspensa e não volta sozinha — veja a escalada abaixo
403key_blockedbloqueio temporário anti-chute; blockedUntil no corpo. Esse expira sozinho — mas escala, veja abaixo
403company_inactiveloja desativada
404not_found / redemption_not_foundrota errada / GET ?orderId sem registro
405method_not_alloweduse POST/GET conforme a rota
429rate_limitedespere 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)
500internalrepita 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

GET/redemptions
curl "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 orderId404 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

  1. A loja emite um crédito de R$ 1,00 para o CPF de alguém da equipe (painel → Novo Crédito).
  2. Essa pessoa abre a carteira → Código pra pagar.
  3. Rode /validate e /redeem com amount: 1.00 e um orderId novo a cada execução — ex.: TESTE-2026-09-02-1911. Reaproveitar TESTE-001 faz a segunda rodada devolver o recibo da primeira (alreadyProcessed: true) sem debitar nada, e você "testa" um sucesso falso.
  4. Confira o débito no painel (Histórico) e em GET /redemptions?orderId=<o que você usou>.
  5. Estorne pelo painel pra fechar o ciclo.

13 · CHECKLIST DE HOMOLOGAÇÃO

Antes de ir pra produção

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.