# Sacador — Documentação completa da API REST v1

Este arquivo concatena toda a documentação pública da API do Sacador num único Markdown, pronto para ser entregue a um assistente de IA (Claude, ChatGPT, Cursor, etc.) como contexto.

Versão online: https://sacador.com.br/developers
Gerado em: 2026-05-13T11:22:07Z

---


---

<!-- ============================================================ -->
<!-- Seção: Visão geral (overview) -->
<!-- ============================================================ -->

# API Sacador

Documentação para integradores que vão consumir a API e/ou receber webhooks do Sacador.

| Tópico | O que cobre |
|---|---|
| [Autenticação](./authentication.md) | Como criar tokens de API, formato (`sct_…`), envio em requisições e revogação |
| [Convenções](./conventions.md) | URL base, versionamento, content type, status codes, paginação, rate limit, formato de erro e regras de multi-tenancy comuns a todos os endpoints |
| [Cobranças](./billings.md) | `GET/POST/PATCH/DELETE /v1/billings` + `cancel` e `pay_off` — campos, validações, edibilidade, itens com destruição implícita |
| [Boletos bancários](./bank_billets.md) | `GET /v1/billings/:billing_id/bank_billets` — somente leitura. Campos do boleto registrado no banco (barcode, linha digitável, Pix), status, snapshots de pagador/beneficiário |
| [Contatos](./contacts.md) | `GET/POST/PATCH/DELETE /v1/contacts` — PF vs PJ, normalização aplicada, filtros e busca |
| [Grupos](./groups.md) | `GET/POST/PATCH/DELETE /v1/groups` — recurso de classificação de contatos e cobranças em massa |
| [Webhooks](./webhooks.md) | Headers enviados, esquema de assinatura HMAC-SHA256 e exemplos de validação em Ruby, Node, Python e PHP |

## Onde gerenciar credenciais

Tokens de API e endpoints de webhook são gerenciados em **/integrations**, na aba **API e Webhook**. Cada conta pode ter múltiplos tokens nomeados e até **3 URLs de webhook** (cada uma com seu próprio secret de assinatura).


---

<!-- ============================================================ -->
<!-- Seção: Autenticação (authentication) -->
<!-- ============================================================ -->

# Autenticação

A API do Sacador é autenticada por **tokens portadores (Bearer tokens)** emitidos pela conta.

## Criando um token

1. Acesse **/integrations** → aba **API e Webhook**.
2. Na seção **Tokens de API**, dê um nome descritivo (ex.: `ERP da empresa`, `Integração com loja virtual`) e clique em **Gerar token**.
3. O token em texto puro será exibido **uma única vez** em um banner verde, com botão para copiar.
4. **Guarde o valor em local seguro imediatamente.** Não há como recuperá-lo depois — se você perder, terá que revogar e gerar um novo.

### Formato do token

```
sct_<48 caracteres base58>
```

- Prefixo `sct_` ("sacador token") — facilita identificar o tipo da credencial em logs e secret scanners.
- Comprimento total: **52 caracteres**.
- Alfabeto: base58 (sem `0`, `O`, `I`, `l` para evitar ambiguidade visual).

### O que o Sacador armazena

Apenas o **digest SHA256** do token e os 4 últimos caracteres (para exibir uma máscara como `sct_••••••••XyZ9` na listagem). O texto puro **não é persistido** em banco, log ou backup. Por isso a única exibição acontece no momento da criação.

## Usando o token nas requisições

Envie o token no header HTTP `Authorization`:

```http
GET /v1/billings HTTP/1.1
Host: api.sacador.com.br
Authorization: Bearer sct_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

**Exemplo cURL** (teste rápido para validar seu token — deve retornar `200 OK` com a listagem ou `200` com lista vazia):

```bash
curl -X GET 'https://api.sacador.com.br/v1/billings?per_page=1' \
  -H 'Authorization: Bearer sct_seu_token_aqui'
