# Vexa Pagamentos — Documentação completa

> Software de checkout single-merchant. CNPJ 51.848.548/0001-27.
> PIX, cartão (até 12x), boleto.

Versão otimizada pra IA. Tudo em markdown, sem assets binários.

---

## Posicionamento

A Vexa Pagamentos NÃO é gateway aberto, sub-adquirente ou IP regulada
pelo BCB. Operamos como software de checkout de uso interno do CNPJ
51.848.548/0001-27. Não há auto-cadastro, signup público ou emissão
self-service de credenciais.

Tokens `sk_*` são emitidos manualmente pelo administrador e revogáveis
no painel `/integrations`.

---

## Base URL e auth

```
https://api.vexapagamentos.com/v1
```

Header obrigatório:

```
Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

Chave pública `pk_live_*` só identifica a loja em contextos de front
(checkout embed, tokenização de cartão) — não autentica API REST.

---

## Idempotência

Toda criação de recurso financeiro exige header:

```
Idempotency-Key: pedido-12345
```

Replay com a mesma chave em até 24h retorna a resposta original com
header `Idempotency-Replayed: true`. Mesma chave + body diferente = 409.

---

## Criar cobrança

`POST /v1/charges`

Métodos suportados via API REST:

- **PIX** — modo Hosted (`productId`) ou Gateway API (`amountCents` + `productName`).
- **Boleto** — modo Hosted ou Gateway API.
- **Cartão** — apenas modo Hosted; API devolve `checkout_url` pro comprador finalizar no Hosted Checkout. Cartão in-place (Gateway API) requer o SDK Vexa.js, em desenvolvimento.

### Convenção de campos

* **Request body**: camelCase (`productId`, `amountCents`, `productName`,
  `externalReference`).
* **Response body**: snake_case (`product_id`, `amount_cents`,
  `product_name`, `external_reference`, `qr_code`, `copy_paste_code`).
* **Customer**: `name`, `email`, `cpf`, `phone` (palavras únicas, mesmo
  em request e response).

Aceita **dois modos mutuamente exclusivos** — você escolhe um por request.

---

### Modo Hosted — produto cadastrado na Vexa

Você cadastrou o produto no painel `/products`; o preço vem de lá. O
checkout pode ser hospedado pela Vexa (em `https://checkout.vexapagamentos.com/<slug>`) ou criado via API
e renderizado por você.

**Request**:

```bash
curl -X POST https://api.vexapagamentos.com/v1/charges \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: pedido-12345" \
  -d '{
    "productId": "550e8400-e29b-41d4-a716-446655440000",
    "method": "pix",
    "customer": {
      "name": "João da Silva",
      "email": "joao@exemplo.com",
      "cpf": "12345678901",
      "phone": "11999998888"
    }
  }'
```

```typescript
import { fetch } from 'undici';

const res = await fetch('https://api.vexapagamentos.com/v1/charges', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.VEXA_SECRET_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': 'pedido-12345',
  },
  body: JSON.stringify({
    productId: '550e8400-e29b-41d4-a716-446655440000',
    method: 'pix',
    customer: {
      name: 'João da Silva',
      email: 'joao@exemplo.com',
      cpf: '12345678901',
    },
  }),
});

const charge = await res.json();
console.log(charge.pix.qr_code);
```

---

### Modo Gateway API — você tem checkout próprio

Pra SaaS, e-commerce custom, marketplace: você gerencia produtos no seu
sistema e usa a Vexa só como adquirente. Sem cadastrar produto na Vexa.

**Pré-requisito**: a API key precisa ter o scope `charges:api_first`
liberado. Solicite a habilitação no painel (suporte@vexapagamentos.com).
Sem esse scope, a request retorna `403 capability_required`.

**Request**:

```bash
curl -X POST https://api.vexapagamentos.com/v1/charges \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: pedido-saas-789" \
  -d '{
    "amountCents": 9990,
    "productName": "Plano Pro Mensal",
    "method": "pix",
    "externalReference": "order_42",
    "metadata": {
      "plan": "pro",
      "coupon": "BLACK50",
      "userId": "user_abc"
    },
    "customer": {
      "name": "João da Silva",
      "email": "joao@exemplo.com",
      "cpf": "12345678901"
    }
  }'
```

* `amountCents` (1..100000000) e `productName` (1..255) são obrigatórios.
* `externalReference` (max 255): ID do pedido no SEU sistema. Ecoa em
  todo webhook desta charge — use pra casar `charge.paid` com pedido.
* `metadata`: até 20 chaves (max 40 chars/chave, 500 chars/valor).
  Também ecoa em todo webhook.

**Não misture modos**: enviar `productId` JUNTO com `amountCents` retorna
400. Mesmo pra `productName`, `externalReference`, `metadata` — só são
aceitos no modo Gateway API.

---

### Response — PIX (ambos modos)

```json
{
  "id": "01900000-0000-7000-8000-000000000000",
  "object": "charge",
  "product_id": null,
  "product_name": "Plano Pro Mensal",
  "external_reference": "order_42",
  "metadata": { "plan": "pro", "coupon": "BLACK50", "userId": "user_abc" },
  "amount_cents": "9990",
  "currency": "BRL",
  "method": "pix",
  "status": "pending",
  "external_id": "12345678",
  "qr_code": "00020126360014BR.GOV.BCB.PIX...",
  "qr_code_image_url": "data:image/png;base64,...",
  "copy_paste_code": "00020126360014BR.GOV.BCB.PIX...",
  "expires_at": "2026-05-11T15:30:00Z"
}
```

No modo Hosted, `product_id` traz o UUID e `external_reference` /
`metadata` ficam `null`.

---

### Erros comuns

