---
name: validapay-api
description: Integra a API REST ValidaPay (Pix, checkout, assinaturas, split, subcontas, saques, devoluções e webhooks). Use ao gerar código, clientes HTTP, exemplos cURL ou fluxos de pagamento ValidaPay.
---

# ValidaPay API — skill de integração

Guia para agentes de IA implementarem integrações com a API ValidaPay. Siga as regras abaixo; não invente rotas, campos ou status.

Como usar esta skill:

- **Cursor:** salve em `.cursor/skills/validapay-api/SKILL.md`
- **Claude:** cole nas instruções do Project, ou salve como `CLAUDE.md` na raiz (Claude Code)
- **ChatGPT:** cole nas instruções de um GPT customizado, no Knowledge do Project, ou no chat
- **Lovable:** cole em Knowledge / instruções do projeto
- **Outras IAs:** cole o Markdown no chat e peça a integração com a API ValidaPay

Credenciais: https://app.validapay.com.br/integracao/api  
Webhooks: https://app.validapay.com.br/integracao/webhooks  
Suporte: contato@validapay.com.br · WhatsApp (11) 93623-6133

## Regras

- REST + JSON. Valores monetários em **reais (BRL)**, nunca em centavos. Duas casas, ponto decimal (`10.00`).
- **`paymentMethod` muda de caixa entre entrada e saída.** No envio use minúsculo (`pix`, `creditcard`, `boleto`, `pix_automatico`); nas respostas ele volta em maiúsculo (`PIX`, `BOLETO`, `CREDIT_CARD`). Não reenvie o valor lido de uma resposta.
- HTTPS obrigatório. Auth: OAuth2 `client_credentials` → `Authorization: Bearer {access_token}`.
- Não existe `POST /v1/subscriptions`. Assinaturas nascem via `POST /v1/charges` (itens recorrentes) ou checkout (`POST /v1/checkouts` / `POST /v1/checkout-sessions`).
- Idempotência só em `POST /v1/charges`: `externalId` → duplicata vira `409 DUPLICATE_CHARGE`. **`POST /v1/charges/pix` não tem idempotência** — `externalId` é ignorado e chamadas repetidas criam cobranças distintas; controle duplicidade do seu lado.
- Cartão no backend: preferir `paymentMethodId` do SDK `@validapay/tokenize`. Não persistir PAN/CVV.
- Pix Automático (`pix_automatico`): só conta **PJ**, preço **recorrente**, mínimo **R$ 4,99**.
- Saques: mesma titularidade da conta. Split: `type` é `fixed` ou `percentage` (literal, minúsculo); informe `accountNumber` **ou** `publicId` do recebedor. Máximo 20 recebedores; soma dos percentuais até 100%. **Split não funciona em `creditcard` nem `pix_automatico`** — só Pix e boleto.
- Webhook: responder **HTTP 200** e não bloquear no handler. A assinatura em `X-Webhook-Signature` é composta — `t=<ms>,v1=<hmac>` — e o HMAC é `HMAC_SHA256(secret, "<t>.<corpo bruto>")`; use o corpo bruto, sem reserializar.
- Sandbox: simular pagamento com `POST /v1/wallet/pay/:chargeId`.

## Ambientes

| | API | OAuth |
|---|---|---|
| Produção | `https://api.validapay.com.br` | `https://oauth2.validapay.com.br/auth/token` |
| Sandbox | `https://sandbox.validapay.com.br` | `https://oauth2-sandbox.validapay.com.br/auth/token` |

Painel: `https://app.validapay.com.br`

Prefixos: `cha_` cobrança · `prod_` produto · `price_` preço · `pl_` link · `cs_` sessão · `cus_` cliente · `pm_` cartão tokenizado · `sub_` assinatura · `item_` item · `ref_` devolução · `wdr_` saque

## Autenticação

```http
POST https://oauth2-sandbox.validapay.com.br/auth/token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&client_id={client_id}&client_secret={client_secret}&scope={scope}
```