```

Se receber `401`, revise o header (deve começar com `Bearer ` exatamente, com um espaço); se receber `403`, o token foi revogado ou a conta está inativa.

Erros comuns:

| Status | Significado |
|---|---|
| `401 Unauthorized` | Header ausente, mal formatado, ou token desconhecido/revogado |
| `403 Forbidden` | Token válido mas sem permissão para o recurso |

## Revogando um token

Em **/integrations**, na linha do token, clique em **Revogar**. A revogação:

- É **imediata** — todas as requisições subsequentes recebem `401`.
- É **definitiva** — não há "des-revogar". Se precisar usar de novo, gere um token novo.
- **Não afeta os outros tokens** da conta.

> O Sacador mantém um cache de autenticação de 15 minutos para reduzir consultas ao banco em rotas de alto volume. Esse cache é invalidado explicitamente no momento da revogação, então tokens revogados deixam de funcionar imediatamente — não há janela de tolerância.

## Boas práticas

- **Um token por sistema integrado.** Se um sistema for comprometido, basta revogar o token correspondente sem afetar os outros.
- **Rotação periódica.** Crie um token novo, atualize o sistema integrado, depois revogue o antigo.
- **Nunca commite o token no código-fonte.** Use variáveis de ambiente, gerenciadores de secret (Vault, AWS Secrets Manager, etc.) ou o sistema de credentials do seu framework.
- **Monitore o uso** dos tokens no seu lado: requisições inesperadas podem indicar que o token vazou.

## Diferença entre token de API e secret de webhook

São coisas diferentes, com propósitos opostos:

| | Token de API (`sct_…`) | Secret de webhook (`whsec_…`) |
|---|---|---|
| Quem usa | **Você** envia ao Sacador | **Você** valida assinaturas vindas do Sacador |
| Direção | Cliente → Sacador | Sacador → seu sistema |
| Onde aparece | Header `Authorization: Bearer …` | Header `X-Sacador-Signature` (assinatura) |
| Armazenamento no Sacador | SHA256 (one-way, não recuperável) | Encriptado simétrico (necessário para assinar) |
| Detalhes | Este documento | [webhooks.md](./webhooks.md) |


---

<!-- ============================================================ -->
<!-- Seção: Convenções (conventions) -->
<!-- ============================================================ -->

# Convenções da API

Regras válidas para **todos os endpoints** sob `/v1/*`. Cada endpoint individual pode acrescentar regras específicas, mas nunca contradiz o que está aqui.

## URL base e versionamento

```
https://api.sacador.com.br/v1/<recurso>
```

A versão (`v1`) está no path. Quebras de contrato → nova versão (`v2`); adições retrocompatíveis (novos campos, novos endpoints) entram na `v1` sem aviso.

## Autenticação

Todas as requisições exigem header:

```http
Authorization: Bearer sct_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Accept: application/json
```

Detalhes do token, criação e revogação: ver [authentication.md](./authentication.md).

## Content type

- **Requisições com corpo** (`POST`/`PATCH`/`PUT`): envie `Content-Type: application/json` **ou** `application/x-www-form-urlencoded`. Os dois são aceitos.
- **Respostas:** sempre `application/json; charset=utf-8`.

## Status codes

| Code | Quando |
|---|---|
| `200 OK` | `GET`, `PATCH`, `POST /cancel` e `POST /pay_off` bem-sucedidos |
| `201 Created` | `POST` que criou o recurso |
| `204 No Content` | `DELETE` bem-sucedido — corpo vazio |
| `400 Bad Request` | JSON malformado |
| `401 Unauthorized` | Header `Authorization` ausente, mal-formado ou com token desconhecido/revogado |
| `403 Forbidden` | Token válido, mas a conta dona do token está com `status` diferente de `active` (`overdue`, `locked`, `canceled`) |
| `404 Not Found` | Recurso inexistente **ou pertencente a outra conta** — o Sacador não diferencia os dois casos, para não vazar a existência de registros de outras contas |
| `422 Unprocessable Content` | Falha de validação ou parâmetro obrigatório ausente |
| `429 Too Many Requests` | Limite de requisições excedido — ver seção [Rate limit](#rate-limit) |
| `500 Internal Server Error` | Erro inesperado no Sacador. Retentar com backoff |

## Formato de erro

Toda resposta `4xx` retorna JSON em um destes dois formatos:

**Erros de validação ou autorização lógica** (`422`):

```json
{
  "status": 422,
  "errors": [
    "Descrição não pode ficar em branco",
    "CPF inválido"
  ]
}
```

`errors` é sempre um array de strings (já localizadas em português). Cada string é uma mensagem independente e exibível ao usuário final.

**Erros de autenticação, autorização e roteamento** (`401`, `403`, `404`):

```json
{
  "status": 401,
  "message": "Unauthorized"
}
```

**Rate limit** (`429`):

```json
{
  "error": "Excedido o limite de requisições. Por favor, tente novamente mais tarde."
}
```

Acompanhado do header `Retry-After: <segundos>`.

## Paginação

Todos os endpoints `GET /v1/<recurso>` (listagens) são paginados. Os parâmetros de paginação **são query params** — não use headers.

### Query params

| Param | Default | Valor aceito | Comportamento fora da faixa |
|---|---|---|---|
| `page` | `1` | Inteiro ≥ 1 | Valores menores que 1 ou inválidos são tratados como página 1 |
| `per_page` | `25` | Inteiro entre `1` e `100` | Valores > 100 são limitados a 100; `0`, negativo ou não-numérico cai para o default `25` |

Exemplo:

```http
GET /v1/contacts?page=2&per_page=50
```

### Response headers

Toda listagem retorna os mesmos quatro headers:

| Header | Significado |
|---|---|
| `X-Total-Count` | Total de registros (já considerando os filtros aplicados) |
| `X-Page` | Página retornada nesta resposta |
| `X-Per-Page` | Tamanho efetivo da página (já clampado para `[1, 100]`) |
| `X-Total-Pages` | Número total de páginas |

> Os totais consideram **apenas os registros da sua conta** — o isolamento é aplicado antes da contagem.

### Estratégia recomendada

```text
1. Inicialize page = 1
2. Faça GET com page atual
3. Processe os items
4. Se page >= X-Total-Pages → fim
5. Senão page += 1 e volte a 2
```

Para sincronizações periódicas, prefira combinar paginação com os filtros de data (`start_created_at` / `end_created_at` quando disponíveis) para limitar a varredura ao intervalo novo.

## Rate limit

- **60 requisições por minuto, por IP de origem** (não por token).
- Inclui todas as rotas `/v1/*`.
- Quando excedido: `429` com header `Retry-After` em segundos. Espere e retente — não há janela deslizante, o contador zera no início do próximo minuto-cheio.
- Se múltiplos sistemas integrados saem do mesmo IP, o limite é compartilhado. Para volumes maiores, distribua a integração em IPs distintos ou abra um canal com o Sacador.

## Filtros e busca

Endpoints de listagem que aceitam `s` (search) interpretam a string como **LIKE substring** do lado do servidor. Não há sintaxe especial — `s=joão` retorna registros contendo `"joão"` no campo indexado para busca daquele recurso. Veja cada endpoint para saber em qual campo a busca é aplicada.

## Multi-tenancy

Toda requisição é automaticamente restrita à conta dona do token. Você **não consegue**:

- Ler, criar, atualizar ou apagar registros de outra conta.
- Atribuir `account_id` em `POST`/`PATCH` — esse parâmetro é silenciosamente ignorado e sempre forçado para a conta autenticada.
- Referenciar IDs de outra conta em FKs (`contact_id`, `group_id`, `bank_rule_id`, `bank_account_id`). Tentativas retornam `422` com erro de associação.

Recursos de outra conta acessados por ID direto retornam `404`, idêntico ao caso de ID inexistente.

## Boas práticas

- **Idempotência:** as APIs **não** implementam chaves de idempotência. Em caso de timeout, antes de retentar um `POST`, consulte o `GET` correspondente (busque pelo `your_code` que você gerou, por exemplo) para detectar criação duplicada.
- **Backoff em 5xx e 429:** use exponencial com jitter. Em `429`, respeite o `Retry-After`.
- **Não dependa de campos não documentados.** Campos não listados na referência podem mudar formato ou ser removidos sem aviso.
- **Versione seus payloads.** O Sacador pode adicionar campos nas respostas. Faça parsing tolerante a chaves desconhecidas.
- **Use `your_code`** como chave de correlação com o seu sistema, e busque por ele depois — é um campo livre indexável (até 20 chars).


---

<!-- ============================================================ -->
<!-- Seção: Contatos (contacts) -->
<!-- ============================================================ -->

# Contatos

Contatos representam clientes (`customer`) e fornecedores (`supplier`) da sua conta. Para cobranças individuais (`billing_issue_type=individual`), o `contact_id` do cliente é obrigatório.

> Veja [conventions.md](./conventions.md) para paginação, autenticação, erros, rate limit e regras de isolamento entre contas.

## Modelo

| Campo | Tipo | Observações |
|---|---|---|
| `id` | integer | |
| `contact_type` | string | `"customer"` ou `"supplier"`. Singular — não confundir com o plural usado em grupos |
| `group_id` | integer \| null | FK para `Group`. Quando informado, o grupo precisa ser da mesma conta |
| `code` | string \| null | Identificador livre, até 20 chars |
| `active` | boolean | Default `true` |
| `email` | string \| null | Formato de email válido, até 160 chars |
| `legal_entity` | boolean | `true` = pessoa jurídica (CNPJ); `false` = pessoa física (CPF) |
| `full_name` | string | Pessoa física: obrigatório, até 255 chars, regex de nome completo (mínimo nome + sobrenome). Pessoa jurídica: opcional |
| `company_name` | string | Obrigatório se `legal_entity=true`, até 255 chars |
| `trademark` | string | Nome fantasia, até 255 chars, opcional para PJ |
| `birth_date` | date | Pessoa precisa ter ≥ 18 anos no momento do cadastro |
| `cpf` | string | Pessoa física: obrigatório, exatamente 11 dígitos, CPF válido. **Único por (conta, contact_type)** |
| `cnpj` | string | Pessoa jurídica: obrigatório, 14 caracteres (números ou letras A-Z; CNPJ alfanumérico válido a partir de jul/2026). **Único por (conta, contact_type)** |
| `business_phone_area`, `business_phone_number` | string | DDD 2 dígitos; número 8 ou 9 dígitos |
| `cell_phone_area`, `cell_phone_number` | string | Idem |
| `address_postal_code` | string | CEP com 9 caracteres (formato `00000-000`) |
| `address_street` | string | 5..255 chars |
| `address_number` | string | 1..10 chars |
| `address_complement` | string | até 255 chars |
| `address_district` | string | 3..255 chars |
| `address_city` | string | 2..255 chars |
| `address_state` | string | Sigla de UF (`SP`, `RJ`, …). Normalizada para maiúsculas no servidor |
| `observation` | string \| null | Texto livre |
| `description` | string | **Derivado**: `company_name` se PJ, `full_name` se PF. Não enviar — calculado pelo Sacador |
| `document` | string | **Derivado**: `cnpj` se PJ, `cpf` se PF |
| `created_at`, `updated_at` | ISO-8601 | |

### Regras de PF vs PJ

O campo `legal_entity` controla quais validações são aplicadas:

- `legal_entity=false` (PF): exige `cpf`, exige `full_name` no formato regex; `cnpj` e `company_name` são opcionais/ignorados na validação cruzada.
- `legal_entity=true` (PJ): exige `cnpj`, exige `company_name`.

Em ambos os casos, **endereço completo é obrigatório** (CEP, rua, número, bairro, cidade, UF) e o telefone (qualquer um dos pares) é opcional.

## Endpoints

### `GET /v1/contacts`

Lista os contatos da conta autenticada, ordenados por `description` ascendente.

**Query params**:

| Param | Tipo | Comportamento |
|---|---|---|
| `contact_type` | `customer` \| `supplier` | Filtra por tipo |
| `group_id` | integer \| `"0"` | Filtra por grupo. Use `"0"` para listar contatos **sem grupo** (group_id IS NULL) |
| `active` | `true` \| `false` | Filtra por status |
| `filter` | string | Define o campo da busca: `contact` (default, busca por `description`), `document` (busca por CPF/CNPJ — só dígitos), `email` (busca por email começando com `s`), `code` (igualdade exata) |
| `s` | string | Termo da busca, interpretado conforme `filter` |
| `page`, `per_page` | int | Padrão de paginação |

**Exemplo cURL**:

```bash
curl -X GET 'https://api.sacador.com.br/v1/contacts?contact_type=customer&active=true&page=1' \
  -H 'Authorization: Bearer sct_seu_token_aqui'
```

**Resposta** `200 OK`:

```json
{
  "contacts": [
    {
      "id": 101,
      "group_id": 12,
      "contact_type": "customer",
      "code": "C-0042",
      "active": true,
      "email": "cliente@exemplo.com",
      "legal_entity": false,
      "full_name": "Maria Silva",
      "company_name": null,
      "trademark": null,
      "birth_date": "1990-03-15",
      "cpf": "12345678901",
      "cnpj": null,
      "business_phone_area": "11",
      "business_phone_number": "30001234",
      "cell_phone_area": "11",
      "cell_phone_number": "999998888",
      "address_postal_code": "01310-100",
      "address_street": "Avenida Paulista",
      "address_number": "1000",
      "address_complement": "Apto 42",
      "address_district": "Bela Vista",
      "address_city": "São Paulo",
      "address_state": "SP",
      "observation": null,
      "description": "Maria Silva",
      "document": "12345678901",
      "created_at": "2026-04-01T10:00:00.000Z",
      "updated_at": "2026-04-01T10:00:00.000Z"
    }
  ]
}
```

---

### `GET /v1/contacts/:id`

**Exemplo cURL**:

```bash
curl -X GET 'https://api.sacador.com.br/v1/contacts/101' \
  -H 'Authorization: Bearer sct_seu_token_aqui'
```

**Resposta** `200 OK`: objeto idêntico ao item da listagem.

**Erros**: `404` se inexistente ou de outra conta.

---

### `POST /v1/contacts`

Cria um contato.

**Body** (envelopado em `contact`):

```json
{
  "contact": {
    "contact_type": "customer",
    "legal_entity": false,
    "full_name": "Maria Silva",
    "cpf": "123.456.789-01",
    "email": "maria@exemplo.com",
    "address_postal_code": "01310-100",
    "address_street": "Avenida Paulista",
    "address_number": "1000",
    "address_district": "Bela Vista",
    "address_city": "São Paulo",
    "address_state": "sp"
  }
}
```

**Campos aceitos** (qualquer outro campo enviado é ignorado, incluindo `account_id`):

`contact_type`, `group_id`, `code`, `active`, `email`, `legal_entity`, `full_name`, `birth_date`, `cpf`, `cnpj`, `company_name`, `trademark`, `business_phone_area`, `business_phone_number`, `cell_phone_area`, `cell_phone_number`, `address_postal_code`, `address_street`, `address_number`, `address_complement`, `address_district`, `address_city`, `address_state`, `observation`.

### Normalização aplicada pelo servidor

- `cpf`: removidos todos os caracteres não-dígito.
- `cnpj`: removidos caracteres fora de `[A-Z0-9]` e convertido para maiúsculas.
- `email`: minúsculo e sem espaços.
- `address_state`: maiúsculo.
- `full_name`, `company_name`, `trademark`, `address_*`: espaços em excesso são colapsados (`squishize`).

**Exemplo cURL** (pessoa física):

```bash
curl -X POST 'https://api.sacador.com.br/v1/contacts' \
  -H 'Authorization: Bearer sct_seu_token_aqui' \
  -H 'Content-Type: application/json' \
  -d '{
    "contact": {
      "contact_type": "customer",
      "legal_entity": false,
      "full_name": "Maria Silva",
      "cpf": "123.456.789-01",
      "email": "maria@exemplo.com",
      "cell_phone_area": "11",
      "cell_phone_number": "999998888",
      "address_postal_code": "01310-100",
      "address_street": "Avenida Paulista",
      "address_number": "1000",
      "address_district": "Bela Vista",
      "address_city": "São Paulo",
      "address_state": "SP"
    }
  }'
```

**Exemplo cURL** (pessoa jurídica):

```bash
curl -X POST 'https://api.sacador.com.br/v1/contacts' \
  -H 'Authorization: Bearer sct_seu_token_aqui' \
  -H 'Content-Type: application/json' \
  -d '{
    "contact": {
      "contact_type": "customer",
      "legal_entity": true,
      "company_name": "Acme Comércio Ltda",
      "trademark": "Acme",
      "cnpj": "12.345.678/0001-90",
      "email": "financeiro@acme.com.br",
      "address_postal_code": "04538-132",
      "address_street": "Rua Funchal",
      "address_number": "263",
      "address_district": "Vila Olímpia",
      "address_city": "São Paulo",
      "address_state": "SP"
    }
  }'
```

**Resposta** `201 Created`: corpo idêntico a um item de `GET /v1/contacts` (todos os campos do [Modelo](#modelo)).

**Erros** `422`:
- CPF/CNPJ inválido ou já cadastrado (mesma conta + mesmo `contact_type`)
- `full_name` fora do regex (PF)
- `company_name` fora do regex (PJ)
- Endereço incompleto
- Email mal formado
- `group_id` aponta para grupo de outra conta

---

### `PATCH /v1/contacts/:id`

Atualiza um contato. Mesmos campos do `POST`. Campos omitidos preservam o valor atual.

**Exemplo cURL**:

```bash
curl -X PATCH 'https://api.sacador.com.br/v1/contacts/101' \
  -H 'Authorization: Bearer sct_seu_token_aqui' \
  -H 'Content-Type: application/json' \
  -d '{
    "contact": {
      "email": "novo-email@exemplo.com",
      "cell_phone_number": "988887777"
    }
  }'
```

**Resposta** `200 OK`: corpo idêntico a um item de `GET /v1/contacts`.

**Erros**: `404` (conta errada), `422` (validação).

---

### `DELETE /v1/contacts/:id`

**Exemplo cURL**:

```bash
curl -X DELETE 'https://api.sacador.com.br/v1/contacts/101' \
  -H 'Authorization: Bearer sct_seu_token_aqui'
```

**Resposta** `204 No Content`.

**Erros**:
- `404` — contato inexistente ou de outra conta
- `422` — contato possui **cobranças** (`billings`), **contas a pagar/receber** (`bills`) ou **lançamentos de caixa** (`cashiers`) associados. A exclusão é bloqueada (`restrict_with_error`)


---

<!-- ============================================================ -->
<!-- Seção: Grupos (groups) -->
<!-- ============================================================ -->

# Grupos

Grupos servem para classificar contatos (clientes ou fornecedores) por afinidade e, no contexto de cobranças, para emitir cobranças em massa (`billing_issue_type=bulk`) endereçadas a todos os contatos do grupo.

> Antes de ler, veja as regras gerais em [conventions.md](./conventions.md) — paginação, autenticação, erros, rate limit e isolamento entre contas seguem o padrão da API.

## Modelo

| Campo | Tipo | Observações |
|---|---|---|
| `id` | integer | Gerado pelo Sacador |
| `contact_type` | string | `"customers"` (clientes) ou `"suppliers"` (fornecedores). **Não confunda** com o `contact_type` de contatos, que é singular (`customer`/`supplier`) |
| `description` | string | Nome do grupo. Obrigatório, até 255 caracteres, **único por conta** (case-insensitive) |
| `created_at`, `updated_at` | ISO-8601 | |

## Endpoints

### `GET /v1/groups`

Lista os grupos da conta autenticada, ordenados por `contact_type` ascendente e depois `description` ascendente.

**Query params**:

| Param | Tipo | Comportamento |
|---|---|---|
| `contact_type` | string | Se presente, filtra exatamente por `"customers"` ou `"suppliers"` |
| `s` | string | Busca por substring em `description` (LIKE `%s%`) |
| `page`, `per_page` | int | Padrão de paginação |

**Exemplo cURL**:

```bash
curl -X GET 'https://api.sacador.com.br/v1/groups?contact_type=customers&page=1&per_page=25' \
  -H 'Authorization: Bearer sct_seu_token_aqui'
```

**Resposta** `200 OK`:

```json
{
  "groups": [
    {
      "id": 12,
      "contact_type": "customers",
      "description": "Clientes Premium",
      "created_at": "2026-04-01T10:00:00.000Z",
      "updated_at": "2026-04-15T14:32:11.000Z"
    }
  ]
}
```

Headers `X-Total-Count`, `X-Page`, `X-Per-Page`, `X-Total-Pages` conforme [conventions.md](./conventions.md#paginação).

---

### `GET /v1/groups/:id`

Retorna um grupo específico.

**Exemplo cURL**:

```bash
curl -X GET 'https://api.sacador.com.br/v1/groups/12' \
  -H 'Authorization: Bearer sct_seu_token_aqui'
```

**Resposta** `200 OK`: corpo idêntico ao item da listagem.

**Erros**: `404 Not Found` se o grupo não existir ou pertencer a outra conta.

---

### `POST /v1/groups`

Cria um grupo.

**Body**:

```json
{
  "group": {
    "contact_type": "customers",
    "description": "Clientes Premium"
  }
}
```

**Campos aceitos**:

| Campo | Obrigatório | Regras |
|---|---|---|
| `contact_type` | sim | `"customers"` ou `"suppliers"` |
| `description` | sim | 1..255 caracteres, único dentro da conta (case-insensitive) |

Qualquer campo não listado (incluindo `account_id`) é silenciosamente ignorado.

**Exemplo cURL**:

```bash
curl -X POST 'https://api.sacador.com.br/v1/groups' \
  -H 'Authorization: Bearer sct_seu_token_aqui' \
  -H 'Content-Type: application/json' \
  -d '{
    "group": {
      "contact_type": "customers",
      "description": "Clientes Premium"
    }
  }'
```

**Resposta** `201 Created`: corpo idêntico a um item de `GET /v1/groups` (todos os campos do [Modelo](#modelo)).

**Erros** `422 Unprocessable Content`:
- `contact_type` inválido
- `description` em branco, > 255, ou já existente na conta

---

### `PATCH /v1/groups/:id` (ou `PUT`)

Atualiza um grupo. Aceita os mesmos campos do `POST`. Campos omitidos no body são preservados.

**Exemplo cURL**:

```bash
curl -X PATCH 'https://api.sacador.com.br/v1/groups/12' \
  -H 'Authorization: Bearer sct_seu_token_aqui' \
  -H 'Content-Type: application/json' \
  -d '{
    "group": {
      "description": "Clientes Premium VIP"
    }
  }'
```

**Resposta** `200 OK`: corpo idêntico a um item de `GET /v1/groups`.

**Erros**:
- `404` — grupo de outra conta ou inexistente
- `422` — mesmas regras do `POST`

---

### `DELETE /v1/groups/:id`

Apaga um grupo.

**Exemplo cURL**:

```bash
curl -X DELETE 'https://api.sacador.com.br/v1/groups/12' \
  -H 'Authorization: Bearer sct_seu_token_aqui'
```

**Resposta** `204 No Content`: corpo vazio.

**Erros**:
- `404` — grupo de outra conta ou inexistente
- `422 Unprocessable Content` — o grupo possui **cobranças associadas**. A exclusão é bloqueada (`dependent: :restrict_with_error`). Para apagar, primeiro reassocie ou cancele as cobranças. Contatos que apontavam para o grupo têm seu `group_id` setado para `null` automaticamente, então essa não é uma razão para `422`.

```json
{
  "status": 422,
  "errors": ["Não é possível excluir o registro pois existem cobranças dependentes"]
}
```


---

<!-- ============================================================ -->
<!-- Seção: Cobranças (billings) -->
<!-- ============================================================ -->

# Cobranças

Cobranças (`billings`) são o recurso central da API: representam um valor a receber de um contato ou grupo, emitido via uma regra bancária (`bank_rule`) que define qual banco/convênio gera o boleto.

> Veja [conventions.md](./conventions.md) para paginação, autenticação, erros, rate limit e isolamento entre contas. Os campos do recurso `contact` e `group` aninhados na resposta seguem o que está em [contacts.md](./contacts.md) e [groups.md](./groups.md).

## Modelo

### Tipos: `billing_type`

A API só opera com **cobranças avulsas** — toda cobrança criada via API tem `billing_type="billing"`.

Cobranças recorrentes (`schedule`) e carnês (`carne`) podem aparecer no `GET` se já existirem (criadas pela interface web), mas o `POST` força sempre `billing` e ignora qualquer valor enviado em `billing_type`. Para gerenciar `schedule`/`carne` via API, abra uma issue.

### Modos de emissão: `billing_issue_type`

| Valor | Comportamento |
|---|---|
| `individual` (default) | Cobrança endereçada a **um único contato** (`contact_id` obrigatório). `group_id` é zerado pelo servidor |
| `bulk` | Cobrança em massa para **todos os contatos de um grupo** (`group_id` obrigatório). `contact_id` é zerado pelo servidor |

### Status

`status` é a coluna persistida; `virtual_status` é o status mostrado nas respostas, e equivale a `status` exceto quando `status="opened"` e `due_date < hoje` — nesse caso `virtual_status="expired"`.

| `status` | Significado |
|---|---|
| `opened` | Cobrança em aberto, aguardando pagamento |
| `under_review` | Em análise pelo banco (intermediário) |
| `awaiting_capture` | Aguardando captura no provedor (gateway digital) |
| `protested` | Protestada |
| `in_dispute` | Em disputa |
| `paid` | Paga via gateway |
| `available` | Valor disponível (compensado) |
| `discharged` | Baixada manualmente (`POST /pay_off`) |
| `canceled` | Cancelada |
| `returned` | Devolvida pelo banco |

Valores adicionais expostos apenas em `virtual_status`:
- `expired` — derivado, não persiste.

### Campos retornados

| Campo | Tipo |
|---|---|
| `id`, `code`, `your_code`, `reference` | int / string |
| `status` | string (ver tabela acima) |
| `virtual_status` | string (status persistido + derivação `expired`) |
| `billing_type`, `billing_issue_type` | string |
| `amount` | decimal — **calculado pelo servidor** a partir dos `billing_items` (`sum(quantity * amount)`) |
| `due_date` | date — se cair em sábado, ajustado para segunda; se domingo, para segunda |
| `competence` | string `MM/YYYY` (ou null) |
| `evincive` | string — texto descritivo, 10..80 chars, sem caracteres especiais |
| `observation` | string até 255 |
| `discount_type` | `percentage` ou `fixed_amount` |
| `discount`, `discount2` | decimal 0..999999999 |
| `discount_antecedence`, `discount_antecedence2` | inteiro 0..365 dias antes do vencimento. O segundo desconto vale para uma janela menor: `discount_antecedence > discount_antecedence2` (ou `discount_antecedence2 = 0`) e `discount > discount2` |
| `penalty_type` | `percentage` ou `fixed_amount` |
| `penalty` | decimal 0..999999999 |
| `cancel_penalty` | boolean — quando `true`, anula juros e multa após vencido |
| `interest_type` | `percentage` ou `fixed_amount` |
| `interest_period` | `monthly` ou `daily` |
| `interest` | decimal 0..999999999 |
| `instruction_type` | `protest` ou `devolution` |
| `instruction_days` | inteiro 0..99 |
| `bank_rule_id`, `contact_id`, `group_id` | FKs (precisam ser da mesma conta) |
| `installments`, `installment_type`, `installment` | int / string — só preenchidos em carnês legados criados pelo admin web. Sempre `1` / `"none"` / `1` em cobranças criadas via API |
| `gw_payment_link` | string \| null — link gerado pelo gateway externo (PagSeguro, MercadoPago) para o pagamento. Use isso pra redirecionar o cliente ou abrir em iframe |
| `gw_received_with` | string \| null — método com que o pagamento foi recebido (`credit_card`, `bank_billet`, `pix`, etc.) |
| `gw_payment_date` | date \| null — data do pagamento no gateway |
| `gw_gross_amount` | decimal — valor bruto recebido |
| `gw_fee_amount` | decimal — tarifa cobrada pelo gateway |
| `gw_net_amount` | decimal — valor líquido (`gross - fee`) |
| `gw_discount_amount` | decimal — desconto aplicado pelo gateway |
| `gw_extra_amount` | decimal — acréscimos (juros/multa) aplicados pelo gateway |
| `gw_escrow_end_date` | datetime \| null — fim do período de retenção (escrow) no gateway |
| `gw_installment_count` | int \| null — número de parcelas escolhidas no checkout |
| `gw_payment_method_type` | int \| null — código numérico do tipo de método de pagamento no gateway |
| `gw_payment_method_code` | int \| null — código numérico do método de pagamento no gateway |
| `gw_type_id` | int \| null — identificador do tipo no gateway |
| `discount_amount`, `extra_amount`, `amount_payable` | **calculados** com base em hoje (descontos antecipados, juros/multa por atraso) |
| `days_overdue` | inteiro (negativo se ainda não venceu) |
| `overdue` | boolean — `days_overdue > 0` e `virtual_status in (opened, expired)` |
| `paid` | boolean — `virtual_status in (paid, available, discharged)` |
| `display_amount_payable` | decimal — versão "congelada" de `amount_payable`: usa a data do registro do boleto AFPAY quando aplicável, ou `gw_gross_amount - gw_discount_amount + gw_extra_amount` quando pago. Use este valor para exibir ao cliente final no checkout |
| `return_date` | `due_date + instruction_days` se `instruction_type=devolution`; caso contrário `null` |
| `redirect_url` | URL para onde o pagador é redirecionado após pagar no checkout do Sacador (eco do que foi configurado) |
| `notification_url` | URL adicional de notificação configurada na cobrança (eco do que foi configurado) |
| `created_at`, `updated_at` | ISO-8601 |
| `contact` | objeto `{id, description, email, document}` ou ausente |
| `group` | objeto `{id, description}` ou ausente |
| `billing_items` | array — **só aparece em `show`, `create`, `update`, `cancel` e `pay_off`**. Não retornado na listagem (`GET /v1/billings`). Ver [`billing_items`](#billing_items) |
| `billing_reminder_deliveries` | array de tentativas de notificação — **só aparece em `show`, `create`, `update`, `cancel` e `pay_off`**. Ver [`billing_reminder_deliveries`](#billing_reminder_deliveries) |
| `bank_billets` | array de boletos bancários — **só aparece em `show`, `create`, `update`, `cancel` e `pay_off`**. Formato detalhado em [bank_billets.md](./bank_billets.md). Para listagem dedicada use `GET /v1/billings/:billing_id/bank_billets` |

### `billing_items`

Cada cobrança tem **pelo menos 1 item**.

| Campo | Tipo | Regras |
|---|---|---|
| `id` | int | Atribuído pelo servidor após criação |
| `description` | string | Obrigatório, até 100 chars |
| `quantity` | int | 1..9999999 |
| `amount` | decimal | ≥ 0,01 |

O `amount` total da cobrança é sempre `sum(quantity * amount)` dos itens — não envie `amount` no nível da cobrança, é ignorado.

### `billing_reminder_deliveries`

Histórico de envios de lembretes (email/WhatsApp). Cada item:

```json
{
  "id": 5,
  "channel": "email",
  "status": "delivered",
  "delivered_at": "2026-04-12T09:00:00.000Z",
  "error_message": null,
  "attempts_count": 1,
  "created_at": "2026-04-12T09:00:00.000Z",
  "updated_at": "2026-04-12T09:00:01.000Z"
}
```

Read-only. Use para auditoria, não para disparar lembretes.

## Checkout transparente

Para construir um checkout próprio (sem redirecionar para o Sacador), use a seguinte combinação de campos:

| Necessidade | Onde está |
|---|---|
| Valor a cobrar (já com desconto/juros aplicados) | `display_amount_payable` em `GET /v1/billings/:id` |
| Boleto bancário (linha digitável, barcode, QR Pix) | Itens do array `bank_billets` — ver [bank_billets.md](./bank_billets.md) |
| Link do gateway (PagSeguro/MercadoPago) | `gw_payment_link` |
| Confirmação de pagamento | Webhook `billing.updated` (ver [webhooks.md](./webhooks.md)) — quando o `status` muda para `paid`/`available`/`discharged` |
| Reconciliação financeira do que efetivamente entrou | Campos `gw_gross_amount`, `gw_fee_amount`, `gw_net_amount`, `gw_payment_date`, `gw_received_with` |
| Redirecionar pagador pós-pagamento | Configure `redirect_url` no `POST` ou `PATCH` — o Sacador redireciona ao final do fluxo |
| Notificação adicional além dos webhooks | Configure `notification_url` |

Fluxo típico:

1. `POST /v1/billings` com `redirect_url` apontando para sua página de obrigado e `your_code` para correlação.
2. Mostre `display_amount_payable` e ofereça os métodos disponíveis a partir de `bank_billets[].pix_qr_code`, `bank_billets[].digitable_line` e/ou redirect para `gw_payment_link`.
3. Aguarde o webhook `billing.updated` para confirmar e baixar internamente no seu sistema.
4. Para reconciliar valores recebidos, use `gw_gross_amount` e `gw_net_amount`.

## Quando uma cobrança é editável

Determina o que pode ser alterado em `PATCH /v1/billings/:id`. Uma cobrança é editável quando **todas** as condições abaixo valem:

- não é `carne` (carnês nunca são editáveis); **e**
- é `schedule` (sempre editável), **ou** é `billing` no status `opened`/`expired` com o boleto bancário (quando existir) ainda editável e não está em gateway PagSeguro.

Em cobranças **não-editáveis**, o `PATCH` aceita **somente** dois campos, e apenas se o status atual permitir (`opened` ou `expired`):

- `due_date` — reagendamento
- `cancel_penalty` — zerar juros/multa

Qualquer outro campo enviado nessa situação é silenciosamente ignorado.

## Endpoints

### `GET /v1/billings`

Lista cobranças da conta. Ordem: `id DESC`. Pré-carrega `bank_billet`, `contact`, `group`, `billing_items` e `billing_reminder_deliveries` para evitar N+1.

**Query params**:

| Param | Tipo | Comportamento |
|---|---|---|
| `billing_type` | string | Default `"billing"`. Use `"schedule"` ou `"carne"` para listar recorrências/carnês legados criados pelo admin web |
| `status` | string ou string separada por espaços | Aceita os valores de `status` + os agregados `opened` (apenas com `due_date >= hoje`), `expired` (status=opened, `due_date < hoje`), `paid` (paid+available). Múltiplos valores: `?status=opened%20under_review` |
| `contact_id` | int | Filtra por contato |
| `group_id` | int | Filtra cobranças endereçadas a contatos do grupo |
| `billing_id` | int | Filtra parcelas filhas de um carnê (campo `billing_id` próprio = id do carnê pai) |
| `your_code` | string | Igualdade exata |
| `start_created_at`, `end_created_at` | date `YYYY-MM-DD` | Faixa por data de criação (ambos obrigatórios para o filtro ativar) |
| `start_due_date`, `end_due_date` | date `YYYY-MM-DD` | Faixa por data de vencimento (ambos obrigatórios) |
| `s` | string | Busca por documento (CPF/CNPJ) **OU** `description` do contato **OU** `evincive` (LIKE) |
| `page`, `per_page` | int | Padrão de paginação |

**Exemplo cURL**:

```bash
curl -X GET 'https://api.sacador.com.br/v1/billings?status=opened&page=1&per_page=25' \
  -H 'Authorization: Bearer sct_seu_token_aqui'
```

**Resposta** `200 OK`:

```json
{
  "billings": [
    {
      "id": 123,
      "code": "ABC123",
      "your_code": "ERP-2026-0001",
      "reference": "Mensalidade",
      "status": "opened",
      "virtual_status": "opened",
      "billing_type": "billing",
      "billing_issue_type": "individual",
      "amount": "150.00",
      "due_date": "2026-06-10",
      "competence": "05/2026",
      "evincive": "Mensalidade de maio",
      "observation": null,
      "penalty_type": "percentage",
      "penalty": "2.0",
      "cancel_penalty": false,
      "interest_type": "percentage",
      "interest_period": "monthly",
      "interest": "1.0",
      "instruction_type": "protest",
      "instruction_days": 5,
      "bank_rule_id": 7,
      "contact_id": 101,
      "group_id": null,
      "discount_amount": "0.0",
      "extra_amount": "0.0",
      "amount_payable": "150.00",
      "days_overdue": -30,
      "overdue": false,
      "return_date": null,
      "created_at": "2026-05-11T10:00:00.000Z",
      "updated_at": "2026-05-11T10:00:00.000Z",
      "contact": {
        "id": 101,
        "description": "Maria Silva",
        "email": "maria@exemplo.com",
        "document": "12345678901"
      }
    }
  ]
}
```

> Note que `billing_items`, `billing_reminder_deliveries` e `bank_billets` **não** aparecem na listagem. Para obtê-los use `GET /v1/billings/:id` (que retorna tudo) ou os endpoints dedicados.

---

### `GET /v1/billings/:id`

Retorna uma cobrança individual. Esta é a **resposta canônica** usada também por `POST`, `PATCH`, `POST /cancel` e `POST /pay_off` — todos retornam o mesmo formato. É o que você consome para construir o checkout transparente.

**Exemplo cURL**:

```bash
curl -X GET 'https://api.sacador.com.br/v1/billings/123' \
  -H 'Authorization: Bearer sct_seu_token_aqui'
```

**Resposta** `200 OK`:

```json
{
  "id": 123,
  "code": "0F7B91A2C3D4E5F60718293A4B5C6D7E",
  "your_code": "ERP-2026-0001",
  "reference": "Mensalidade",
  "status": "opened",
  "virtual_status": "opened",
  "billing_type": "billing",
  "billing_issue_type": "individual",
  "amount": "150.00",
  "due_date": "2026-06-10",
  "competence": "05/2026",
  "evincive": "Mensalidade de maio",
  "observation": null,
  "discount_type": "percentage",
  "discount": "10.0",
  "discount_antecedence": 5,
  "discount_type2": null,
  "discount2": "0.0",
  "discount_antecedence2": 0,
  "penalty_type": "percentage",
  "penalty": "2.0",
  "cancel_penalty": false,
  "interest_type": "percentage",
  "interest_period": "monthly",
  "interest": "1.0",
  "instruction_type": "protest",
  "instruction_days": 5,
  "bank_rule_id": 7,
  "contact_id": 101,
  "group_id": null,
  "installments": 1,
  "installment_type": "none",
  "installment": 1,
  "redirect_url": "https://store.example.com/thanks",
  "notification_url": null,
  "gw_payment_link": null,
  "gw_received_with": null,
  "gw_payment_date": null,
  "gw_gross_amount": "0.0",
  "gw_fee_amount": "0.0",
  "gw_net_amount": "0.0",
  "gw_discount_amount": "0.0",
  "gw_extra_amount": "0.0",
  "gw_escrow_end_date": null,
  "gw_installment_count": null,
  "gw_payment_method_type": null,
  "gw_payment_method_code": null,
  "gw_type_id": null,
  "created_at": "2026-05-12T10:00:00.000Z",
  "updated_at": "2026-05-12T10:00:00.000Z",
  "virtual_status": "opened",
  "return_date": null,
  "discount_amount": "15.0",
  "extra_amount": "0.0",
  "amount_payable": "135.0",
  "display_amount_payable": "135.0",
  "days_overdue": -29,
  "overdue": false,
  "paid": false,
  "contact": {
    "id": 101,
    "description": "Maria Silva",
    "email": "maria@exemplo.com",
    "document": "12345678901"
  },
  "billing_items": [
    {
      "id": 555,
      "description": "Mensalidade",
      "quantity": 1,
      "amount": "150.00"
    }
  ],
  "billing_reminder_deliveries": [
    {
      "id": 5,
      "channel": "email",
      "status": "delivered",
      "delivered_at": "2026-05-12T09:00:00.000Z",
      "error_message": null,
      "attempts_count": 1,
      "created_at": "2026-05-12T09:00:00.000Z",
      "updated_at": "2026-05-12T09:00:01.000Z"
    }
  ],
  "bank_billets": [
    {
      "id": 9001,
      "status": "opened",
      "bank_symbol": "bb",
      "amount": "150.00",
      "due_date": "2026-06-10",
      "barcode": "00190000000000010000000000000000000000000000",
      "digitable_line": "00190.00009 00000.000009 00000.000009 0 00000000000000",
      "pix_qr_code": "00020101021226...",
      "our_number": "1234567",
      "days_overdue": -29,
      "overdue": false,
      "editable": false,
      "created_at": "2026-05-12T10:00:00.000Z",
      "updated_at": "2026-05-12T10:00:00.000Z"
    }
  ]
}
```

> Campos do array `bank_billets` foram resumidos acima — o formato completo (recipient/payer, datas de pagamento, valores líquidos, provider, etc.) está em [bank_billets.md](./bank_billets.md).

**Erros**: `404` se inexistente ou de outra conta.

---

### `POST /v1/billings`

Cria uma cobrança.

**Body**:

```json
{
  "billing": {
    "bank_rule_id": 7,
    "contact_id": 101,
    "billing_issue_type": "individual",
    "due_date": "2026-06-10",
    "evincive": "Mensalidade de maio",
    "instruction_type": "protest",
    "instruction_days": 5,
    "penalty_type": "percentage",
    "penalty": 2,
    "interest_type": "percentage",
    "interest_period": "monthly",
    "interest": 1,
    "billing_items": [
      { "description": "Mensalidade", "quantity": 1, "amount": 150.00 }
    ]
  }
}
```

**Campos aceitos** (qualquer outro é ignorado, incluindo `account_id`, `code`, `status`):

`bank_rule_id`, `contact_id`, `group_id`, `billing_issue_type`, `evincive`, `observation`, `reference`, `your_code`, `competence`, `due_date`, `penalty`, `penalty_type`, `interest`, `interest_type`, `interest_period`, `instruction_type`, `instruction_days`, `redirect_url`, `notification_url`, `billing_items`.

> O campo `amount` no nível da cobrança é **somente leitura** — sempre recalculado pelo servidor como `sum(quantity * amount)` dos `billing_items`. Se você enviar, é descartado.

> `billing_type` **não** é aceito: a API sempre cria com `billing_type="billing"`. Qualquer valor enviado é descartado.

**Exemplo cURL** (cobrança individual):

```bash
curl -X POST 'https://api.sacador.com.br/v1/billings' \
  -H 'Authorization: Bearer sct_seu_token_aqui' \
  -H 'Content-Type: application/json' \
  -d '{
    "billing": {
      "bank_rule_id": 7,
      "contact_id": 101,
      "billing_issue_type": "individual",
      "due_date": "2026-06-10",
      "evincive": "Mensalidade de maio",
      "your_code": "ERP-2026-0001",
      "competence": "05/2026",
      "penalty_type": "percentage",
      "penalty": 2,
      "interest_type": "percentage",
      "interest_period": "monthly",
      "interest": 1,
      "billing_items": [
        { "description": "Mensalidade", "quantity": 1, "amount": 150.00 }
      ]
    }
  }'
```

**Exemplo cURL** (cobrança em massa para um grupo):

```bash
curl -X POST 'https://api.sacador.com.br/v1/billings' \
  -H 'Authorization: Bearer sct_seu_token_aqui' \
  -H 'Content-Type: application/json' \
  -d '{
    "billing": {
      "bank_rule_id": 7,
      "group_id": 12,
      "billing_issue_type": "bulk",
      "due_date": "2026-06-10",
      "evincive": "Mensalidade junho",
      "billing_items": [
        { "description": "Mensalidade", "quantity": 1, "amount": 99.90 }
      ]
    }
  }'
```

### `billing_items` em `POST`

Cada elemento aceita: `description`, `quantity`, `amount`. **Não envie `id`** na criação.

### Validações

Resumo das regras que mais aparecem em `422`:

- `bank_rule_id` obrigatório e pertencente à conta
- `contact_id` obrigatório se `billing_issue_type=individual`; precisa ser da conta
- `group_id` obrigatório se `billing_issue_type=bulk`; precisa ser da conta
- `due_date` obrigatório
- `billing_items.length >= 1`, e cada item válido (descrição até 100 chars, quantidade 1..9999999, valor ≥ 0,01)
- `evincive` se preenchido: 10..80 chars, sem `@#$%^&*"{}|<>\[]`
- `interest`, `penalty`, `discount`, `discount2` ≤ 999.999.999
- Quando ambos os descontos são usados: `discount > discount2` e `discount_antecedence > discount_antecedence2` (o segundo desconto deve ter valor menor e janela mais próxima do vencimento)
- **Conta não verificada** (sem KYC aprovado): valor da cobrança limitado a `R$ 10,00`

**Resposta** `201 Created`: corpo idêntico a [`GET /v1/billings/:id`](#get-v1billingsid).

### Observação sobre boleto bancário

A criação do `bank_billet` no banco é assíncrona. O `POST` retorna a cobrança imediatamente com `status=opened`; o boleto fica disponível assim que o banco confirma o registro. Reconsulte via `GET /v1/billings/:id` se precisar do `provider_id`.

---

### `PATCH /v1/billings/:id`

Atualiza uma cobrança.

- Cobranças **editáveis** (ver [Quando uma cobrança é editável](#quando-uma-cobrança-é-editável)): aceita os mesmos campos do `POST`.
- Cobranças **não editáveis** em status `opened`/`expired`: aceita apenas `due_date` e `cancel_penalty`.
- Demais casos: o `PATCH` é aceito (`200 OK`), mas nenhuma alteração é efetuada.

**Exemplo cURL** (prorrogar vencimento):

```bash
curl -X PATCH 'https://api.sacador.com.br/v1/billings/123' \
  -H 'Authorization: Bearer sct_seu_token_aqui' \
  -H 'Content-Type: application/json' \
  -d '{
    "billing": {
      "due_date": "2026-07-10"
    }
  }'
```

### `billing_items` em `PATCH` — semântica de destruição implícita

Quando você envia `billing_items` no body de um `PATCH`:

- Itens **com `id`** existentes na cobrança são **atualizados**.
- Itens **sem `id`** são **criados**.
- Itens existentes na cobrança que **não aparecem no array** são **removidos** automaticamente.

Exemplo: cobrança tem itens `[1, 2, 3]`. Você envia:

```json
{
  "billing": {
    "billing_items": [
      { "id": 1, "description": "Mensalidade atualizada", "quantity": 1, "amount": 200 },
      { "description": "Item novo", "quantity": 1, "amount": 50 }
    ]
  }
}
```

Resultado: item `1` atualizado, item novo criado, itens `2` e `3` removidos. Para **manter** um item, inclua-o (ao menos com `id`) no array.

> Para apagar **todos** os itens, envie `billing_items: []` — mas o `POST`/`PATCH` falhará com `422` porque a cobrança precisa de ao menos 1 item. Use o cancelamento se o objetivo for descontinuar a cobrança.

**Erros**: `404` (outra conta), `422` (validações).

---

### `DELETE /v1/billings/:id`

Apaga uma cobrança.

**Restrição:** somente cobranças com `billing_type=schedule` (recorrências legadas) podem ser deletadas. Cobranças `billing` criadas via API **não podem ser deletadas** — use `POST /v1/billings/:id/cancel` para descontinuá-las. Tentativa de delete retorna:

```json
{
  "status": 422,
  "errors": ["Não é possível alterar esta cobrança"]
}
```

**Exemplo cURL**:

```bash
curl -X DELETE 'https://api.sacador.com.br/v1/billings/123' \
  -H 'Authorization: Bearer sct_seu_token_aqui'
```

**Resposta** `204 No Content` quando bem-sucedido.

**Erros**: `404` (outra conta), `422` (não é `schedule`).

---

### `POST /v1/billings/:id/cancel`

Cancela uma cobrança.

**Body**:

```json
{
  "billing": {
    "cancellation_reason": "Cliente desistiu da compra"
  }
}
```

| Campo | Regras |
|---|---|
| `cancellation_reason` | Obrigatório, mínimo 3 caracteres, máximo 1000 |

**Exemplo cURL**:

```bash
curl -X POST 'https://api.sacador.com.br/v1/billings/123/cancel' \
  -H 'Authorization: Bearer sct_seu_token_aqui' \
  -H 'Content-Type: application/json' \
  -d '{
    "billing": {
      "cancellation_reason": "Cliente desistiu da compra"
    }
  }'
```

**Resposta** `200 OK`: corpo idêntico a [`GET /v1/billings/:id`](#get-v1billingsid), com `status="canceled"`.

**Erros**: `404` (outra conta), `422` (motivo curto/ausente, ou cobrança em status que não permite cancelamento).

---

### `POST /v1/billings/:id/pay_off`

Dá baixa manual (descarregamento).

**Body**:

```json
{
  "billing": {
    "bank_account_id": 42,
    "discharge_reason": "bank_deposit",
    "gw_gross_amount": 150.00
  }
}
```

| Campo | Regras |
|---|---|
| `bank_account_id` | Obrigatório. **Precisa pertencer à mesma conta.** Não pode ser conta do tipo `sacador` |
| `discharge_reason` | Obrigatório. `"bank_deposit"` ou `"payment_in_hand"` |
| `gw_gross_amount` | Valor efetivamente recebido (pode diferir de `amount` se houve juros/desconto manual) |

Após baixa o `status` vira `discharged` e um lançamento de caixa (`Cashier`) é criado automaticamente na conta bancária informada.

**Exemplo cURL**:

```bash
curl -X POST 'https://api.sacador.com.br/v1/billings/123/pay_off' \
  -H 'Authorization: Bearer sct_seu_token_aqui' \
  -H 'Content-Type: application/json' \
  -d '{
    "billing": {
      "bank_account_id": 42,
      "discharge_reason": "bank_deposit",
      "gw_gross_amount": 150.00
    }
  }'
```

**Resposta** `200 OK`: corpo idêntico a [`GET /v1/billings/:id`](#get-v1billingsid), com `status="discharged"`.

**Erros**: `404` (outra conta), `422` (`bank_account_id` inválido, conta sacador, `discharge_reason` inválido, ou status que não permite baixa).


---

<!-- ============================================================ -->
<!-- Seção: Boletos (bank_billets) -->
<!-- ============================================================ -->

# Boletos bancários (bank billets)

Boletos bancários representam o **registro do boleto no banco** associado a uma cobrança. Uma cobrança (`billing`) pode ter vários boletos ao longo do tempo (re-emissões, mudança de banco, etc.); a API expõe todos.

Os endpoints são **somente leitura** e sempre aninhados sob uma cobrança da conta autenticada.

> Veja [conventions.md](./conventions.md) para paginação, autenticação, erros, rate limit e isolamento entre contas.

## Modelo

### Status

| Valor | Significado |
|---|---|
| `issued` | Boleto emitido localmente, ainda não enviado ao banco |
| `pending_remittance` | Pronto para ser remetido ao banco |
| `remitting` | Remessa em andamento |
| `remittanced` | Remessa entregue ao banco, aguardando confirmação |
| `opened` | Registrado no banco, aguardando pagamento |
| `rejected` | Banco recusou o registro (ver `bank_rejection` para a mensagem) |
| `paid` | Pago via banco |
| `discharged` | Baixado |
| `canceled` | Cancelado |
| `protested` | Em protesto |

### Campos retornados

| Campo | Tipo | Observações |
|---|---|---|
| `id` | int | |
| `status` | string | Ver tabela acima |
| `bank_symbol` | string | Símbolo do banco (`bb`, `afpay`, `pagseguro`, etc.) |
| `amount` | decimal | Valor original do boleto |
| `due_date` | date | Vencimento |
| `barcode` | string \| null | Código de barras (44 dígitos) |
| `digitable_line` | string \| null | Linha digitável formatada |
| `pix_qr_code` | string \| null | EMV BR Code para pagamento via Pix (quando suportado pelo banco) |
| `our_number` | string \| null | "Nosso número" atribuído pelo banco |
| `our_sequential_number` | int | Sequencial interno por (conta, convênio) |
| `uuid` | string \| null | Identificador único do boleto |
| `agreement_code`, `bank_billet_account` | string \| null | Códigos do convênio bancário |
| `agency_number`, `agency_digit` | string | Agência do beneficiário |
| `account_number`, `account_digit` | string | Conta corrente do beneficiário |
| `recipient_full_name`, `recipient_document`, `recipient_full_address` | string | Beneficiário (sua conta) — congelado no momento da emissão |
| `payer_full_name`, `payer_document` | string | Pagador (contato) — congelado no momento da emissão |
| `payer_address_postal_code`, `payer_address_street`, `payer_address_district`, `payer_address_city`, `payer_address_state` | string | Endereço do pagador — congelado no momento da emissão |
| `paid_amount` | decimal | Valor efetivamente pago |
| `paid_at` | date \| null | Data do pagamento |
| `net_amount` | decimal | Valor líquido (já descontadas tarifas) |
| `discount` | decimal | Desconto aplicado pelo banco |
| `interest` | decimal | Juros aplicados |
| `bank_rate` | decimal | Tarifa do banco |
| `credit_at` | date \| null | Data prevista de crédito |
| `discharged_at` | date \| null | Data da baixa |
| `remittanced_at` | date \| null | Data da entrega da remessa |
| `provider_id` | string \| null | ID do boleto no provedor externo (AFPAY, etc.) |
| `provider_status` | string \| null | Status reportado pelo provedor externo |
| `bank_rejection` | string \| null | Mensagem de rejeição do banco (presente quando `status="rejected"`) |
| `days_overdue` | int | Dias em atraso (negativo se ainda não venceu) |
| `overdue` | boolean | `true` se `days_overdue > 0` e o status ainda permite cobrar |
| `editable` | boolean | `true` se o boleto pode ser alterado (`issued`, `pending_remittance` ou `rejected`) |
| `created_at`, `updated_at` | ISO-8601 | |

> Os campos `recipient_*` e `payer_*` são **snapshots** do momento da emissão. Atualizar o contato **não** propaga para boletos já emitidos.

## Endpoints

### `GET /v1/billings/:billing_id/bank_billets`

Lista todos os boletos da cobrança, ordenados por `id DESC` (mais recente primeiro).

**Exemplo cURL**:

```bash
curl -X GET 'https://api.sacador.com.br/v1/billings/5001/bank_billets?page=1&per_page=25' \
  -H 'Authorization: Bearer sct_seu_token_aqui'
```

**Resposta** `200 OK`:

```json
{
  "bank_billets": [
    {
      "id": 9001,
      "status": "opened",
      "bank_symbol": "bb",
      "amount": "150.00",
      "due_date": "2026-06-10",
      "barcode": "00190000000000010000000000000000000000000000",
      "digitable_line": "00190.00009 00000.000009 00000.000009 0 00000000000000",
      "pix_qr_code": null,
      "our_number": "1234567",
      "our_sequential_number": 42,
      "uuid": null,
      "agreement_code": "1234567",
      "bank_billet_account": null,
      "agency_number": "1234",
      "agency_digit": "5",
      "account_number": "123456",
      "account_digit": "7",
      "recipient_full_name": "Empresa Exemplo Ltda",
      "recipient_document": "12345678000190",
      "recipient_full_address": "Av. Paulista, 1000 - Bela Vista - SP",
      "payer_full_name": "Maria Silva",
      "payer_document": "12345678901",
      "payer_address_postal_code": "01310-100",
      "payer_address_street": "Avenida Paulista",
      "payer_address_district": "Bela Vista",
      "payer_address_city": "São Paulo",
      "payer_address_state": "SP",
      "paid_amount": "0.0",
      "paid_at": null,
      "net_amount": "0.0",
      "discount": "0.0",
      "interest": "0.0",
      "bank_rate": "0.0",
      "credit_at": null,
      "discharged_at": null,
      "remittanced_at": "2026-05-11",
      "provider_id": null,
      "provider_status": null,
      "bank_rejection": null,
      "days_overdue": -30,
      "overdue": false,
      "editable": false,
      "created_at": "2026-05-11T10:00:00.000Z",
      "updated_at": "2026-05-11T10:05:00.000Z"
    }
  ]
}
```

Headers `X-Total-Count`, `X-Page`, `X-Per-Page`, `X-Total-Pages` conforme [conventions.md](./conventions.md#paginação).

**Erros**:
- `404` — `billing_id` inexistente ou pertencente a outra conta.

---

### `GET /v1/billings/:billing_id/bank_billets/:id`

Retorna um boleto específico.

**Exemplo cURL**:

```bash
curl -X GET 'https://api.sacador.com.br/v1/billings/5001/bank_billets/9001' \
  -H 'Authorization: Bearer sct_seu_token_aqui'
```

**Resposta** `200 OK`: objeto idêntico ao item da listagem.

**Erros**:
- `404` — o boleto não existe, pertence a outra cobrança da mesma conta, ou a cobrança/boleto pertence a outra conta.

## Como aparece dentro de `GET /v1/billings/:id`

Para evitar uma chamada extra quando você já está consultando a cobrança, o `GET /v1/billings/:id` (assim como as respostas de `POST`, `PATCH /v1/billings/:id` e dos endpoints `cancel`/`pay_off`) **já embute** os boletos no campo `bank_billets`, com o mesmo formato dos endpoints acima.

```json
{
  "id": 123,
  "code": "ABC123",
  "...": "...",
  "bank_billets": [
    { "id": 9001, "status": "opened", "...": "..." }
  ]
}
```

O `GET /v1/billings` (listagem) **não** inclui `bank_billets` — para evitar payloads pesados em consultas de muitos registros, use o endpoint dedicado quando precisar dos boletos durante uma listagem.


---

<!-- ============================================================ -->
<!-- Seção: Webhooks (webhooks) -->
<!-- ============================================================ -->

# Webhooks

O Sacador envia notificações `POST` para o seu sistema sempre que ocorre um evento relevante (ex.: atualização de status de cobrança). Cada requisição é **assinada com HMAC-SHA256** para que você possa verificar que ela realmente partiu do Sacador e não foi adulterada em trânsito.

## Configuração

1. Acesse **/integrations** → aba **API e Webhook** → seção **URLs de Webhook**.
2. Cadastre a URL pública do seu endpoint receptor. Você pode cadastrar **até 3 URLs** por conta (todas recebem todos os eventos, em paralelo).
3. No momento da criação, o Sacador exibe **uma única vez** o `signing_secret` daquele endpoint, no formato `whsec_…`. Copie e armazene em local seguro (variável de ambiente, secret manager).
4. Use esse secret no seu sistema receptor para validar a assinatura de cada requisição recebida.