| HTTP | `error` | Quando |
| ---- | -------- | ------ |
| 400 | `invalid_input` | Body inválido OU misturou modos OU `productName` faltou no Gateway API |
| 400 | `invalid_cpf` | CPF fora de 11/14 dígitos |
| 400 | `idempotency_key_required` | Header `Idempotency-Key` ausente ou < 8 chars |
| 403 | `capability_required` | API key não tem `charges:api_first` no modo Gateway API |
| 403 | `risk_rejected` | Antifraude bloqueou (velocity/score) |
| 404 | `product_not_found` | `productId` não existe ou não é seu |
| 409 | `idempotency_mismatch` | Mesma Idempotency-Key, body diferente |
| 502 | `acquirer_error` | Falha do adquirente (transitória; retry com nova chave) |

Validação Zod retorna `details: { formErrors, fieldErrors }`.

---

### Simular pagamento (sandbox)

`POST /v1/charges/{id}/simulate` — força transição de status sem pagar
de verdade. **Bloqueado em `sk_live_*`** (403 `forbidden_in_live`).

```bash
curl -X POST https://api.vexapagamentos.com/v1/charges/<chargeId>/simulate \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{ "to_status": "paid" }'
```

`to_status` aceita `"paid"` (default), `"failed"` ou `"refunded"`.
Dispara os mesmos webhooks de pagamento real (`charge.paid` etc),
ledger de carteira, audit log. Útil pra testar end-to-end sem PIX real.

---

### Rate limit

Response inclui:

```
X-RateLimit-Limit: <janela>
X-RateLimit-Remaining: <quanto sobra>
X-RateLimit-Reset: <unix ts>
Retry-After: <segundos>     // só em 429
```

---

## Cartão — via Hosted Checkout

Tokenização in-place via SDK próprio (Vexa.js) está em desenvolvimento.
Enquanto o SDK não fica pronto, cartão funciona pelo **Hosted Checkout**:
você cria a charge via API e redireciona o comprador pra URL que a
Vexa devolve. Ele paga no nosso painel (PCI scope nosso) e Vexa
redireciona ele de volta pra `returnUrl` que você passar.

Pré-requisito: o produto **precisa estar cadastrado** no painel em
`/products`. Cartão via Gateway API (sem productId) ainda retorna
`422 unsupported_method_in_gateway_api`.

### Request

```bash
curl -X POST https://api.vexapagamentos.com/v1/charges \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: pedido-12346" \
  -d '{
    "productId": "550e8400-e29b-41d4-a716-446655440000",
    "method": "card",
    "installments": 3,
    "returnUrl": "https://meu-saas.com/obrigado",
    "customer": {
      "name": "João da Silva",
      "email": "joao@exemplo.com",
      "cpf": "12345678901"
    }
  }'
```

### Response

```json
{
  "object": "checkout_session",
  "product_id": "550e8400-e29b-41d4-a716-446655440000",
  "product_name": "Plano Pro Mensal",
  "amount_cents": "19700",
  "currency": "BRL",
  "method": "card",
  "installments": 3,
  "checkout_url": "https://checkout.vexapagamentos.com/plano-pro?method=card&installments=3&return_url=...",
  "note": "Redirecione o comprador pra checkout_url. Status final via webhook charge.paid/charge.failed."
}
```

A charge real **só nasce quando o comprador submita o form** no Hosted
Checkout. O webhook `charge.paid` ou `charge.failed` chega então com
o `chargeId` real. Reconcilie com seu pedido pelo `external_reference`
não — o cartão hosted ainda não suporta `externalReference`; use webhook
`charge.paid` com `metadata.productId` ou data/hora do pedido pra casar.

### 3DS

Se o emissor exigir 3DS, o desafio acontece **dentro do hosted checkout**.
O comprador vê a tela do banco emissor, conclui, e o hosted redireciona
ele pra `returnUrl`. Status final só via webhook (não confie em
parâmetro de query da redirect — pode ser spoofed).

## Boleto — via API

PIX-style: cria e devolve linha digitável + PDF na resposta. Compensação
banco → Vexa em 1-2 dias úteis depois do pagamento.

### Request — modo Gateway API

```bash
curl -X POST https://api.vexapagamentos.com/v1/charges \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: pedido-12347" \
  -d '{
    "amountCents": 19700,
    "productName": "AdHunter Pro",
    "method": "boleto",
    "boletoDueDate": "2026-05-20",
    "boletoInstructions": "Não receber após o vencimento.",
    "externalReference": "order_42",
    "metadata": { "plan": "pro" },
    "customer": {
      "name": "João da Silva",
      "email": "joao@exemplo.com",
      "cpf": "12345678901"
    }
  }'
```

- `boletoDueDate` é opcional (YYYY-MM-DD). Default: hoje + 3 dias.
  Janela aceita: hoje+1 até hoje+30.
- `boletoInstructions` é opcional (max 200 chars). Default: nome do produto.

### Response

```json
{
  "id": "01900000-0000-7000-8000-000000000000",
  "object": "charge",
  "product_id": null,
  "product_name": "AdHunter Pro",
  "external_reference": "order_42",
  "metadata": { "plan": "pro" },
  "amount_cents": "19700",
  "currency": "BRL",
  "method": "boleto",
  "status": "pending",
  "external_id": "12345678",
  "boleto": {
    "digital_line": "23793.39001 60000.123456 78901.234567 8 90120000019900",
    "barcode": "23798901200000199003390016000012345678901234567",
    "pdf_url": "https://api.vexapagamentos.com/boleto/...pdf",
    "due_date": "2026-05-20T00:00:00.000Z",
    "instructions": "Não receber após o vencimento."
  }
}
```

### Boleto via modo Hosted (productId)

Substitua `amountCents` + `productName` por `productId` — preço, nome
e instructions (default) vêm do produto. Resto igual.

### Sandbox

Em `sk_test_*`, o PDF/linha digitável/barcode são mock — não pagáveis
em banco. Fecha o ciclo com `POST /v1/charges/{id}/simulate` body
`{ "to_status": "paid" }`.