Resposta: `{ "access_token", "expires_in", "token_type": "Bearer" }`. **`expires_in` não é fixo em 3600** — o servidor reaproveita tokens já emitidos e devolve o tempo restante. Calcule a validade por `min(expires_in, exp do JWT)` com margem, e não repita a autenticação em laço ao tomar 401: a nova chamada pode devolver o mesmo token. Peça só os scopes necessários, separados por espaço.

### Scopes

| Scope | Uso |
|---|---|
| `pix.cob/write` `pix.cob/read` | QR Pix imediato (`/v1/charges/pix`) e consulta |
| `checkouts/write` `checkouts/read` | Links, sessões e checkout transparente (`/v1/charges`) |
| `products/write` `products/read` | Produtos e preços |
| `customers/write` `customers/read` `customers/delete` | Cadastro de clientes |
| `subscriptions/write` `subscriptions/read` | Gestão de assinaturas (não cria) |
| `proposals/write` | Onboarding de subcontas |
| `subaccounts/read` | Listar subcontas e cobranças |
| `wallet/write` `wallet/read` | Saldo, extrato, saque, devolução, pagar sandbox |

Nas demais rotas: `Authorization: Bearer {access_token}` e `Content-Type: application/json`.

Header opcional: `X-Sub-Account: {numero}` — opera na subconta (ex.: cobrança Pix do seller com split para a master).

## Mapa de rotas

### Pix imediato — `pix.cob/*`

| Método | Path | Função |
|---|---|---|
| POST | `/v1/charges/pix` | QR imediato. Body mínimo `{ "amount": 10.00 }`. Com split, array `split`. |
| GET | `/v1/charges/:chargeId` | Status (`PAID`, etc.) |

Split para parceiros (na conta master):

```json
{
  "amount": 1.00,
  "externalTxid": "loja-01-caixa-03",
  "split": [
    { "type": "fixed", "accountNumber": "896532569", "amount": 0.10 }
  ]
}
```

Split para a master a partir do seller: mesmo body **sem** `accountNumber` no split + header `X-Sub-Account`.

Resposta da criação: `{ "chargeId", "emv" }` (Pix Copia e Cola).

### Produtos — `products/*`

| Método | Path |
|---|---|
| POST | `/v1/products` |
| GET | `/v1/products` · `/v1/products/:id` |
| PUT | `/v1/products/:id` |
| DELETE | `/v1/products/:id` |
| POST | `/v1/products/:id/archive` |

`type`: `RECURRING` \| `ONE_TIME`. `prices[].recurrenceType`: `ONE_TIME` `DAILY` `WEEKLY` `MONTHLY` `QUARTERLY` `SEMIANNUAL` `YEARLY`. `recurrenceInterval` mínimo 1 (ex.: 2 = bimestral). `trialDays` opcional. `statementDescriptor` máx. 22 caracteres.

```json
{
  "name": "Plano Premium",
  "type": "RECURRING",
  "prices": [
    { "title": "Mensal", "amount": 99.9, "recurrenceType": "MONTHLY", "trialDays": 7 }
  ]
}
```

### Links e sessões — `checkouts/*`

| Método | Path | Função |
|---|---|---|
| POST/GET/PUT | `/v1/checkouts` · `/v1/checkouts/:id` | Link reutilizável (`pl_`). Informe `priceId` **ou** `product`. |
| POST/GET | `/v1/checkout-sessions` · `/v1/checkout-sessions/:id` | Sessão única (`cs_`), cliente pré-preenchido. |

`allowedPaymentMethods`: `pix` `creditcard` `boleto` `pix_automatico`.

### Checkout transparente — `POST /v1/charges`

Campo `paymentMethod` obrigatório. Cliente obrigatório (`name`, `email`, `documentNumber`). Itens via `items[].priceId` **ou** `amount` avulso (exceto Pix Automático: só `items` recorrentes).