> Se você perder o secret, clique em **Novo secret** na linha do endpoint para gerar outro. O secret antigo deixa de funcionar imediatamente — atualize seu sistema receptor para usar o novo antes de fazer a rotação em produção.

## Anatomia da requisição

```http
POST /seu/endpoint HTTP/1.1
Host: seu-sistema.com.br
Content-Type: application/json
X-Sacador-Event: billing.updated
X-Sacador-Timestamp: 1762828800
X-Sacador-Signature: t=1762828800,v1=4f8b1a2c…<64 hex chars total>

{"billing":"ABC123"}
```

### Headers

| Header | Descrição |
|---|---|
| `X-Sacador-Event` | Identificador do evento (ex.: `billing.updated`) |
| `X-Sacador-Timestamp` | Epoch em segundos do momento em que o webhook foi assinado/enviado. **Faz parte da entrada do HMAC** — qualquer alteração invalida a assinatura |
| `X-Sacador-Signature` | Assinatura no formato `t=<timestamp>,v1=<hmac_hex>`. O `v1=` permite versionarmos o esquema no futuro mantendo compatibilidade |

### Eventos disponíveis

| Evento | Quando dispara | Payload |
|---|---|---|
| `billing.updated` | Mudança de status de uma cobrança (paga, cancelada, etc.) | `{"billing":"<código_da_cobrança>"}` |