---

## Consultar cobrança

`GET /v1/charges/{id}`

```bash
curl https://api.vexapagamentos.com/v1/charges/chg_01HW8K... \
  -H "Authorization: Bearer sk_live_..."
```

Status canônicos: `pending`, `authorized`, `paid`, `failed`,
`expired`, `refunded`, `partially_refunded`, `chargeback`.

Em vez de polling, **prefira webhooks**.

---

## Listar cobranças

`GET /v1/charges` — lista paginada das cobranças do operador
autenticado, ordenadas por `created_at DESC`. Use pra reconciliação
em batch ou consultas analíticas. Para acompanhar status em tempo
real, **continue usando webhooks** (não faça polling dessa rota).

```bash
# últimas 24h, só PIX pago, 50 por página
curl "https://api.vexapagamentos.com/v1/charges?status=paid&method=pix&since=24h&limit=50" \
  -H "Authorization: Bearer sk_live_..."
```

### Filtros

| Param | Valores | Descrição |
|---|---|---|
| `status` | CSV de statuses canônicos | Multi: `pending,paid,failed`. |
| `method` | `pix` \| `card` \| `boleto` | Único valor. |
| `since` | preset ou ISO 8601 | Presets: `24h`, `7d`, `30d`. ISO ex: `2026-04-01T00:00:00Z`. |
| `limit` | 1–100 (default 25) | Tamanho da página. |
| `cursor` | string opaca | Use o `next_cursor` da página anterior. Não construa manualmente. |

### Resposta

```json
{
  "data": [
    {
      "id": "chg_01HW8K...",
      "object": "charge",
      "product_id": "...",
      "amount_cents": "9900",
      "currency": "BRL",
      "method": "pix",
      "status": "paid",
      "external_id": "tran_...",
      "paid_at": "2026-05-07T12:34:56Z",
      "failed_at": null,
      "expires_at": "2026-05-07T13:04:56Z",
      "created_at": "2026-05-07T12:00:00Z",
      "pix": { "qr_code": "...", "copy_paste_code": "...", "expires_at": "..." },
      "customer": { "name": "João", "email": "joao@exemplo.com" }
    }
  ],
  "next_cursor": "MjAyNi0wNS0wN1QxMjowMDowMC4wMDBafGNoZ18..."
}
```

Quando `next_cursor` é `null`, acabou. Cursor é keyset estável
(`created_at + id`) — seguro contra inserts concorrentes.

---

## Capturar cartão pré-autorizado

`POST /v1/charges/{id}/capture`

Cobranças cartão em estado `authorized` (pré-autorização ou
pós-3DS) são capturadas com este endpoint. Idempotência opcional via
`Idempotency-Key`. Retorna a cobrança em estado `paid` ou erro 409
se não está em estado capturável.

---

## Webhooks

Cadastre URL HTTPS em `/integrations` (botão "Novo endpoint"). Cada
endpoint recebe um `whsec_*` próprio.

### Headers de entrega

```
POST https://seu-app.com/webhooks/vexa
X-Vexa-Signature: t=1715085600,v1=abc123def456...
X-Vexa-Event: charge.paid
Content-Type: application/json
```

### Eventos

| Evento | Quando |
|---|---|
| `charge.paid` | PIX confirmado, cartão capturado, boleto compensado |
| `charge.failed` | Cartão recusado, antifraude bloqueou |
| `charge.refunded` | Estorno total |
| `charge.chargeback` | Chargeback aberto pelo banco emissor |

### Payload

```json
{
  "id": "evt_01HW8K...",
  "type": "charge.paid",
  "livemode": true,
  "created_at": "2026-05-11T15:34:56Z",
  "data": {
    "charge": {
      "id": "01900000-0000-7000-8000-000000000000",
      "product_id": null,
      "product_name": "Plano Pro Mensal",
      "external_reference": "order_42",
      "metadata": {
        "subscription_id": "sub_xyz",
        "plan": "pro",
        "userId": "user_abc"
      },
      "amount_cents": "9990",
      "fee_cents": "699",
      "net_cents": "9291",
      "currency": "BRL",
      "status": "paid",
      "method": "pix",
      "installments": null,
      "card_brand": null,
      "card_last4": null,
      "external_id": "12345678",
      "failure_code": null,
      "failure_message": null,
      "created_at": "2026-05-11T15:30:02Z",
      "paid_at": "2026-05-11T15:34:50Z",
      "customer": {
        "name": "João da Silva",
        "email": "joao@exemplo.com",
        "phone": "+5511999990000"
      }
    }
  }
}
```

* `product_id` é `null` em charges modo Gateway API; UUID em modo Hosted.
* `product_name` é sempre presente — snapshot do nome na hora da venda.
* `external_reference` é o ID do pedido no SEU sistema (modo Gateway API).
  Use ele pra casar `charge.paid` com o pedido — é a chave canônica de
  reconciliação, não o `id` da Vexa.
* `metadata` ecoa 1:1 o que veio no `POST /v1/charges`. Em modo Hosted
  e em modo Gateway sem metadata, vem `null`.
* `amount_cents`, `fee_cents` e `net_cents` são inteiros em centavos
  **como string** (evita perda de precisão). `fee_cents`/`net_cents` vêm
  `null` enquanto a venda não liquidou.
* `livemode` é `false` em charges sandbox (`sk_test_`) e no disparo de
  teste; `true` em produção.
* Campos de cartão (`card_brand`, `card_last4`, `installments`) e de
  falha (`failure_code`, `failure_message`) vêm `null` quando não se
  aplicam ao evento/método.

Mesmo schema pra todos os eventos (`charge.paid`, `charge.failed`,
`charge.refunded`, `charge.chargeback`). Só `type`, `status` e os
campos dependentes do estado (`paid_at`, `failure_*`) mudam.

### Validar assinatura (obrigatório)