| `paymentMethod` | Particularidades | Resposta útil |
|---|---|---|
| `pix` | `expiration` YYYY-MM-DD | `pix.emv`, `pix.qrCode` |
| `pix_automatico` | PJ + preço recorrente ≥ 4.99; `billingDay` 1–31 | `pix.emv`, `pix.recurrencyId`; assinatura `PENDING` até o banco autorizar |
| `boleto` | `customer.address` completo; `dueDate` / `boletoDueDays`; `boletoInstructions` | `boleto.digitableLine`, `barCode`, `pdfUrl` |
| `creditcard` | `card` **ou** `paymentMethodId`; `installments` 1–12 | `status: "paid"` ou 402 recusa |

```json
{
  "paymentMethod": "pix",
  "externalId": "pedido-2026-0001",
  "customer": {
    "name": "João da Silva",
    "email": "joao@email.com",
    "documentNumber": "12345678901"
  },
  "items": [{ "priceId": "price_abc123", "quantity": 1 }]
}
```

Cartão tokenizado: omita `card` e envie `"paymentMethodId": "pm_abc123"`.

### Emitir nota fiscal junto com a cobrança

Envie `nfConfigId` no corpo da cobrança, com o id de uma configuração criada em `POST /v1/invoices/notas/config`.

- **`customer.address` passa a ser obrigatório.** Sem ele: `400 INVALID_DATA`. A nota precisa do endereço do tomador, e o `cityCode` (IBGE) é resolvido pelo CEP.
- O **momento da emissão vem da configuração**, não da cobrança: `invoiceTiming` aceita `IMMEDIATE` (padrão), `AFTER_CONFIRMATION` e `DAYS_AFTER_CONFIRMATION`; neste último, `daysAfterConfirmation` define o prazo (padrão 1).
- O mesmo `nfConfigId` existe em `POST /v1/products` e nas configurações de assinatura, para emitir a cada ciclo sem repetir na cobrança.

```json
{
  "paymentMethod": "pix",
  "amount": 150.00,
  "nfConfigId": "nfc_1788364755079_psuecwgdc",
  "customer": {
    "name": "João da Silva",
    "email": "joao@email.com",
    "documentNumber": "12345678901",
    "address": {
      "zipCode": "01310100",
      "street": "Avenida Paulista",
      "number": "1000",
      "neighborhood": "Bela Vista",
      "city": "São Paulo",
      "state": "SP"
    }
  }
}
```

### Campos de POST /v1/charges

Além de `paymentMethod`, `customer` e `items`/`amount`:

| Campo | Regra |
|---|---|
| `installments` | 1 a 12, só cartão |
| `freeInstallments` | 0 a 12, parcelas sem juros ao comprador |
| `passFeesToCustomer` | repassa a taxa de parcelamento |
| `split` | **não** funciona em `creditcard` nem `pix_automatico` |
| `discounts[].type` | `percentage` ou `fixed` (também aceita maiúsculo) |
| `allowedPaymentMethods` | subconjunto de `pix` `creditcard` `boleto` `pix_automatico` |
| `dueDate` | `YYYY-MM-DD`, precisa ser maior que hoje |
| `expirationAfterDueDate` | 0 a 60 dias após o vencimento |
| `boletoDueDays` | derivado de `dueDate` quando este é enviado |
| `externalId` / `externalTxid` | até 100 caracteres |
| `productId` | prefixo `prod_`; `items[].priceId`, prefixo `price_` |
| `recurrencyStartDate`, `prorataStartDate`, `prorataDueDate` | `YYYY-MM-DD` |
| `mergeWithNextCycle` | junta a pro rata ao próximo ciclo |
| `nfConfigId` | emite nota; exige `customer.address` |

Com `creditcard`, informe `card`, `tokenId` **ou** `paymentMethodId` — sem um deles a cobrança é recusada.

### Clientes — `customers/*`