## Como validar a assinatura

A fórmula é:

```
signed_payload  = "<X-Sacador-Timestamp>.<corpo bruto da requisição>"
expected_v1     = HMAC-SHA256(signing_secret, signed_payload)  // hex lowercase
```

Compare `expected_v1` com o valor `v1=` extraído de `X-Sacador-Signature` usando uma **comparação resistente a timing attacks** (ex.: `OpenSSL.fixed_length_secure_compare` em Ruby, `hmac.compare_digest` em Python, `crypto.timingSafeEqual` em Node, `hash_equals` em PHP). Nunca use `==` direto — isso expõe um canal lateral por tempo de comparação.

### Passos obrigatórios

1. **Leia o corpo bruto** da requisição (a string JSON original, **não** uma re-serialização do objeto parseado — espaços e ordem de chaves importam).
2. Extraia `t` e `v1` do header `X-Sacador-Signature`.
3. Compute `HMAC-SHA256(secret, "<t>.<body>")` em hex.
4. Compare com `v1` em tempo constante.
5. **Valide a janela de tempo:** rejeite se `|now - t| > 300` (5 minutos). Isso protege contra **replay attacks** caso uma requisição legítima seja capturada e reenviada depois.
6. Só então processe o payload.

Se qualquer passo falhar, responda com `401` e descarte a requisição.