```typescript
import crypto from 'node:crypto';

export function verifyVexaSignature(
  rawBody: string,
  header: string,
  whsec: string,
): boolean {
  const [tPart, vPart] = header.split(',');
  const t = tPart?.split('=')[1];
  const v1 = vPart?.split('=')[1];
  if (!t || !v1) return false;

  // Anti-replay: 5min de skew
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;

  const signed = `${t}.${rawBody}`;
  const expected = crypto.createHmac('sha256', whsec).update(signed).digest('hex');

  // Decodifica hex pra comparar bytes (não strings) — timing-safe correto.
  const v1Buf = Buffer.from(v1, 'hex');
  const expBuf = Buffer.from(expected, 'hex');
  if (v1Buf.length !== expBuf.length) return false;
  return crypto.timingSafeEqual(v1Buf, expBuf);
}
```

```php
<?php
function verifyVexaSignature(string $rawBody, string $header, string $whsec): bool {
    [$tPart, $vPart] = explode(',', $header, 2) + [null, null];
    $t = explode('=', $tPart, 2)[1] ?? null;
    $v1 = explode('=', $vPart, 2)[1] ?? null;
    if (!$t || !$v1) return false;
    if (abs(time() - (int) $t) > 300) return false;
    $expected = hash_hmac('sha256', "{$t}.{$rawBody}", $whsec);
    return hash_equals($expected, $v1);
}
```

```python
import hmac, hashlib, time

def verify_vexa_signature(raw_body: str, header: str, whsec: str) -> bool:
    try:
        t_part, v_part = header.split(',', 1)
        t = t_part.split('=', 1)[1]
        v1 = v_part.split('=', 1)[1]
    except (ValueError, IndexError):
        return False
    if abs(time.time() - int(t)) > 300:
        return False
    signed = f'{t}.{raw_body}'
    expected = hmac.new(whsec.encode(), signed.encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)
```

**SEMPRE** use comparação timing-safe (`timingSafeEqual`,
`hash_equals`, `compare_digest`). Comparação com `==` abre timing
attack que vaza a assinatura caractere a caractere.

### Retry policy

Se seu endpoint responder ≠ 2xx ou demorar > 10s, retentamos com
backoff exponencial: 30s, 2min, 10min, 1h, 6h, 24h (6 tentativas em
~32h). Após 6 falhas, evento vai pra dead letter — visível em
`/settings/webhooks/deliveries`.

### Dedupe

O mesmo evento pode chegar 2x se sua resposta atrasou. Use `id` do
evento como chave de dedupe (Redis SETEX 7d resolve).

---

## Checkout hospedado

Cada produto tem URL pronta em `https://checkout.vexapagamentos.com/{slug}`. PIX, cartão,
boleto, antifraude e 3DS estão tudo plugados. Pré-preenchimento via
query string:

```
https://checkout.vexapagamentos.com/curso-trafego-iniciante?name=João&email=joao@exemplo.com&phone=11999998888&cpf=12345678901&utm_source=instagram&utm_campaign=lancamento
```

Use quando o canal de aquisição (Instagram bio, e-mail, anúncio) só
carrega URL.

---

## Códigos de erro

| HTTP | error | Quando |
|---|---|---|
| 400 | `invalid_input` | CPF, valor ou idempotency-key malformados |
| 401 | `unauthorized` | Token ausente ou inválido |
| 403 | `forbidden` | Operador inativo, antifraude rejeitou, ou produto não pertence ao titular |
| 404 | `not_found` | Produto/cobrança não encontrado |
| 409 | `idempotency_conflict` | Mesma chave + body diferente |
| 409 | `invalid_state` | Captura de cobrança não-capturável |
| 429 | `rate_limited` | Header `Retry-After` em segundos |
| 502 | `acquirer_error` | Processador retornou erro |

---

## Limites

| Plano | Limit |
|---|---|
| starter | 100 req/min por chave |
| pro | 500 req/min por chave |
| business | 1000 req/min por chave |

---

## Saques (NÃO existem via API)

Endpoint de saque NÃO existe. Saques só pelo painel com 2FA. Decisão
de produto: chave comprometida não pode esvaziar a conta. Tentativas
de chamar saque via API retornam 404.

---

## Lifecycle de cobrança (state machine)

Status canônicos: `pending`, `authorized`, `paid`, `failed`,
`expired`, `refunded`, `partially_refunded`, `chargeback`.

### PIX

```
pending ──┬──▶ paid       (banco do comprador confirmou)
          ├──▶ expired    (passou de pix.expires_at sem pagamento)
          └──▶ refunded   (estorno aprovado após paid)
```

- `pending` é o estado inicial. `pix.qr_code` está válido.
- Transição pra `paid` chega em <200ms (via webhook `charge.paid`).
- Após `expired`, **NÃO recriar a mesma cobrança** com a mesma
  `Idempotency-Key` — crie uma nova cobrança (nova chave) ou exiba ao
  comprador que o tempo expirou.

### Cartão

Cartão via API roda em modo **Hosted Checkout**. A primeira chamada
(`POST /v1/charges` com `method=card`) **não cria charge** — devolve
`object: "checkout_session"` com `checkout_url`. A charge nasce quando o
comprador finaliza (ou abandona) no hosted.

```
checkout_session  ──▶ comprador paga no hosted    ──┬──▶ paid       (charge.paid webhook)
                                                     ├──▶ failed     (charge.failed webhook)
                                                     └──▶ expired    (comprador abandonou)
                                                                      │
                                                                      ▶ refunded (estorno após paid)
                                                                         └▶ partially_refunded
```

- 3DS, antifraude e captura acontecem **dentro do hosted** — caller só vê estado final via webhook.
- `POST /charges/{id}/capture` segue existindo pro caso raro de `authorized` aguardando captura manual (não acontece no fluxo default).
- **Não confie no `returnUrl`**: parâmetros de query podem ser spoofed. Estado final é só webhook.

