04 — Contrato de dados para API REST¶
A PyCobrança serializa seus artefatos (boleto, remessa, retorno) para um contrato de dados JSON (OpenAPI 3.0), pronto para ser exposto por um serviço REST. O contrato vem como dado, não como servidor: a biblioteca não fala HTTP e não traz cliente nem framework — quem expõe é você, em FastAPI, Flask, Django ou o que preferir.
Contrato de dados (boleto)¶
O payload é {"bank": <slug>, "data": <BoletoData>} — o banco vai fora do data, como slug
(itau, não 341), e o schema BoletoData não tem campo banco:
{
"bank": "itau",
"data": {
"valor": 127.50,
"cedente": "Empresa Exemplo LTDA",
"documento_cedente": "12345678000190",
"agencia": "1234",
"conta_corrente": "56789",
"carteira": "109",
"nosso_numero": "12345678",
"data_vencimento": "2026-08-15",
"sacado": "Cliente Final",
"sacado_documento": "12345678909"
}
}
Obrigatórios no schema são seis: nosso_numero, valor, cedente, documento_cedente,
sacado e sacado_documento. agencia e conta_corrente não entram nessa lista — o
Santander identifica o cedente pelo convênio e a Caixa pelo código do beneficiário, e exigi-los
tornaria esses dois inexprimíveis. O que cada banco realmente precisa é conferido por validar()
na engine, ver 14 — Validação de campos.
O mapa código FEBRABAN → slug é SLUG_POR_CODIGO. Os nomes dentro de data acompanham os campos
de BancoBase, com quatro exceções que a serialização traduz — o mapa NOMES_DO_CONTRATO é a
fonte:
| No contrato | No construtor |
|---|---|
conta_corrente |
conta |
documento_cedente |
cedente_documento |
chave_pix |
pix_chave |
pix_copia_cola |
pix_copia_cola |
txid |
pix_txid |
pix_observacao |
pix_observacao |
Publicando o seu OpenAPI¶
A PyCobrança não tem endpoints — é biblioteca, por escolha de projeto. O que ela publica são os schemas de dados, versionados junto do código que os implementa. Quem tem paths, auth e respostas é a sua API.
openapi_de() cola os dois lados sem cópia de schema, que é onde a divergência começa: um
arquivo copiado envelhece em silêncio quando a biblioteca sobe de versão.
import yaml
from pycobranca.contracts import openapi_de
doc = openapi_de(
{"/boletos": {"post": {...}}}, # seus paths
info={"title": "cobranca_api", "version": "1.0.0"},
servers=[{"url": "https://api.exemplo.com.br"}],
)
Path("openapi.yaml").write_text(yaml.safe_dump(doc, sort_keys=False))
Nos paths, aponte para os schemas daqui com {"$ref": "#/components/schemas/BoletoData"}.
| Detalhe | Comportamento |
|---|---|
| Versão da engine | carimbada em info["x-pycobranca"] e na description — quem abre o Swagger sabe de qual PyCobrança veio o contrato |
schemas= |
schemas seus são somados; colidir com um nome da biblioteca levanta ErroDeContrato |
| Cópia | os schemas são copiados: mutar o documento não corrompe o CONTRATO do processo |
| Formato | devolve dict. Serializar em JSON ou YAML é com você — a biblioteca não escolhe |
Guarda sugerida do lado da API: um teste que compare o
openapi.yamlpublicado comopenapi_de(...)recém-gerado. Se alguém subir a PyCobrança sem regerar o YAML, o teste acusa — mesmo padrão que aqui impede o mapa de nomes e o schema de divergirem.Dois caminhos de PIX, e o contrato expressa os dois.
pix_copia_colaé o payload que o banco devolveu ao registrar a cobrança — o QR que liquida o título.chave_pixmonta um BR Code estático, que credita a chave mas deixa o boleto em aberto. Quando os dois vêm, vale o do banco. A resposta trazpix_vinculadodizendo qual foi usado — ver 07 — PIX.
additionalPropertiesé permissivo — mas só na validação.valida_contratoignora campo desconhecido em vez de recusá-lo, então umdatacombancodentro passa por ele, e o banco declarado ali seria silenciosamente descartado.boleto_de_apirecusa, nomeando o campo:Quem valida sem construir o título (um serviço que só confere a forma antes de enfileirar) precisa conferir a forma acima por conta própria.
Campos específicos de banco¶
Nove dos 19 bancos precisam de campo que não aparece no payload genérico: ele entra no campo
livre do código de barras ou é exigido por regra do banco. A tupla CAMPOS_POR_BANCO lista os
nove:
from pycobranca.contracts import CAMPOS_POR_BANCO
# ('data_documento', 'digito_conta', 'digito_agencia', 'digito_convenio',
# 'variacao', 'incremento', 'portfolio', 'posto', 'byte_idt')
Quem precisa de quê, nos dados de referência de cada banco:
| Banco | Campos |
|---|---|
| 004 Banco do Nordeste | digito_conta |
| 021 Banestes | digito_conta, variacao |
| 041 Banrisul | digito_convenio |
| 070 BRB | incremento |
| 136 Unicred | digito_conta |
| 422 Safra | digito_conta, digito_agencia |
| 745 Citibank | portfolio |
| 748 Sicredi | posto, byte_idt |
| 756 Sicoob | variacao |
data_documento vale para qualquer banco (é a data impressa no título). Os demais 10 bancos não
usam nenhum destes campos.
Omitir um deles não levanta erro em todos os casos. Banco do Nordeste, Banestes, Unicred,
Safra, BRB e Sicredi barram na validação, com a mensagem nomeando o campo
(dígito da conta deve ter no mínimo 1 dígito(s)) — antes os quatro primeiros só falhavam na
montagem, com um campo livre de 24 dígitos e nenhuma indicação de qual campo faltava. Banrisul
(digito_convenio, que só entra na impressão) e Sicoob (variacao, que tem valor padrão) geram
normalmente. O Citibank produz um código de barras diferente, sem exceção nenhuma — com o
portfolio zerado, estruturalmente válido, DV recalculado e destino errado:
com portfolio: 74593153900000127503172006247107010999940225
sem portfolio: 74595153900000127503000006247107010999940225
^^^ 172 → 000
Por isso a ida e volta é testada nos 19 bancos (ver Caminho de volta).
Contrato de dados verificado¶
O módulo pycobranca.contracts mantém o contrato de dados REST
alinhado e testado automaticamente, sem que a engine dependa de qualquer camada HTTP. Ele
oferece:
- Serializadores engine → schemas da API:
boleto_para_api(banco)(→{"bank", "data"}comBoletoData),pagamento_para_api(pagamento)(→Pagamento),remessa_para_api(remessa)(→RemessaRequest) eretorno_item_para_api(registro, layout="400", banco=None)(→RetornoItem, com valores em centavos convertidos para reais e a ocorrência traduzida). boleto_de_api(payload)etema_de_api(data)— o caminho de volta, contrato → engine.valida_contrato(dados, schema)— validador leve (obrigatórios, tipos,enum, itens de array via$ref) contra o contrato vendorizado.CONTRATO— os 13 schemas (BoletoData,RemessaRequest,Pagamento,Encargos,Mora,Multa,Desconto,RetornoItem,ExtratoOFX,TransacaoOFX,ItemFatura,FaturaCorpo,BlocoFatura), versionados empycobranca/contracts/contrato_rest.json.- Constantes de apoio, para não duplicar listas do lado de fora:
SLUG_POR_CODIGO(código FEBRABAN → slug dobank),NOMES_DO_CONTRATO,TOTALIZADORES,CAMPOS_POR_BANCOeTEMA_DO_CONTRATO.
Os testes de contrato (tests/test_contrato_rest.py) validam a serialização de boleto
para os 19 bancos, além de remessa e retorno (usando as fixtures .RET), garantindo que os
artefatos permaneçam válidos conforme a API evolui:
from pycobranca.bancos.itau import Itau
from pycobranca.contracts import boleto_para_api, valida_contrato
payload = boleto_para_api(
Itau(
valor="127.50",
agencia="1234",
conta="56789",
carteira="109",
nosso_numero="12345678",
cedente="Empresa LTDA",
cedente_documento="12345678000190",
sacado="Cliente",
sacado_documento="12345678909",
)
)
valida_contrato(payload["data"], "BoletoData") # levanta ErroDeContrato se divergir
# payload == {"bank": "itau", "data": {...}}
Faixa de encargos (BoletoData)¶
Os cinco campos FEBRABAN viajam no contrato com os mesmos nomes que têm em BancoBase — não
há tradução nos dois sentidos. A tupla TOTALIZADORES expõe a lista:
from pycobranca.contracts import TOTALIZADORES, boleto_para_api
TOTALIZADORES
# ('desconto_abatimento', 'outras_deducoes', 'mora_multa', 'outros_acrescimos', 'valor_cobrado')
boleto_para_api(boleto)["data"]
# {..., 'desconto_abatimento': 150.0, 'mora_multa': 8.0}
Campo não informado some do payload — boleto sem encargo sai idêntico ao que saía antes destes
campos existirem. valor_cobrado é serializado como foi informado, ou omitido: não há cálculo em
lugar nenhum, então boleto_para_api é projeção fiel do que o chamador montou.
Estes campos não são impressos no boleto. A faixa da ficha sai em branco, porque quem a preenche é o caixa no ato do pagamento — ver 11 — Renderização. No contrato eles servem para trafegar o encargo entre sistemas, que é outro problema: a regra que o pagador lê vai em
instrucao1/instrucao2.
Encargos na remessa (Pagamento)¶
Quando o Pagamento tem juros/mora, multa, desconto (1º/2º/3º), IOF ou abatimento, o schema
Pagamento ganha um objeto encargos ({"mora", "multa", "descontos", "iof", "abatimento"}),
emitido apenas quando há encargo — pagamentos sem encargo ficam com o payload inalterado.
from datetime import date
from pycobranca.cnab import Pagamento
from pycobranca.contracts import pagamento_para_api, valida_contrato
pag = Pagamento(
nosso_numero="12345678",
data_vencimento=date(2026, 8, 15),
valor=199.90,
tipo_mora="2",
percentual_mora=3.17, # juros: taxa mensal (%)
codigo_multa="2",
percentual_multa=2.00, # multa: 2%
cod_desconto="1",
valor_desconto=10.0,
data_desconto=date(2026, 8, 1),
)
dados = pagamento_para_api(pag)
valida_contrato(dados, "Pagamento")
# dados["encargos"] == {
# "mora": {"tipo": "2", "percentual": 3.17},
# "multa": {"codigo": "2", "percentual": 2.0},
# "descontos": [{"codigo": "1", "valor": 10.0, "data": "2026-08-01"}],
# }
Schemas: Encargos, Mora, Multa e Desconto (em contrato_rest.json).
Documentos: CPF e CNPJ alfanumérico¶
Os campos documento_cedente, sacado_documento (BoletoData) e documento_sacado (Pagamento)
têm pattern no contrato, então um serviço HTTP rejeita formato inválido antes de chamar a
engine. O padrão aceita:
- CPF — 11 dígitos, com ou sem máscara (
529.982.247-25); - CNPJ — 14 posições, com ou sem máscara, sendo as 12 primeiras alfanuméricas e os
2 DVs numéricos (IN RFB 2.229/2024):
12ABC34501DE35ou12.ABC.345/01DE-35.
O pattern valida apenas o formato; o dígito verificador é conferido pela engine
(validar_cnpj) na emissão. Ver 14 — Validação de campos.
Tema visual (faixa de marca)¶
Quatro campos do BoletoData descrevem a faixa de marca do boleto moderno. Eles são de
apresentação, não de domínio: não existem em BancoBase, então boleto_para_api nunca os
emite — é uma via de mão única, do contrato para a renderização. tema_de_api(data) faz a
tradução, porque contrato e renderizador usam vocabulários diferentes (o mapa é
TEMA_DO_CONTRATO):
| No contrato | No renderizador |
|---|---|
cor_marca |
cor |
logo_empresa |
logo_texto (e, por herança, empresa) |
marca_dagua |
marca_dagua |
rodape_contato |
rodape |
from pycobranca.contracts import boleto_de_api, tema_de_api
from pycobranca.render import emite_boleto
emite_boleto(boleto_de_api(payload), tema=tema_de_api(payload["data"]))
parcela_atual e total_parcelas, quando vêm juntos, viram o texto Parcela 2/12 na faixa.
Sem nenhum campo de tema, tema_de_api devolve None e o boleto sai sem faixa. O que cada chave
faz no desenho está em 11 — Renderização.
Fatura (BoletoData.itens / BoletoData.fatura)¶
O mesmo schema BoletoData carrega o corpo da fatura — não há schema novo de requisição:
itens— array deItemFatura(descricao,quantidade,valor_unitario,valor): a tabela pronta.fatura—FaturaCorpo(titulo+blocos), onde cadaBlocoFaturatemtipo(tabela,campos,texto,total,separador,espaco) e os campos daquele bloco.
{
"valor": 127.50, "...": "...",
"fatura": {
"titulo": "FATURA DE CONSUMO",
"blocos": [
{"tipo": "campos", "itens": [["Período", "01/08 a 31/08"]]},
{"tipo": "tabela", "colunas": ["Descrição", "Total"],
"linhas": [["Água", "63,00"]], "alinhamento": "lr"},
{"tipo": "texto", "conteudo": "Leitura em <b>18/08</b>."},
{"tipo": "total", "rotulo": "Total da fatura", "valor": 127.50}
]
}
}
O tipo do bloco é validado por enum, então um serviço HTTP rejeita bloco desconhecido antes de
chamar a renderização. Detalhes de layout em 11 — Renderização.
O terceiro nível da fatura (
fatura.desenhar, umcallablePython) não faz parte do contrato — só existe no uso em processo, por não ser serializável.
Retorno curado (RetornoItem)¶
O RegistroRetorno da engine expõe os campos crus do arquivo (fidelidade total). Para a visão
curada da API — valores monetários em reais e ocorrência legível — use retorno_item_para_api:
from pycobranca.cnab.retorno import Retorno
from pycobranca.contracts import retorno_item_para_api
retorno = Retorno.ler(upload.read()) # caminho, bytes ou objeto com .read()
itens = [
retorno_item_para_api(r, layout=retorno.layout, banco=retorno.codigo_banco)
for r in retorno.registros
]
# {'nosso_numero': '00000011', 'valor_titulo': 40.0, 'valor_pago': 37.9,
# 'codigo_ocorrencia': '06', 'motivo_ocorrencia': 'Liquidação normal', ...}
Passe
banco=retorno.codigo_banco. Há banco que redefine códigos do CNAB 400, e o sentido se inverte: o40do Safra é baixa de título protestado, e no mapa padrão da FEBRABAN é baixa por ter sido liquidado. Sem o banco, a API descreve um título protestado como pago — e o rótulo continua plausível, então o erro atravessa a conciliação sem nenhum sinal. ORetornojá traz o código; é só repassar.O mesmo vale para o Inter, em três códigos:
07(cancelado, não liquidação parcial),15(alteração de valor, não liquidação em cartório) e16(alteração de valor e vencimento, não instrução de protesto).
Caminho de volta: boleto_de_api¶
Receber um BoletoData e construir o título é uma chamada:
from pycobranca.contracts import boleto_de_api, tema_de_api
from pycobranca.render import emite_boleto
boleto = boleto_de_api(payload) # {"bank": ..., "data": {...}}
saida = emite_boleto(boleto, tema=tema_de_api(payload["data"]))
resposta = {"pdf_base64": b64(saida.pdf), **saida.to_dict()}
Ela valida contra o schema, resolve o slug, aplica as quatro traduções de nome, converte as datas
ISO para date, junta instrucao1/instrucao2 na lista instrucoes e descarta os campos de
apresentação que o construtor não aceita (tema e fatura).
A ida e volta é testada nos 19 bancos: boleto_de_api(boleto_para_api(b)) reproduz o mesmo
código de barras e a mesma linha digitável. É o que garante que os
campos específicos de banco estejam todos no schema.
Entrada malformada¶
O payload vem de fora, então a fronteira é onde o lixo tem de parar. Toda falha sai como
PyCobrancaError, nomeando o campo — nada de TypeError do construtor ou de ValueError do
fromisoformat escapando de um except da biblioteca:
| No payload | O que você recebe |
|---|---|
| obrigatório ausente | ErroDeContrato: BoletoData: campo obrigatório ausente/vazio: 'nosso_numero' |
"data_vencimento": "15/08/2026" |
ErroDeContrato: BoletoData.data_vencimento: '15/08/2026' não é uma data ISO 8601 (AAAA-MM-DD) |
"valor": true |
ErroDeContrato: BoletoData.valor: esperado number, recebido bool |
"cor_do_papel": "azul" |
ErroDeContrato: BoletoData.cor_do_papel: campo desconhecido para o banco Itaú (341) |
"bank": "banco_x" |
BancoNaoRegistrado |
| carteira fora do banco | BoletoInvalido, com .erros |
O bool é o caso que passa despercebido: isinstance(True, int) é verdadeiro em Python, então um
validador ingênuo aceita true como number e o erro só aparece lá adiante, em Decimal("True").
O que o contrato não cobre¶
- Não há cliente HTTP. A biblioteca não fala rede; um SDK, se existir, é projeto de quem o
escreve. Nenhum nome de cliente é importável de
pycobranca. valida_contratoconfere forma, não regra bancária. Ele aplicarequired,type,enum,patterne itens de array — que é exatamente o conjunto de palavras-chave usado nocontrato_rest.json. Carteira aceita pelo banco, largura de campo e DV são conferidos porvalidar()na engine, ver 14 — Validação de campos.- O nível 3 da fatura (
fatura.desenhar) é umcallablePython e não atravessa REST.
Versionamento¶
CONTRATO é versionado em pycobranca/contracts/contrato_rest.json e mantido em sincronia
manual com o domínio. Os testes de contrato (tests/test_contrato_rest.py) prendem a
serialização ao schema, mas só conferem o que está no payload: um campo novo em BancoBase
que não chegue ao BoletoData deixa o consumidor sem acesso ao recurso e não quebra a suíte
sozinho. A omissão é invisível porque o payload continua válido — falta um campo que ninguém
declarou esperar.
Foi assim que a faixa de totalizadores e os nove campos específicos de banco ficaram de fora, com
o resultado descrito acima: o Citibank sem portfolio produzia um código de barras
estruturalmente válido, com DV recalculado, e o destino errado.
Quem acrescentar campo ao domínio acrescenta aqui também. Duas redes pegam o esquecimento hoje:
test_contrato_hierarquia_erros.py confere que os totalizadores estão declarados, e o teste de
ida e volta nos 19 bancos falha se um campo consumido pelo campo livre ficar de fora.