## Exemplos por linguagem

### Ruby (Rails)

```ruby
require 'openssl'

class WebhooksController < ApplicationController
  TOLERANCE = 5 * 60 # segundos

  def sacador
    raw_body = request.raw_post
    signature_header = request.headers['X-Sacador-Signature'].to_s
    timestamp = request.headers['X-Sacador-Timestamp'].to_i

    parts = signature_header.split(',').to_h { |p| p.split('=', 2) }
    received_v1 = parts['v1'].to_s

    expected = OpenSSL::HMAC.hexdigest('SHA256', ENV['SACADOR_WEBHOOK_SECRET'], "#{timestamp}.#{raw_body}")

    return head :unauthorized unless OpenSSL.fixed_length_secure_compare(expected, received_v1)
    return head :unauthorized if (Time.now.to_i - timestamp).abs > TOLERANCE

    payload = JSON.parse(raw_body)
    SacadorWebhookProcessor.call(event: request.headers['X-Sacador-Event'], payload: payload)

    head :ok
  end
end
```

### Node.js (Express)

```js
const crypto = require('crypto');
const express = require('express');
const app = express();

const TOLERANCE = 5 * 60; // segundos
const SECRET = process.env.SACADOR_WEBHOOK_SECRET;

// IMPORTANTE: usar express.raw para ter acesso ao corpo bruto
app.post(
  '/webhooks/sacador',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const rawBody = req.body.toString('utf8');
    const timestamp = parseInt(req.header('X-Sacador-Timestamp'), 10);
    const signatureHeader = req.header('X-Sacador-Signature') || '';

    const parts = Object.fromEntries(
      signatureHeader.split(',').map((p) => p.split('=', 2))
    );
    const receivedV1 = Buffer.from(parts.v1 || '', 'utf8');

    const expected = Buffer.from(
      crypto.createHmac('sha256', SECRET).update(`${timestamp}.${rawBody}`).digest('hex'),
      'utf8'
    );

    if (
      receivedV1.length !== expected.length ||
      !crypto.timingSafeEqual(receivedV1, expected)
    ) {
      return res.status(401).end();
    }

    if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE) {
      return res.status(401).end();
    }

    const payload = JSON.parse(rawBody);
    // ... processe o payload
    res.status(200).end();
  }
);
```