### Boleto

```
pending ──┬──▶ paid       (banco emissor confirmou compensação)
          └──▶ expired    (passou da boleto.due_date sem pagamento)
```

- Compensação banco→Vexa leva **1 a 2 dias úteis** após o pagamento
  efetivo. O comprador paga hoje, o webhook `charge.paid` chega
  amanhã/depois.
- Boleto **não tem refund automático** via API — operação é manual via
  painel (regra do banco emissor).
- Job recomendado: cron diário fazendo `GET /charges/{id}` em
  cobranças `pending` há mais de 1 dia útil pra capturar pagamentos
  que possam ter perdido a janela do webhook.

### Estorno

```
paid ──┬──▶ refunded            (estorno total)
       └──▶ partially_refunded  (estorno parcial — só PIX e cartão)
```

---

## Fluxo end-to-end

```
┌──────────┐
│ Comprador│
└─────┬────┘
      │ acessa página de checkout
      ▼
┌─────────────┐    POST /v1/charges    ┌──────────┐    chama processador    ┌──────────┐
│  Seu front  │───────────────────────▶│   Vexa   │────────────────────────▶│Processador│
└─────────────┘  (Authorization: sk_*) │   API    │                          └─────┬────┘
      ▲          (Idempotency-Key)     └────┬─────┘                                │
      │                                     │ 201 + charge {id, qr_code, ...}      │
      │                                     ▼                                      │
      │                             ┌──────────────┐                               │
      │                             │  Seu backend │ persiste charge.id no pedido  │
      │                             └──────┬───────┘                               │
      │  renderiza QR Code/3DS/boleto      │                                       │
      └────────────────────────────────────┘                                       │
                                                                                   │
              comprador paga                                                       │
              ──────────────────────────────────────────────────────────────▶      │
                                                                                   │
                                  ┌────────────────────────────────────────────────┘
                                  │ confirma pagamento
                                  ▼
                            ┌──────────┐    POST webhook (HMAC)    ┌──────────────┐
                            │   Vexa   │──────────────────────────▶│ Seu webhook  │
                            │  Worker  │   X-Vexa-Signature        │   handler    │
                            └──────────┘   X-Vexa-Event=charge.paid└──────┬───────┘
                                                                          │ 1. valida HMAC (timing-safe)
                                                                          │ 2. dedup por evt_id
                                                                          │ 3. atualiza pedido
                                                                          │ 4. responde 200
                                                                          │ 5. (async) libera produto
                                                                          ▼
                                                                  ┌──────────────┐
                                                                  │  Comprador   │
                                                                  │ recebe acesso│
                                                                  └──────────────┘
```

**Regra de ouro:** o pedido só é considerado pago **quando o webhook
`charge.paid` chega e a assinatura HMAC é válida**. Nunca confiar em:
- Resposta da rota de redirect/success (atacante pode manipular URL).
- `status` no JSON inicial de `POST /charges` (estado pode mudar).
- Polling em loop (consome rate limit, latência alta, frágil).

---

## Implementação completa: checkout PIX em Next.js 15

Sistema mínimo viável: cria cobrança, renderiza QR Code, escuta webhook,
libera produto. Todo o código é server-side (`sk_*` nunca toca o front).

### 1. Variáveis de ambiente (.env.local)

```bash
VEXA_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
VEXA_WHSEC=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
DATABASE_URL=postgres://localhost:5432/myapp
REDIS_URL=redis://localhost:6379
```

### 2. Schema do banco (Drizzle/Prisma equivalente)

```sql
CREATE TABLE orders (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id         UUID NOT NULL,
  product_id      TEXT NOT NULL,
  amount_cents    BIGINT NOT NULL,
  status          TEXT NOT NULL DEFAULT 'awaiting_payment',  -- awaiting_payment | paid | expired | failed
  vexa_charge_id  TEXT UNIQUE,                               -- preenchido após POST /charges
  paid_at         TIMESTAMPTZ,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_orders_charge ON orders (vexa_charge_id);

CREATE TABLE webhook_events (
  evt_id          TEXT PRIMARY KEY,                          -- evt_01HW8K... (id do evento Vexa)
  type            TEXT NOT NULL,                             -- charge.paid, charge.failed, ...
  charge_id       TEXT NOT NULL,
  raw_payload     JSONB NOT NULL,
  processed_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```

`webhook_events` é **tabela de dedupe** — INSERT com PK no `evt_id`.
Se já existe (PK violation), evento é replay → ignora.

### 3. Server action: criar cobrança

```typescript
// app/actions/create-pix-charge.ts
'use server';

import { db } from '@/lib/db';
import { orders } from '@/lib/db/schema';

export async function createPixCharge(productId: string, userId: string, amountCents: number) {
  // 1. Cria order localmente primeiro (sem vexa_charge_id ainda).
  const [order] = await db.insert(orders).values({
    userId, productId, amountCents,
  }).returning();

  // 2. Idempotency-Key derivada do orderId — retry seguro.
  const res = await fetch('https://api.vexapagamentos.com/v1/charges', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.VEXA_SECRET_KEY}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': order.id,
    },
    body: JSON.stringify({
      productId,
      method: 'pix',
      customer: {
        name: 'João da Silva',
        email: 'joao@exemplo.com',
        cpf: '12345678901',
        phone: '11999998888',
      },
    }),
  });

  if (!res.ok) {
    const err = await res.json();
    throw new Error(`vexa_create_charge_failed: ${err.error}`);
  }

  const charge = await res.json();

  // 3. Atualiza order com charge_id da Vexa.
  await db.update(orders).set({ vexaChargeId: charge.id }).where({ id: order.id });

  return { orderId: order.id, qrCode: charge.pix.qr_code, expiresAt: charge.pix.expires_at };
}
```

### 4. Webhook handler (App Router route)