| Método | Path | Função |
|---|---|---|
| POST | `/v1/customers` | Cadastra. Já existente → **200** com o cadastro atual (não duplica); com `upsert: true` → **400** `CUSTOMER_ALREADY_EXISTS` |
| GET | `/v1/customers` | Lista. Query: `limit`, `lastKey`, `search`, `document`, `status`, `startDate`, `endDate` |
| GET | `/v1/customers?lookupDocument=` | Busca 1 cliente pelo CPF/CNPJ exato + endereço padrão |
| GET | `/v1/customers/:customerId` | Detalhe com endereços e assinaturas |
| PATCH | `/v1/customers/:customerId` | Atualiza. Documento **não** pode ser alterado → **400** `DOCUMENT_UPDATE_NOT_ALLOWED` |
| DELETE | `/v1/customers/:customerId` | Remove. Com assinatura recorrente → **400** `CUSTOMER_HAS_ACTIVE_SUBSCRIPTIONS` |

`search` faz busca parcial simultânea em **nome, e-mail e documento** — use para autocompletar cliente. `document` é match exato; prefira quando o CPF/CNPJ completo já é conhecido. `lookupDocument` devolve `{ customer, address }` prontos para preencher formulário; documento não cadastrado retorna **200** com ambos `null` (não é erro).

Paginação por cursor: repita a chamada com `lastKey` = `pagination.lastKey`, mantendo os mesmos filtros. `lastKey` `null` = fim.

`document`: apenas dígitos, CPF (11) ou CNPJ (14). `phone`: E.164 com DDI 55. Com `address`, o `cityCode` (IBGE) é resolvido pelo CEP — necessário para NFS-e. Status: `ACTIVE` `INACTIVE` `BLOCKED`.

```json
{
  "name": "Alexandre Souza",
  "document": "05154089561",
  "phone": "5511987654321",
  "email": "alexandre@exemplo.com.br",
  "address": {
    "zipCode": "01310100",
    "street": "Avenida Paulista",
    "number": "1000",
    "neighborhood": "Bela Vista",
    "city": "São Paulo",
    "state": "SP"
  }
}
```

### Assinaturas — `subscriptions/*`

Não criar por esta API. Após o pagamento, liste/filtre (ex. `checkoutId`) e use `GET /v1/subscriptions/:subscriptionId`.

| Método | Path | Função |
|---|---|---|
| GET | `/v1/subscriptions` | Lista. Query: `limit`, `lastKey`, `status`, `paymentMethod`, `document`, `search`, `startDate`, `endDate`, `priceId`, `productId` |
| GET | `/v1/subscriptions/:subscriptionId` | Detalhe |
| PUT | `/v1/subscriptions/:id/items/:itemId` | Upgrade/downgrade canônico (`priceId` e/ou `quantity`) |
| POST | `/v1/subscriptions/:id/items` | Add-on. Assinatura `ACTIVE`/`PAST_DUE`; não no 1º ciclo (`currentCycleNumber === 1`) |
| POST | `/v1/subscriptions/:id/prorata` | Preview — **não cobra** |
| PATCH | `/v1/subscriptions/:id` | `{ old: { itemId }, new: { priceId } }` upgrade/downgrade; `{ old: { itemId } }` cancela item |
| DELETE | `/v1/subscriptions/:id` | Cancela assinatura. Body opcional `{ "reason" }` |

Upgrade (`newTotal > currentTotal`): cobra pro rata já (cartão) ou PIX/boleto assíncrono. Downgrade: no próximo ciclo, sem cobrança imediata. Falha de pagamento nestas rotas: **400** `PAYMENT_DECLINED` / `PAYMENT_FAILED` (não 402).

Status assinatura: `PENDING` `TRIALING` `ACTIVE` `PAST_DUE` `DEFAULT` `EXPIRED` `CANCELED` `COMPLETED` `ARCHIVED`.  
Status do item: `PENDING` `TRIALING` `AWAITING_PAYMENT` `ACTIVE` `CHARGED` `FAILED` `CANCELED` `PENDING_UPGRADE` `DEFAULT`. Tipo: `RECURRING` `ONE_TIME`.  
`paymentType`: `CREDIT_CARD` `PIX` `BOLETO` `PIX_AUTOMATICO`.