### Python (Flask)

```python
import hashlib
import hmac
import os
import time
from flask import Flask, request, abort

app = Flask(__name__)
TOLERANCE = 5 * 60  # segundos
SECRET = os.environ['SACADOR_WEBHOOK_SECRET'].encode()


@app.post('/webhooks/sacador')
def sacador():
    raw_body = request.get_data()  # bytes brutos, sem parse
    timestamp = int(request.headers.get('X-Sacador-Timestamp', '0'))
    signature_header = request.headers.get('X-Sacador-Signature', '')

    parts = dict(p.split('=', 1) for p in signature_header.split(','))
    received_v1 = parts.get('v1', '')

    signed = f"{timestamp}.{raw_body.decode()}".encode()
    expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(expected, received_v1):
        abort(401)

    if abs(int(time.time()) - timestamp) > TOLERANCE:
        abort(401)

    payload = request.get_json(force=True)
    # ... processe o payload
    return '', 200
```

### PHP

```php
<?php
const TOLERANCE = 300; // segundos

$secret    = getenv('SACADOR_WEBHOOK_SECRET');
$rawBody   = file_get_contents('php://input');
$timestamp = (int) ($_SERVER['HTTP_X_SACADOR_TIMESTAMP'] ?? 0);
$header    = $_SERVER['HTTP_X_SACADOR_SIGNATURE'] ?? '';

$parts = [];
foreach (explode(',', $header) as $kv) {
    [$k, $v] = array_pad(explode('=', $kv, 2), 2, '');
    $parts[$k] = $v;
}
$receivedV1 = $parts['v1'] ?? '';

$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);

if (!hash_equals($expected, $receivedV1)) {
    http_response_code(401);
    exit;
}
if (abs(time() - $timestamp) > TOLERANCE) {
    http_response_code(401);
    exit;
}

$payload = json_decode($rawBody, true);
// ... processe o payload
http_response_code(200);
```