```typescript
// app/api/webhooks/vexa/route.ts
import crypto from 'node:crypto';
import { db } from '@/lib/db';
import { orders, webhookEvents } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';

export async function POST(req: Request) {
  const sig = req.headers.get('x-vexa-signature') ?? '';
  const raw = await req.text();

  // 1. Valida HMAC com timing-safe compare.
  if (!verifyVexaSignature(raw, sig, process.env.VEXA_WHSEC!)) {
    return new Response('invalid signature', { status: 401 });
  }

  const event = JSON.parse(raw);

  // 2. Dedupe por evt_id (PK em webhook_events).
  try {
    await db.insert(webhookEvents).values({
      evtId: event.id,
      type: event.type,
      chargeId: event.data.charge.id,
      rawPayload: event,
    });
  } catch (e: unknown) {
    if (isUniqueViolation(e)) {
      // Replay — já processado. Retorna 200 pra Vexa parar de retentar.
      return new Response('duplicate', { status: 200 });
    }
    throw e;
  }

  // 3. Aplica efeito conforme tipo.
  if (event.type === 'charge.paid') {
    await db.update(orders)
      .set({ status: 'paid', paidAt: new Date(event.data.charge.paid_at) })
      .where(eq(orders.vexaChargeId, event.data.charge.id));

    // 4. (async) Libera produto. Se falhar, retry job — webhook NÃO espera.
    await queue.add('release-product', { chargeId: event.data.charge.id });
  } else if (event.type === 'charge.failed') {
    await db.update(orders)
      .set({ status: 'failed' })
      .where(eq(orders.vexaChargeId, event.data.charge.id));
  } else if (event.type === 'charge.refunded' || event.type === 'charge.chargeback') {
    // Estorno/chargeback: revogue o acesso ao produto.
    await db.update(orders)
      .set({ status: event.type })
      .where(eq(orders.vexaChargeId, event.data.charge.id));
    await queue.add('revoke-product', { chargeId: event.data.charge.id });
  }

  // 5. Sempre 200 rápido — timeout do remetente é 10s.
  return new Response('ok', { status: 200 });
}

function verifyVexaSignature(rawBody: string, header: string, whsec: string): boolean {
  const [tPart, vPart] = header.split(',');
  const t = tPart?.split('=')[1];
  const v1 = vPart?.split('=')[1];
  if (!t || !v1) return false;

  // Anti-replay 5min
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;

  const expected = crypto
    .createHmac('sha256', whsec)
    .update(`${t}.${rawBody}`)
    .digest('hex');

  return crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}
```

### 5. Página de checkout