### Subcontas — `proposals/write` · `subaccounts/read`

| Método | Path | Função |
|---|---|---|
| POST | `/v1/proposals` | PF ou PJ (campos diferentes). Retorna `formId`. |
| GET | `/v1/proposals/:formId` | Status da proposta |
| GET | `/v1/accounts/subaccounts` | Lista (`dateFrom`, `dateTo`, `page`, `perPage`) |
| GET | `/v1/charges` | Cobranças da conta |
| GET | `/v1/wallet/balance?accountId=` | Saldo |

Não reutilizar e-mail/telefone da master. `financialDetails` (PF) / `financialOwnerDetails` (PJ) obrigatórios — códigos do apêndice na doc. Número da conta chega no webhook `onboarding.create` (`data.account`).

### Carteira — `wallet/*`

| Método | Path | Função |
|---|---|---|
| POST | `/v1/wallet/pay/:chargeId` | **Só sandbox** — dispara o evento de pagamento. Responde `status: "PROCESSING"`; a confirmação chega pelo webhook. Conta não-sandbox → 400 `NOT_SANDBOX_ACCOUNT` |
| POST | `/v1/wallet/withdraw` | Saque Pix. Master: `amount`, `pixKey`, `pixKeyType`. Subconta: + `accountId` |
| GET | `/v1/wallet/transactions` | Extrato. Query: `accountId` (sub), `type`, `category`, `dateFrom`, `dateTo`, `limit`, `nextPageToken` |
| POST | `/v1/wallet/refunds` | Pix: `endToEndId` + `reason` obrigatório. Cartão: `chargeId` |
| GET | `/v1/wallet/refunds?refundId=` | Status da devolução/estorno |

`reason` Pix: `CUSTOMER_REQUEST` `FRAUD` `BANK_ERROR` `PIX_CHANGE_ERROR`. Pix costuma nascer `PROCESSING`; cartão pode vir `CONFIRMED` + `success: true`.


### Cartões de teste em sandbox

Em conta de sandbox nenhuma cobrança chega ao adquirente: **o número do cartão define o resultado**.

| Número | Resultado |
|---|---|
| `4111111111111111` | aprovado |
| `4000000000000002` | recusado pela operadora (`card_declined`) |
| `4000000000000004` | saldo insuficiente (`insufficient_funds`) |
| `4000000000000006` | cartão expirado (`expired_card`) |
| `4000000000000008` | CVV inválido (`invalid_cvv`) |
| `4000000000000010` | suspeita de fraude (`fraud_suspected`) |

Qualquer outro número de 16 dígitos é aprovado. CVV, validade e nome do titular podem ser quaisquer valores válidos. **Em produção o número não muda nada** — quem decide é o emissor.

Os códigos entre parênteses são os `declinedCode` que chegam em `details.declinedCode` no `400 PAYMENT_DECLINED` das rotas de assinatura. Para Pix e boleto, o pagamento em sandbox se dispara com `POST /v1/wallet/pay/:chargeId`.

`pixKeyType`: `CPF` `CNPJ` `EMAIL` `PHONE` `EVP`.

## Webhooks

Cadastro no painel ou via `POST /v1/users/webhooks`. A ValidaPay envia `POST` JSON. Responda **2xx em até 5s** (timeout do envio). Assinatura HMAC: `X-Webhook-Signature`; token opcional: `x-access-token`.

**Não há retry automático** — reenvio manual por `POST /v1/users/webhooks/{webhookEventId}/retry`.

Chave da conta: `accountNumber` em `subscription.*`, `charge.created` e no `payment.success` com `subscriptionId`; `accountId` em `payment.failed`, `refund.*`, `onboarding.*`, `med.*` e no `payment.success` avulso. Leia os dois.