## Comportamento de entrega

- **Paralelo:** se você tiver múltiplas URLs cadastradas, todas recebem o mesmo evento ao mesmo tempo. Falha em uma URL não afeta as outras.
- **Sem retry automático:** nesta versão, falhas de entrega (timeout, 5xx) **não** são reagendadas. Se sua infra estiver indisponível, eventos podem ser perdidos. Implemente sua própria reconciliação periódica via API se precisar de garantias mais fortes.
- **Timeout:** o Sacador aguarda no máximo **10 segundos** pela resposta. Responda rápido (`200 OK` vazio) e processe o payload de forma assíncrona se for trabalhoso.
- **Idempotência:** projete seu handler para ser idempotente — em caso de retry manual ou disparo duplicado, o mesmo evento pode chegar mais de uma vez.

## Resposta esperada

Qualquer status `2xx` é considerado sucesso e a entrega é descartada do log de tentativas. Status `3xx`, `4xx` e `5xx` são tratados como falha (mas não reagendados — ver seção anterior).

Não retorne corpo: o Sacador ignora.

## Cabeçalho de teste / debug

Para validar sua implementação, basta criar um endpoint de webhook apontando para um serviço como **webhook.site** ou **ngrok**, gerar uma cobrança no Sacador e mudá-la de status. Use o `signing_secret` exibido na criação para reproduzir o cálculo do HMAC e confirmar que bate com o `v1=` recebido.