```tsx
// app/checkout/[orderId]/page.tsx
import { db } from '@/lib/db';
import { orders } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';

export default async function CheckoutPage({ params }: { params: { orderId: string } }) {
  const order = await db.query.orders.findFirst({ where: eq(orders.id, params.orderId) });
  if (!order) return <p>Pedido não encontrado.</p>;
  if (order.status === 'paid') return <ProductAccess productId={order.productId} />;

  // Buscar QR Code atual via GET /v1/charges/{id} (não via polling — só no load inicial)
  const charge = await fetch(`https://api.vexapagamentos.com/v1/charges/${order.vexaChargeId}`, {
    headers: { Authorization: `Bearer ${process.env.VEXA_SECRET_KEY}` },
    next: { revalidate: 0 },
  }).then((r) => r.json());

  return (
    <div>
      <QrCode emv={charge.pix.qr_code} />
      <CopyButton value={charge.pix.qr_code} />
      <Countdown until={charge.pix.expires_at} />
      {/* Atualização do status vem por webhook → revalidate em server component
          ou via Server-Sent Events / WebSocket.
          NÃO faça polling de GET /charges/{id} a cada N segundos. */}
    </div>
  );
}
```

---

## Modelo de dados recomendado

Mínimo pra rodar a integração com segurança:

| Tabela | Pra que serve |
|---|---|
| `orders` | Pedido do seu sistema. `vexa_charge_id` é FK pra cobrança Vexa. `status` é `awaiting_payment` / `paid` / `expired` / `failed`. Atualizado SÓ por webhook. |
| `webhook_events` | PK = `evt_id`. Tabela de dedupe por INSERT/UNIQUE. Guarda `raw_payload` JSONB pra debug. |
| `customers` (opcional) | Se quiser salvar comprador entre pedidos no SEU lado. Use `email` + `cpf_hash` como chave canônica — a Vexa não expõe `customer_id` no schema público hoje. |

**Regras de schema:**
- `amount_cents` sempre `BIGINT` (centavos). Float é proibido — perde precisão acima de R$ 90 trilhões em cents (irrelevante na prática, mas `BIGINT` é hábito correto).
- `vexa_charge_id` UNIQUE (impede salvar 2 orders pra mesma charge — sintoma de bug de retry mal implementado).
- `webhook_events.evt_id` é PK — dedupe é via INSERT, não SELECT.
- Tudo em `TIMESTAMPTZ` (UTC armazenado, render no fuso do user).

---

## Production best practices

| ✅ Faça | ❌ Não faça |
|---|---|
| Tratar `charge.paid` SÓ via webhook | Marcar pedido pago em redirect success |
| `Idempotency-Key` derivada do `orderId` interno | Gerar UUID novo a cada retry — duplica cobrança |
| Salvar `evt_id` (PK em `webhook_events`) | Reprocessar evento toda vez que chega |
| HMAC com `timingSafeEqual` / `hash_equals` / `compare_digest` | Comparar com `==` ou `===` (timing attack) |
| Anti-replay 5min na timestamp `t` da assinatura | Aceitar webhook antigo sem checar `t` |
| `amount_cents` em `BIGINT` | `amount_cents` em float / number JS |
| Webhook responde 200 rápido + processa async | Webhook faz trabalho pesado inline (timeout 10s da Vexa) |
| Reconciliar boleto via `GET /charges/{id}` em cron diário | Confiar 100% no webhook pra boleto |
| Mascarar CPF/PAN em logs e Sentry | Logar `req.body` cru com PAN ou CPF |
| Cartão 3DS `authorized` = aguardando webhook | Marcar pago em `authorized` |
| Usar `sk_test_` em desenvolvimento | Compartilhar `sk_live_` em prompt de IA cloud |
| `sk_*` sempre server-side; pra cartão, redirecione comprador pra `checkout_url` do hosted | Mandar `sk_*` ou dado de cartão pelo client |
| Rate limit no SEU webhook (worker pode ficar atrás de NAT) | Endpoint webhook aberto sem rate limit |

---

## Common mistakes

Lista do que mais quebra integração nova. Se você fez algum disso, está com bug:

1. **Confiar em redirect success.** Comprador é redirecionado pra `/sucesso` — você marca pedido como pago. Atacante manipula a URL e libera produto sem pagar. Sempre webhook + HMAC.

2. **Comparar HMAC com `==`.** Cria timing attack que vaza a assinatura caractere a caractere. Use sempre `crypto.timingSafeEqual`, `hash_equals` (PHP) ou `hmac.compare_digest` (Python).

3. **Polling em vez de webhook.** Você bate `GET /charges/{id}` a cada 3s aguardando paid. Consome rate limit, latência alta, escala mal. Webhook chega em <200ms.

4. **Idempotency-Key randômica a cada retry.** Você falha o request, retenta com nova chave — cria 2 cobranças no banco do comprador. Use `Idempotency-Key` determinística (`order.id`).

5. **Dedupe via SELECT antes de INSERT.** Race condition: 2 webhooks chegam simultaneamente, ambos veem "não existe", ambos inserem. Use PK no `evt_id` e trate UNIQUE violation.

6. **Marcar boleto como pago em response inicial.** Boleto retorna `status: pending`. Compensação chega 1-2 dias úteis depois.

7. **Marcar cartão 3DS como pago em `authorized`.** `authorized` significa "aguardando comprador concluir desafio do emissor". Espere o webhook `charge.paid` ou `charge.failed`.

8. **`amount_cents` em float.** `19.90` em JS é `19.8999999...`. Sempre `BIGINT` em centavos. `R$ 19,90 = 1990` (não `19.90`).

9. **Webhook handler que faz trabalho pesado inline.** Timeout da Vexa é 10s. Trabalho longo (gerar PDF, enviar email, processar imagem) tem que ir pra fila — webhook só atualiza status e responde 200.

10. **`sk_live_` no front / em log / em prompt de IA cloud.** `sk_*` é server-only. Sempre proxy via sua rota `/api/charge`.

11. **Não validar timestamp `t` da assinatura.** Atacante captura webhook antigo e reenvia. Anti-replay = rejeitar `t` fora da janela de 5min.

12. **CPF como string com máscara em comparação.** `'123.456.789-01' !== '12345678901'`. Normalize com `replace(/\D/g, '')` antes de comparar/persistir.

---

## Erros estruturados

Resposta de erro **sempre** segue este schema:

```json
{
  "error": "invalid_input",
  "message": "Customer CPF is invalid",
  "details": {
    "field": "customer.cpf",
    "value_received_length": 10
  }
}
```

Campos:
- `error` — código curto e estável pra tratamento programático. NUNCA muda.
- `message` — descrição legível. Pode mudar (não use em `if`).
- `details` — campos extras específicos do erro (field path, valor recebido, valor esperado). **Não** logar `details` se contiver PII (CPF, e-mail).

### Exemplos por código

| HTTP | `error` | Cenário | Como tratar |
|---|---|---|---|
| 400 | `invalid_input` | CPF/valor/idempotency-key malformado | Mostre `message` ao usuário, marque `details.field` no form |
| 401 | `unauthorized` | `sk_*` ausente, expirada ou revogada | Não retry. Alertar oncall. |
| 403 | `forbidden` | Operador inativo, antifraude rejeitou, produto não pertence ao titular | Não retry. Mostrar mensagem genérica ao comprador. |
| 404 | `not_found` | Charge/produto não existe | Não retry. |
| 409 | `idempotency_conflict` | Mesma chave + body diferente | Bug do seu lado. Investigue por que body mudou. |
| 409 | `invalid_state` | Captura de cobrança não-capturável (ex.: já `paid`) | Recarregue o status atual e siga o lifecycle. |
| 429 | `rate_limited` | Plano excedido | Espere o header `Retry-After` (segundos) e refaça. Backoff exponencial. |
| 502 | `acquirer_error` | Processador retornou erro transitório | Retry com backoff (max 3x). Se persistir, alerta oncall. |

---

## Regras operacionais por método

### PIX

- **Expiração:** fixa em 1800s (30min) a partir da criação. Após expirar, status migra automaticamente pra `expired`. Não é configurável na API pública.
- **Reuso de cobrança expirada:** NÃO reutilizar `Idempotency-Key` da cobrança expirada. Criar nova cobrança, nova chave.
- **Timezone:** `expires_at` na resposta vem em UTC (ISO 8601). Não confunda com `America/Sao_Paulo` na UI — converta no cliente.
- **QR Code:** `pix.qr_code` é o EMV string completo (copia-e-cola). Para renderizar QR visual, use `pix.qr_code_image_url` ou gere com lib (ex: `qrcode` no Node).
- **Mesmo CPF múltiplas tentativas:** ok. Cada cobrança é independente — antifraude avalia por contexto, não bloqueia repetição.
- **Antifraude:** `phone` do customer é **de fato obrigatório** (não documentado como required, mas o motor antifraude rejeita pedido sem telefone real com flag automática de risco).

### Cartão

- **PCI scope:** PAN + CVV trafegam **apenas** pelo Hosted Checkout (`https://checkout.vexapagamentos.com/<slug>`). Caller da API REST nunca toca dado de cartão — o redirect mantém o caller fora de scope PCI. SDK Vexa.js pra tokenização in-place (scope A, sem redirect) está em desenvolvimento.
- **Fluxo:** `POST /v1/charges` com `method=card` + `productId` devolve `object: "checkout_session"` com `checkout_url`. Redirecione o comprador; ele paga no hosted (3DS transparente).
- **3DS:** acontece dentro do hosted checkout. Caller da API REST não vê o desafio nem o ACS URL — estado final só via webhook `charge.paid` / `charge.failed`.
- **Installments:** 1 a 12. Juros (se houver) são configurados a nível de produto, não no request. Passe `installments` no `POST /v1/charges` pra pré-selecionar no hosted.
- **Soft descriptor:** texto que aparece na fatura do cartão do comprador. Configurado a nível da Vexa, não passado no request.
- **Antifraude:** mesma regra do PIX (`phone` obrigatório de fato). Em transações alta-risco, processador pode retornar `failed` mesmo com cartão válido.
- **Gateway API puro:** `POST /v1/charges` com `method=card` **sem** `productId` retorna `422 unsupported_method_in_gateway_api`. Use `productId` (modo Hosted) enquanto o SDK Vexa.js não chega.