| Evento | Quando |
|---|---|
| `payment.success` | Pagamento confirmado (Pix, boleto, ONE_TIME) |
| `payment.failed` | Falha em cobrança recorrente. Schema próprio: `accountId`, `failed` (string), sem base de assinatura |
| `charge.created` | Cobrança criada. `status` é o da COBRANÇA, não da assinatura |
| `subscription.created` | Recorrência criada, 1º pagamento ainda não confirmado |
| `subscription.trial` | Trial iniciado. Sem objeto `trial` — prazo em `items[].price.trialDays` |
| `subscription.activated` | 1º pagamento ok — ativa |
| `subscription.renewed` | Ciclo ≥ 2 pago |
| `subscription.canceled` | Cancelada (API ou inadimplência). `currentCycle` continua preenchido |
| `subscription.cancel_scheduled` | Cancela no fim do período |
| `subscription.upgraded` | Upgrade pago |
| `subscription.item_added` | Item adicionado e cobrado |
| `subscription.item_removed` | Item removido |
| `subscription.downgrade_scheduled` | Downgrade no próximo ciclo |
| `onboarding.backgroundcheck` | PENDING → APPROVED/REPROVED |
| `onboarding.documentscopy` | Upload de docs |
| `onboarding.proposal` | Proposta APPROVED/REPROVED |
| `onboarding.create` | Subconta criada (`data.account`), status `CONFIRMED` |
| `refund.requested` | Devolução em processamento (`PROCESSING`) |
| `refund.confirmed` | Devolução concluída (`CONFIRMED`) |
| `refund.failed` | Devolução falhou — status enviado é `ERROR`, não `FAILED` |
| `med.*` | Infração e bloqueios do MED. Só assinável via API |

Exemplo `payment.success` (Pix avulso):

```json
{
  "event": "payment.success",
  "chargeId": "cha_1771453171013_fp6iocaxb",
  "amount": 150.0,
  "paymentMethod": "PIX",
  "paymentId": "E1234567820260215120000000000001",
  "paidAt": "2026-02-15T15:30:00.000Z",
  "payer": {
    "name": "Issac Newton",
    "taxId": "12345678900",
    "bank": "260",
    "account": "12345678",
    "branch": "0001",
    "accountType": "CACC"
  }
}
```

Eventos de assinatura incluem `subscriptionId`, `customer`, `items`, `currentCycle`, `timestamp`, `accountNumber`. Trate o handler como idempotente.

## Tokenização de cartão

```bash
npm install @validapay/tokenize
```

```js
import { tokenize } from '@validapay/tokenize';

const result = await tokenize({
  clientId,
  clientSecret,
  card: { number, cardHolderName, cvv, expiration: '12/2030' },
  customer: { name, document, email },
});
// result.paymentMethodId → POST /v1/charges { paymentMethod: "creditcard", paymentMethodId }
```

## Fluxos

**Pix avulso:** OAuth (`pix.cob/write`) → `POST /v1/charges/pix` `{ amount }` → exibir `emv` → webhook `payment.success` ou GET status. Sandbox: `POST /v1/wallet/pay/:chargeId`.

**Checkout + assinatura:** `POST /v1/products` → `POST /v1/charges` com `items` + `paymentMethod` **ou** `POST /v1/checkouts` / `checkout-sessions` → webhook `subscription.activated` → `GET /v1/subscriptions`.

**Marketplace:** `POST /v1/proposals` (PF/PJ) → webhooks de onboarding → `onboarding.create` → `POST /v1/charges/pix` com `split` (e `X-Sub-Account` se a cobrança é do seller).

**Devolução:** Pix: `endToEndId` do pagamento + `reason`. Cartão: `chargeId`. Poll `GET /v1/wallet/refunds?refundId=` se `PROCESSING`.

## Erros frequentes

| HTTP | code | Causa |
|---|---|---|
| 400 | `INVALID_DATA` `INVALID_PRODUCT_DATA` | Campo inválido |
| 400 | `PIX_AUTOMATICO_PJ_ONLY` | Pix Automático em conta PF |
| 400 | `PAYMENT_DECLINED` `PAYMENT_FAILED` | Recusa em upgrade/add item |
| 400 | `SUBSCRIPTION_ALREADY_CANCELED` | DELETE repetido |
| 401/403 | `OWNERSHIP_MISMATCH` `FORBIDDEN` | Subconta/chave de outro titular ou scope |
| 402 | — | Cartão recusado no `POST /v1/charges` |
| 404 | `PRICE_NOT_FOUND` | `priceId` inexistente |
| 409 | `DUPLICATE_CHARGE` | `externalId` repetido; use o `chargeId` retornado |

Envelope típico: `{ "error": { "message", "code", "details", "timestamp" } }`.

## Cliente HTTP mínimo

```js
async function validapay(method, path, { token, body, headers } = {}) {
  const res = await fetch(`${process.env.VALIDAPAY_API}${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${token}`,
      ...(body ? { 'Content-Type': 'application/json' } : {}),
      ...headers,
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  const data = await res.json().catch(() => ({}));
  if (!res.ok) {
    const err = new Error(data?.error?.message || data?.message || res.statusText);
    err.status = res.status;
    err.code = data?.error?.code || data?.code;
    err.body = data;
    throw err;
  }
  return data;
}
```

## Fluxos ponta a ponta

Errar o fluxo é mais comum que errar o campo. Siga estas sequências.

### Pix avulso

1. Token com `pix.cob/write`.
2. `POST /v1/charges/pix` com `{ "amount": 10.00 }`.
3. Use `emv` (copia e cola) ou `qrCode` na interface.
4. Sandbox: dispare o pagamento com `POST /v1/wallet/pay/:chargeId` — responde `PROCESSING`, não confirmação.
5. Aguarde o webhook `payment.success` ou consulte `GET /v1/charges/:chargeId`.

### Venda avulsa com checkout transparente

1. Token com `checkouts/write`.
2. `POST /v1/charges` com `paymentMethod`, `customer` e `items` (ou `amount`).
3. Envie `externalId` como idempotência. Duplicata → `409 DUPLICATE_CHARGE`.
4. Pix/boleto: aguarde `payment.success`. Cartão: a resposta já traz `status: "paid"` ou `402`.

### Assinatura

1. `POST /v1/products` com `prices[].recurrenceType` recorrente → guarde o `priceId`.
2. `POST /v1/charges` (ou checkout) com `items: [{ priceId }]`.
3. Webhook `subscription.created` (`PENDING`) chega.
4. Após o pagamento: `charge.created` → `payment.success` → `subscription.activated`.
5. Gerencie por `GET/PATCH/PUT /v1/subscriptions/...`. **Não existe `POST /v1/subscriptions`.**

### Upgrade de plano

1. `POST /v1/subscriptions/:id/prorata` para calcular — **não cobra**, use para mostrar ao cliente.
2. `PUT /v1/subscriptions/:id/items/:itemId` com o novo `priceId`.
3. Cartão: cobrança síncrona. Pix/boleto: assíncrona, aguarde `payment.success`.
4. Recusa nessas rotas retorna **400** `PAYMENT_DECLINED`/`PAYMENT_FAILED`, não 402.

### Subconta com split

1. `POST /v1/proposals` → `formId`.
2. Aguarde `onboarding.create` → o número da conta vem em `data.account`.
3. Cobrança do seller: `POST /v1/charges/pix` com header `X-Sub-Account` e `split` sem `accountNumber`.
4. Cobrança da master para parceiros: `split` com `accountNumber` de cada recebedor.

### Devolução

1. Pix: `POST /v1/wallet/refunds` com `endToEndId` + `reason`. Nasce `PROCESSING`.
2. Cartão: `POST /v1/wallet/refunds` com `chargeId`. Pode vir `CONFIRMED` direto.
3. Aguarde `refund.confirmed` — não trate a resposta da solicitação como conclusão no Pix.

## Sequências de webhook

| Cenário | Ordem |
|---|---|
| Assinatura com cartão | `subscription.created` → `charge.created` → `payment.success` → `subscription.activated` |
| Assinatura com trial | `subscription.created` → `subscription.trial` → `charge.created` → `payment.success` → `subscription.activated` |
| Renovação | `charge.created` → `payment.success` → `subscription.renewed` |
| Inadimplência | `charge.created` → `payment.failed` (por tentativa) → `subscription.canceled` se o dunning esgotar |
| Cancelamento agendado | `subscription.cancel_scheduled` → `subscription.canceled` |
| Onboarding | `onboarding.backgroundcheck` → `onboarding.documentscopy` → `onboarding.proposal` → `onboarding.create` |

Trate os eventos de forma idempotente e ordene por `timestamp` — a ordem de chegada não é garantida.

## Cinco formatos de erro

A API não usa um formato único. Trate sempre pelo campo de código, nunca pela mensagem.

**1. Envelope (dominante)** — toda exceção lançada nos módulos v1 passa pelo `errorHandler` e sai assim:

```json
{ "error": { "message": "Cobrança duplicada", "code": "DUPLICATE_CHARGE", "details": null, "timestamp": "2026-07-14T21:39:36.322Z" } }
```

**2. OAuth2 (`POST /auth/token`)** — segue a RFC 6749; `error` é uma **string**:

```json
{ "error": "Invalid credentials" }
```

Valores: `Unsupported grant_type`, `Missing client_id, client_secret, or scope` (400), `Invalid credentials` (401), `Unauthorized scope` (403).

**3. Cartão recusado no checkout transparente (402)** — `error` é a razão da recusa, em texto:

```json
{ "success": false, "chargeId": "cha_...", "status": "failed", "error": "Cartão recusado" }
```

**5. Token sem scope (401)** — sem envelope e sem código, e a mensagem engana: o token pode estar válido, faltando só o scope.

```json
{ "message": "TOKEN EXPIRADO OU INVALIDO" }
```

**4. Propostas de subconta** — recusas de proposta retornam sem envelope:

```json
{ "message": "Proposta rejeitada", "code": "FORM_NOT_FOUND" }
```

Na mesma rota, erros de validação usam o envelope do formato 1. Trate os dois.

### Recusa de cartão em assinaturas

Nas rotas de assinatura a recusa vem como **400** (não 402), com o motivo em `details.declinedCode`:

```json
{ "error": { "message": "Cartão recusado por saldo insuficiente", "code": "PAYMENT_DECLINED", "details": { "declinedCode": "insufficient_funds" }, "timestamp": "..." } }
```

`declinedCode`: `insufficient_funds` · `card_declined` · `expired_card` · `invalid_cvv` · `fraud_suspected`

### Erro presente em quase toda rota autenticada

`404 ACCOUNT_NOT_FOUND` — conta não resolvida a partir das credenciais; vale para praticamente qualquer rota autenticada. Há também `404 NOT_FOUND` genérico, usado quando a exceção é lançada sem código próprio. Trate os dois, com fallback para código desconhecido.

Correlação com o pedido, por rota: em `/v1/charges/pix` use `metadata` (persistido, volta no webhook); em `/v1/charges` use `externalId` — ali `metadata` é aceito mas descartado. Guarde sempre um índice local `chargeId` → pedido.

## Cliente HTTP mínimo (continuação)

O cliente acima já cobre os dois formatos: `data?.error?.code || data?.code`.

## Recursos para agentes

| Recurso | URL |
|---|---|
| Contrato OpenAPI 3.1 | `/openapi.json` |
| Índice para IA | `/llms.txt` |
| Documentação completa em texto | `/llms-full.txt` |
| Markdown de qualquer página | acrescente `.md` à URL |
| Collection Postman (JSON válido) | `/collection.json` |
| Environment sandbox | `/ValidaPay-Sandbox.postman_environment.json` |

Prefira `/openapi.json` para gerar código: ele traz tipos, campos obrigatórios, scopes por rota e os payloads de webhook.