### Boleto

- **Vencimento:** `boleto_due_date` aceita até D+30. Default D+3 (incluindo dias úteis e finais de semana — banco emissor não aceita pagamento após `due_date`).
- **Compensação:** banco→Vexa em **1 a 2 dias úteis** após pagamento efetivo. Webhook `charge.paid` chega depois desse delay (não <200ms como PIX).
- **Reconciliação:** **obrigatória**. Cron diário fazendo `GET /charges/{id}` em cobranças `pending` há mais de 1 dia útil pra capturar pagamentos que perderam a janela do webhook.
- **Estorno:** **não automatizado** via API. Operação manual via painel (regra do banco emissor).
- **Linha digitável vs código de barras:** `boleto.digitable_line` (47 dígitos formatados) é pra internet banking. `boleto.barcode` (44 dígitos) é pra leitor óptico em lotérica/agência. Exiba ambos.
- **PDF:** `boleto.pdf_url` é a versão imprimível. Linka no e-mail pro comprador ou exibe no checkout pra download.
- **Instructions:** `boleto_instructions` (até 200 chars) é texto livre impresso no boleto — useful pra "Não receber após o vencimento" ou "Em caso de dúvida, contato@..." (orientação ao caixa do banco).

---

## MCP Server (Model Context Protocol)

Endpoint hospedado: `https://mcp.vexapagamentos.com/mcp` (StreamableHTTP).
Permite que agentes IA (Claude Desktop, Cursor, n8n, agentes em cloud)
controlem a Vexa em linguagem natural via 5 tools que mapeiam 1:1 pros
endpoints REST acima — auth pelo mesmo `sk_test_*` / `sk_live_*` da API.

### Tools disponíveis

| Tool | Endpoint Vexa equivalente | Notas |
|------|---------------------------|-------|
| `createPixCharge` | `POST /v1/charges` (`method=pix`) | Devolve `pix.qr_code` + copia-e-cola |
| `createBoletoCharge` | `POST /v1/charges` (`method=boleto`) | Endereço do sacado obrigatório (regra Bacen) |
| `getCharge` | `GET /v1/charges/{id}` | Status + detalhes |
| `listCharges` | `GET /v1/charges` | Filtro por status, paginado |
| `simulateChargePayment` | `POST /v1/charges/{id}/simulate` | Sandbox only (`sk_test_*`) |

> Cartão fica de fora da MCP enquanto o SDK Vexa.js não chega — pra cartão, o agente AI deve devolver pro usuário o `checkout_url` do Hosted Checkout (gerado via `POST /v1/charges` com `method=card` na API REST direta).

### Modos de operação

**stdio (Claude Desktop, Cursor, agentes locais)**

Cliente roda `@vexagroup/mcp` como subprocess via npx. Auth pela env
`VEXA_API_KEY` injetada no spawn.

```json
{
  "mcpServers": {
    "vexa": {
      "command": "npx",
      "args": ["-y", "@vexagroup/mcp"],
      "env": {
        "VEXA_API_KEY": "sk_live_xxxxxxxxxxxxxxxx",
        "VEXA_API_BASE_URL": "https://api.vexapagamentos.com/v1"
      }
    }
  }
}
```

**HTTP (n8n cloud, Make, agentes self-hosted)**

Cliente envia `Authorization: Bearer sk_live_...` em todo request pra
`https://mcp.vexapagamentos.com/mcp`. Server não persiste a chave —
mantém só `Map<sessionId, apiKey>` em RAM enquanto a sessão estiver viva
(TTL 30min). Restart do server limpa todas as sessões.

### Comportamento e segurança

- **Sem acesso direto ao DB**: o MCP é um wrapper de `fetch()` na API REST v1. Bug no MCP só consegue fazer o que o Bearer permite.
- **Validação de formato**: Bearer fora do padrão `sk_(test|live)_\w{16,128}` é rejeitado em 401 antes de alocar sessão.
- **Logs sanitizados**: PAN, CVV e chaves `sk_*` são redactados em qualquer linha de erro escrita pelo server.
- **Isolamento**: cada sessão tem sua própria instância `McpServer` + `apiClient` — sem state compartilhado entre tenants.

## Recursos relacionados

- [OpenAPI 3.0 (JSON)](https://vexapagamentos.com/openapi.json)
- [OpenAPI 3.0 (YAML)](https://vexapagamentos.com/api/openapi.yaml)
- [Painel de integrações](https://vexapagamentos.com/integrations) — chaves, webhooks, telemetria
- [Documentação visual](https://vexapagamentos.com/integrations/docs) — exemplos navegáveis
- [Suporte técnico](mailto:contato@vexapagamentos.com)
