OpenPix (Pix via Woovi)¶
Esta receita monta um serviço que abre uma cobrança Pix, descobre quando ela foi paga e libera o pedido — com a arquitetura em camadas que o resto do SDK usa, e sem escrever nenhum cliente HTTP à mão.
Ao final você terá:
- um
POST /api/checkoutque devolve o BR Code para o app desenhar o QR; - um
POST /webhooks/openpixque recebe a notificação de pagamento já verificada; - uma conferência pela API antes de liberar qualquer coisa;
- um job de reconciliação para as cobranças que o webhook não trouxe.
Assinaturas e planos (mensalidade, Pix Automático) estão na receita ao lado: OpenPix (assinaturas e planos).
O que já vem no pacote¶
Instalar o SDK já traz a OpenPix inteira: 686 schemas e 125 operações gerados da especificação, mais quatro coisas que a especificação não diz (ambientes, eventos de webhook, verificação de assinatura e centavos).
O HTTPClient, transporte do cliente gerado, vem no pacote base: o httpx
já é dependência dele. O extra [http] continua existindo e é opcional —
declara o httpx explicitamente para quem prefere o contrato escrito. O
cryptography é o que verifica a assinatura do webhook — sem ele o módulo
importa normalmente e só falha na primeira entrega de verdade, em produção.
Precisa de outra API que o SDK não traz?
Este módulo existe porque OpenPix é comum o bastante para todo serviço estar gerando o mesmo cliente. Para qualquer outra API, o gerador continua sendo a ferramenta certa: veja Cliente de integração (OpenAPI).
Configuração¶
from tempest_fastapi_sdk import OpenPixSettings
class Settings(OpenPixSettings):
"""Settings do serviço."""
settings = Settings()
OpenPixSettings traz OPENPIX_APP_ID e OPENPIX_ENVIRONMENT, e
settings.openpix_kwargs() devolve o que o HTTPClient precisa — a base URL
resolvida e o header de autorização. Sandbox é o default: apontar para produção
sem querer cobra dinheiro de verdade, apontar para sandbox sem querer falha
barulhento.
OPENPIX_ENVIRONMENT só aceita dois valores
production ou sandbox, validados no load. prod — o typo plausível —
é ValidationError na subida do serviço, não uma cobrança no ambiente
errado.
Um caractere separa produção de teste
Produção é api.woovi.com. Testes é api.woovi-sandbox.com. O AppID de um
não vale no outro, e a diferença é um sufixo numa URL que carrega dinheiro
de verdade — por isso OpenPixEnvironment existe em vez de uma string no
.env.
Produção mudou de api.openpix.com.br na v0.260.0, seguindo o servers do
documento refrescado. O host antigo continua respondendo: medido em
2026-08-28, GET /api/v1/charge devolve 401 nos três.
A arquitetura sugerida¶
A OpenPix entra pela camada de serviço, atrás de uma dependência. Nenhum
router monta payload, nenhum service abre HTTPClient.
src/
├── core/
│ └── settings.py # OPENPIX_APP_ID + ambiente
├── api/
│ ├── dependencies/
│ │ └── payments.py # constrói HTTPClient -> OpenPixClient -> service
│ └── routers/
│ ├── checkout.py # POST /api/checkout (JSON, no schema)
│ └── webhooks.py # POST /webhooks/openpix (include_in_schema=False)
├── controllers/
│ └── checkout.py # orquestra pedido + cobrança
├── services/
│ ├── openpix.py # regras de cobrança: abrir, conferir, estornar
│ └── orders.py # o seu pedido, que não sabe o que é Pix
└── db/
├── models/order.py # status do pedido + correlation_id
└── repositories/order.py
| Camada | Pode importar | Nunca importa |
|---|---|---|
api/routers |
controllers, schemas |
OpenPixClient, db |
controllers |
services, schemas |
OpenPixClient |
services |
OpenPixClient, db/repositories |
fastapi |
api/dependencies |
tudo acima, para montar | — |
Três decisões que vale explicitar:
- O
HTTPClienté um só, criado no lifespan. Ele carrega pool de conexões, retry e circuit breaker por host. Criar um por requisição joga fora as três coisas e abre um socket novo em cada checkout. - O
correlationIDé a sua chave primária do lado da OpenPix. Use o id do pedido, não um UUID novo: é o que amarra webhook, consulta e estorno ao registro do seu banco. - O webhook mora em um router separado, fora do schema OpenAPI. Ele não é parte da sua API pública, e a autenticação dele é uma assinatura, não o seu token de sessão.
A dependência¶
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from tempest_fastapi_sdk import HTTPClient
from tempest_fastapi_sdk.integrations.payment.openpix import OpenPixClient
from src.core.settings import settings
from src.services.openpix import OpenPixService
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Abre um cliente HTTP para o processo inteiro e o fecha no shutdown.
Args:
app (FastAPI): A aplicação, onde o cliente fica guardado.
Yields:
None: Enquanto a aplicação serve requisições.
"""
http: HTTPClient = HTTPClient(**settings.openpix_kwargs(), timeout=15.0)
app.state.openpix = OpenPixClient(http)
try:
yield
finally:
await http.aclose()
def get_openpix_service(request: Request) -> OpenPixService:
"""Entrega o serviço de cobranças já montado.
Args:
request (Request): A requisição em curso.
Returns:
OpenPixService: O serviço, sobre o cliente do lifespan.
"""
return OpenPixService(request.app.state.openpix)
O AppID vai no header Authorization cru
Sem Bearer, sem Basic. É a string que o painel da OpenPix mostra, e
ela vale para a conta inteira. default_headers a coloca em toda
requisição do cliente.
Fluxo 1 — abrir a cobrança¶
from tempest_fastapi_sdk.integrations.payment.openpix import (
Charge,
ChargePayload,
CustomerPayload,
OpenPixClient,
reais_to_cents,
)
class OpenPixService:
"""Regras de cobrança Pix."""
def __init__(self, client: OpenPixClient) -> None:
"""Guarda o cliente gerado.
Args:
client (OpenPixClient): O cliente da OpenPix.
"""
self.client: OpenPixClient = client
async def open_charge(
self,
*,
reference: str,
amount_brl: str,
customer_name: str,
customer_email: str,
) -> Charge:
"""Abre uma cobrança Pix para um pedido.
Args:
reference (str): O id do pedido, usado como `correlationID`.
amount_brl (str): Valor em reais, como texto ("19.90").
customer_name (str): Nome do pagador.
customer_email (str): E-mail do pagador.
Returns:
Charge: A cobrança criada, com o BR Code para desenhar o QR.
Raises:
ValueError: Se a resposta vier sem a cobrança.
"""
response = await self.client.create_charge(
body=ChargePayload(
correlation_id=reference,
value=reais_to_cents(amount_brl),
comment=f"Pedido {reference}",
customer=CustomerPayload(
name=customer_name,
email=customer_email,
),
expires_in=3600,
),
return_existing=True,
)
if response.charge is None:
raise ValueError(f"OpenPix não devolveu a cobrança de {reference}")
return response.charge
O router só repassa e escolhe o que o app precisa ver:
from typing import Any
from fastapi import APIRouter, Depends
from src.api.dependencies.payments import get_openpix_service
from src.services.openpix import OpenPixService
router: APIRouter = APIRouter(prefix="/api", tags=["checkout"])
@router.post("/checkout")
async def checkout(
service: OpenPixService = Depends(get_openpix_service),
) -> dict[str, Any]:
"""Abre a cobrança do pedido e devolve o que o app desenha.
Args:
service (OpenPixService): O serviço de cobranças.
Returns:
O BR Code, a imagem do QR e o link de pagamento.
"""
charge = await service.open_charge(
reference="pedido-1",
amount_brl="19.90",
customer_name="Ana",
customer_email="ana@example.com",
)
return {
"br_code": charge.br_code,
"qr_code_image": charge.qr_code_image,
"payment_link_url": charge.payment_link_url,
"status": charge.status,
}
Rodando isso contra a API, a rota devolve:
{
"br_code": "00020101021226830014BR.GOV.BCB.PIX...",
"qr_code_image": "https://api.woovi.com/openpix/charge/brcode/image/x.png",
"payment_link_url": "https://openpix.com.br/pay/pedido-1",
"status": "ACTIVE"
}
São três formas de cobrar a mesma cobrança, e você escolhe pela interface:
| Campo | O que é | Quando usar |
|---|---|---|
br_code |
A string EMV do Pix | App próprio: você desenha o QR e oferece "copia e cola" |
qr_code_image |
URL de um PNG do QR | Página simples, e-mail, PDF |
payment_link_url |
Página de pagamento hospedada pela OpenPix | Quando não quer construir tela nenhuma |
Construa pelo nome Python — o type-checker aceita
Os campos gerados carregam o nome do fio em validation_alias +
serialization_alias, não em alias. A diferença não aparece em runtime,
e aparece no seu editor: com alias, o pyright renomeia o parâmetro e
rejeita ChargePayload(correlation_id=...) pedindo correlationID.
Medido com basedpyright: com a forma dividida, 0 erros para o nome
Python, e model_validate / model_dump(by_alias=True) seguem falando a
grafia da OpenPix.
return_existing=True deixa a chamada idempotente
Sem ele, um segundo POST com o mesmo correlationID é erro. Com ele, a
OpenPix devolve a cobrança que já existe — que é o que você quer quando o
usuário aperta "pagar" duas vezes ou o app repete a requisição.
expires_in é em segundos, mínimo de 5 minutos
O padrão da OpenPix é uma cobrança de longa validade. Se o seu pedido
reserva estoque, feche essa janela: expires_in=3600 expira em uma hora e
a cobrança some do "aguardando pagamento" sozinha.
Fluxo 2 — descobrir se foi paga¶
Existem três caminhos, e eles não são alternativas: são papéis diferentes.
| Caminho | O que é | Papel |
|---|---|---|
Webhook CHARGE_COMPLETED |
A OpenPix avisa | Aviso. Rápido, mas chega por rede aberta |
get_charge |
Você pergunta | Fato. É o que autoriza liberar |
list_charges(status=...) |
Você varre | Rede de segurança. Pega o que o webhook perdeu |
A regra que resume: o webhook avisa, a API confirma.
O webhook, verificado¶
from fastapi import APIRouter, Depends
from tempest_fastapi_sdk.integrations.payment.openpix import (
Charge,
OpenPixEvent,
OpenPixWebhookEvent,
make_openpix_webhook_dependency,
)
from src.api.dependencies.payments import get_openpix_service
from src.services.openpix import OpenPixService
router: APIRouter = APIRouter(prefix="/webhooks", include_in_schema=False)
verify = make_openpix_webhook_dependency()
@router.post("/openpix")
async def receive_openpix(
event: OpenPixWebhookEvent = Depends(verify),
service: OpenPixService = Depends(get_openpix_service),
) -> dict[str, str]:
"""Recebe uma entrega já verificada e confirma antes de liberar.
Args:
event (OpenPixWebhookEvent): A entrega verificada e decodificada.
service (OpenPixService): O serviço de cobranças.
Returns:
Uma confirmação, para a OpenPix parar de reentregar.
"""
if event.event is not OpenPixEvent.CHARGE_COMPLETED:
return {"status": "ignored", "event": event.event_name}
charge = Charge.model_validate(event.payload["charge"])
reference = charge.correlation_id or ""
if not await service.is_paid(reference):
return {"status": "not-settled"}
await service.release(reference)
return {"status": "released"}
A dependência faz três coisas antes do corpo da rota rodar: confere a
assinatura RSA do header x-webhook-signature, decodifica o JSON e resolve a
string event para um membro de OpenPixEvent. O que sobra em
event.payload é o dict cru — você valida só o ramo que interessa.
Medido com esse router de pé (chave de teste, corpo assinado):
| Entrega | Resposta |
|---|---|
| Sem o header de assinatura | 401 |
Assinatura válida, OPENPIX:CHARGE_COMPLETED |
200 {"status": "released"} |
| A mesma entrega de novo | 200 {"status": "released"} — este router não deduplica; ver abaixo |
| Evento que este SDK não conhece | 200 {"status": "ignored", "event": "..."} |
A chave pública da OpenPix é RSA-1024
Conferido carregando na cryptography: 1024 bits, expoente 65537 —
abaixo do piso de 2048 bits que o NIST recomenda desde 2013. Isso
limita o que a assinatura consegue provar.
Trate uma assinatura válida como evidência de que a entrega veio da
OpenPix, não como autorização para movimentar dinheiro. Quem autoriza é
a releitura pela API — que é exatamente o service.is_paid acima. Nada
aqui aumenta a força da chave; a mitigação é não confiar nela além do que
ela é.
Reenvio (replay) e entrega repetida
A assinatura cobre o corpo e mais nada, então uma entrega capturada
continua válida para sempre — e a própria OpenPix reentrega quando não
recebe 200. Trate o handler como idempotente: chave pelo
correlationID e ignore o que já processou. A liberação em si vai sob
claim_once (de tempest_fastapi_sdk.wallet), que marca o pedido com um
UPDATE ... WHERE paid_at IS NULL e devolve False para a segunda
entrega. O caminho inteiro sobre o contrato canônico — releitura com
confirm_pix_payment, conferência de status e valor, claim_once — está
no protocolo de Pix.
Veja também Idempotência.
A conferência¶
from tempest_fastapi_sdk.integrations.payment.openpix import ChargeStatus
async def is_paid(self, reference: str) -> bool:
"""Pergunta à API se a cobrança está liquidada.
Args:
reference (str): O `correlationID` da cobrança.
Returns:
bool: `True` somente quando a OpenPix responde `COMPLETED`.
"""
response = await self.client.get_charge(id=reference)
charge = response.charge
return charge is not None and charge.status == ChargeStatus.COMPLETED
Compare status com ==, nunca com is
Os modelos gerados herdam de BaseSchema, que usa
use_enum_values=True: o campo chega como str, não como membro do enum.
Medido: charge.status == ChargeStatus.COMPLETED é True,
charge.status is ChargeStatus.COMPLETED é False — em toda entrega,
em silêncio. ChargeStatus é um str enum, então a comparação por ==
funciona com o membro e com a string literal.
Os três valores documentados são ACTIVE, COMPLETED e EXPIRED.
O campo aceita um quarto valor — de propósito (v0.270.0)
Charge.status é anotado ChargeStatus | str | None, e não só
ChargeStatus. A especificação declara os três valores em
Charge.status e deixa WebhookCharge.status como string livre — ou
seja, a própria Woovi não se comprometeu com a lista no objeto que
entrega.
Enquanto o campo era um enum fechado, um estado novo do provedor não
virava um estado desconhecido: virava ValidationError dentro do
cliente, que chega ao seu serviço como 500, com o valor real em
lugar nenhum da resposta. Medido antes da correção:
pydantic_core._pydantic_core.ValidationError: 1 validation error
charge.status
Input should be 'ACTIVE', 'COMPLETED' or 'EXPIRED'
[input_value='CANCELLED']
A comparação por == não muda: valor conhecido continua batendo com o
membro do enum. Se você ramifica em status, trate o caso de fora da
lista — pelo contrato canônico isso é
PaymentStatus.UNKNOWN.
A reconciliação¶
Webhook é rede: uma entrega vai se perder. Um job periódico varre o que ficou para trás — e note que ele lista o que ainda está aberto no lado da OpenPix, cruzando com o que o seu banco acha que está aberto:
from datetime import UTC, datetime, timedelta
from tempest_fastapi_sdk.integrations.payment.openpix import ChargeStatus, OpenPixClient
async def sweep_pending(client: OpenPixClient) -> list[str]:
"""Lista as cobranças ainda abertas nas últimas 24 horas.
Args:
client (OpenPixClient): O cliente da OpenPix.
Returns:
Os `correlationID` que continuam aguardando pagamento.
"""
now = datetime.now(UTC)
response = await client.list_charges(
start=now - timedelta(days=1),
end=now,
status=ChargeStatus.ACTIVE,
)
return [charge.correlation_id or "" for charge in response.charges]
Todo pedido que o seu banco tem como "aguardando" e que não aparece nessa
lista terminou de outro jeito: ou foi pago (e o webhook se perdeu) ou expirou.
Consulte cada um com get_charge e feche o caso.
A listagem não tem paginação na especificação
GET /api/v1/charge aceita start, end, status, customer e
subscription — e mais nada. A resposta traz page_info, mas a
especificação não declara skip/limit, então o cliente gerado não os
expõe. Para janelas grandes, varra por intervalos de tempo menores.
Fluxo 3 — mudar o prazo, ou desistir¶
from tempest_fastapi_sdk.integrations.payment.openpix import (
ChargePatchPayload,
OpenPixClient,
)
async def extend(client: OpenPixClient, reference: str, until: str) -> None:
"""Empurra o vencimento de uma cobrança aberta.
Args:
client (OpenPixClient): O cliente da OpenPix.
reference (str): O `correlationID` da cobrança.
until (str): Nova data de expiração, em ISO 8601.
"""
await client.update_charge(
id=reference,
body=ChargePatchPayload(expires_date=until),
)
async def cancel(client: OpenPixClient, reference: str) -> None:
"""Cancela uma cobrança que não será mais paga.
Args:
client (OpenPixClient): O cliente da OpenPix.
reference (str): O `correlationID` da cobrança.
"""
await client.delete_charge(id=reference)
patch só mexe na expiração — é o único campo que ChargePatchPayload tem.
Para mudar valor, abra outra cobrança.
Fluxo 4 — estornar¶
from tempest_fastapi_sdk.integrations.payment.openpix import (
ChargeRefundPayload,
OpenPixClient,
reais_to_cents,
)
async def refund(
client: OpenPixClient,
*,
reference: str,
refund_reference: str,
amount_brl: str,
) -> None:
"""Devolve o dinheiro de uma cobrança paga.
Args:
client (OpenPixClient): O cliente da OpenPix.
reference (str): O `correlationID` da cobrança paga.
refund_reference (str): A sua chave para este estorno.
amount_brl (str): Quanto devolver, em reais.
"""
await client.refund_charge(
id=reference,
body=ChargeRefundPayload(
correlation_id=refund_reference,
value=reais_to_cents(amount_brl),
comment="Pedido cancelado",
),
)
O estorno tem correlationID próprio — ele é um registro seu, separado da
cobrança. list_charge_refunds lista os estornos de uma cobrança,
e o valor é opcional: sem ele, a OpenPix devolve o total.
Dinheiro: centavo inteiro, não float¶
A especificação escreve, textualmente, "Value in cents of this charge" — e
tipa o campo como number. Até a v0.258.0 o modelo gerado copiava isso: 1990
virava o float 1990.0, e o cliente mandava {"value": 1990.0} de volta pelo
fio. Dinheiro que passou por float é dinheiro que pode estar errado: some
alguns e você chega em 0.30000000000000004.
Desde a v0.259.0 o campo já chega int
O overlay da spec (veja O que este pacote corrige na
spec) reescreve os campos de valor e
de contagem como integer antes de gerar, então Charge.value,
ChargePayload.value, Transaction.value, SubAccount.balance e os
outros 153 são int — 157 campos ao todo, contados pelo overlay. Os três helpers continuam valendo — o que muda é
que to_cents deixou de ser obrigatório para ler um modelo gerado.
from decimal import Decimal
from tempest_fastapi_sdk.integrations.payment.openpix import (
cents_to_reais,
reais_to_cents,
to_cents,
)
assert reais_to_cents("19.90") == 1990
assert to_cents(1990.0) == 1990
assert cents_to_reais(1990) == Decimal("19.90")
reais_to_centsé o que você usa ao criar: recebe reais e devolve centavo. Arredonda meio-para-cima (0.005->1), que é o que uma pessoa espera de dinheiro e não é o que oroundembutido faz — ele arredonda meio-para-par, eround(0.005 * 100)dá0.to_centsé o que você usa ao ler um payload cru — o dicionário que saiu do JSON, um webhook, um campo que o modelo gerado não declara. Ele recusa fração de propósito:to_cents(19.9)levantaValueError, porque o campo já é centavo e uma fração significa que alguém está tratando reais como se fossem centavos. Arredondar em silêncio esconderia esse erro atrás de um número plausível. Sobre modelo gerado ele não estreita mais nada (o campo já éint), mas continua validando.cents_to_reaisdevolveDecimal, para o valor chegar exato até a formatação.
O que este pacote corrige na spec¶
vendor/openpix-openapi.json é o documento que a Woovi publica, byte a byte —
atualizar é um diff só do que eles mudaram. Tudo que a gente sabe que o
documento erra vive em scripts/openpix_overlay.py, uma correção nomeada por
vez, cada uma com a evidência ao lado. São quatro famílias:
1. Unidade inteira. 157 campos tipados number que são valor em centavo,
contagem ou código HTTP viram integer. A regra lê a descrição antes do nome,
porque o mesmo nome muda de unidade: inputAmount é centavo na lista de
depósito e é "BRL (currency unit, not cents)" na cotação de stablecoin — essa
continua float, junto de outputAmount, basePrice e rate, que são
fracionários de verdade. Os 19 que continuam float estão fixados por teste:
number novo num refresh falha, porque a unidade de um campo é julgamento de
pessoa, não default silencioso.
2. Campos que a resposta traz e o documento não declara. Charge ganha
fee, discount e valueWithDiscount; ChargeRefund ganha refundId (o
documento declara esse campo só no Refund, de estorno de transação Pix, e a
API responde com ele nos dois). Todos opcionais — resposta sem eles continua
validando.
from tempest_fastapi_sdk.integrations.payment.openpix import Charge
charge = Charge.model_validate(
{"value": 199000, "fee": 2500, "discount": 0, "valueWithDiscount": 199000}
)
charge.fee, charge.value_with_discount # (2500, 199000) — int, não float
3. Campos que o documento declara com o tipo errado. Charge.expiresIn é
declarado string e a API responde 3600, então toda leitura de cobrança
falhava na validação até a v0.269.0. A mesma varredura achou mais quatro, todos
dinheiro: PixQrCode.value, WithdrawTransaction.value, o dispute.value
inline de GET /api/v1/dispute/{id} e o pix.value dos três callbacks
receivedPix*. Os três primeiros são endereçados por nome de componente
(MISTYPED_PROPERTIES); os dois últimos não têm componente para nomear —
moram inline sob um path e sob um callback — e são endereçados por JSON
pointer (MISTYPED_POINTERS).
from tempest_fastapi_sdk.integrations.payment.openpix import GetDisputeResponse
reply: GetDisputeResponse = GetDisputeResponse.model_validate(
{"dispute": {"status": "CREATED", "value": 15000, "type": "MED"}}
)
print(reply.dispute) # value=15000 — int, não a string que a spec declara
4. Enum que o provedor não honra numa resposta. Charge.status é declarado
com três valores, e WebhookCharge.status — o mesmo objeto, entregue por
webhook — é string livre. Enquanto o campo era enum fechado, um quarto estado
não virava estado desconhecido: virava ValidationError dentro do cliente, ou
seja 500 no seu serviço. LIFTED_ENUMS move os valores para um componente
próprio e deixa a propriedade como anyOf dele com string, então a classe
ChargeStatus continua existindo e o campo aceita o que a Woovi mandar.
A correção se aposenta sozinha
Toda correção destas famílias só aplica quando o que está declarado
difere do corrigido. No dia em que a Woovi arrumar o documento, a linha
correspondente some do log de make openpix-regen — sem ninguém precisar
lembrar de remover a entrada.
Uma quinta família foi removida na v0.260.0
A v0.259.0 adicionou aqui um DELETE /api/v1/payment/{id}, na conta de que
o fluxo de transferência em dois passos não tinha caminho de volta. O
documento que a Woovi publica tem só get nesse path, e nenhum caminho de
payment tem delete — o DELETE documentado é em /api/v1/charge/{id}.
Corrigir um documento é para o que ele erra; endpoint que ninguém
observou é palpite, e palpite não entra em caminho de dinheiro.
Model de resposta guarda o que a spec não previu
Desde a v0.259.0 todo model alcançável por resposta é gerado com
extra="allow". Campo que o provedor adicionar amanhã fica disponível em
model_extra em vez de sumir na validação — que é exatamente como o fee
se perdia antes de ser declarado. Model de payload continua com
extra="ignore": ali chave inesperada é erro de digitação de quem chamou,
e levá-la ao provedor é pior que descartar.
Registrando o webhook na OpenPix¶
Dá para cadastrar pelo painel, ou pela própria API — o que deixa o endereço versionado junto do deploy:
from tempest_fastapi_sdk.integrations.payment.openpix import (
OpenPixClient,
CreateWebhookBody,
WebhookEventEnum,
WebhookPayload,
)
async def register_webhook(client: OpenPixClient, url: str) -> None:
"""Assina o evento de cobrança paga para uma URL.
Args:
client (OpenPixClient): O cliente da OpenPix.
url (str): O endereço público de `POST /webhooks/openpix`.
"""
await client.create_webhook(
body=CreateWebhookBody(
webhook=WebhookPayload(
name="cobranca-paga",
event=WebhookEventEnum.OPENPIX_CHARGE_COMPLETED,
url=url,
is_active=True,
)
)
)
O prefixo dos eventos não é uniforme, e isso é da OpenPix
OpenPixEvent traz os 28 eventos verbatim. Cobrança, transação, movimento
e disputa carregam o namespace OPENPIX:
(OpenPixEvent.CHARGE_COMPLETED.value == "OPENPIX:CHARGE_COMPLETED"). As
famílias Pix-automático e account-register não
(OpenPixEvent.PIX_AUTOMATIC_APPROVED.value == "PIX_AUTOMATIC_APPROVED").
Parece erro de transcrição, não é — e por isso tem teste fixando os dois
casos.
Testando sem rede¶
O cliente recebe o transporte por injeção, então um httpx.MockTransport
exercita o fluxo inteiro sem sair da máquina:
import httpx
from tempest_fastapi_sdk import HTTPClient
from tempest_fastapi_sdk.integrations.payment.openpix import (
ChargePayload,
OpenPixClient,
)
seen: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
"""Registra a requisição e devolve uma cobrança falsa.
Args:
request (httpx.Request): A requisição que sairia.
Returns:
httpx.Response: A resposta canned.
"""
seen.append(request)
return httpx.Response(
200,
json={
"charge": {"status": "ACTIVE", "correlationID": "pedido-1", "value": 1990},
"correlationID": "pedido-1",
"brCode": "00020101021226830014BR.GOV.BCB.PIX...",
},
)
async def test_charge_carries_the_customer() -> None:
"""Os dados do cliente chegam ao corpo da requisição."""
http = HTTPClient(
base_url="https://api.woovi-sandbox.com",
transport=httpx.MockTransport(handler),
)
client = OpenPixClient(http)
await client.create_charge(
body=ChargePayload(correlation_id="pedido-1", value=1990)
)
assert seen[-1].url.path == "/api/v1/charge"
Para o webhook, injete um verificador sobre um par de chaves de teste em vez da chave da OpenPix:
from tempest_fastapi_sdk.integrations.payment.openpix import (
make_openpix_webhook_dependency,
webhook_verifier,
)
verify = make_openpix_webhook_dependency(
verifier=webhook_verifier(public_key_pem="-----BEGIN PUBLIC KEY-----\n...")
)
As duas metades do módulo¶
Vale saber qual parte veio de onde, porque elas se mantêm de formas diferentes:
| Metade | O que é | De onde vem |
|---|---|---|
| Gerada | OpenPixClient, DEFAULT_BASE_URL, 684 classes de schema |
A spec, verbatim |
| À mão | OpenPixEnvironment, OpenPixEvent, webhook, helpers de dinheiro |
O que a spec não diz |
A metade gerada é versionada, não escrita à mão
scripts/regen_openpix.py produz schemas.py e client.py a partir da
spec fixada em vendor/openpix-openapi.json, e um teste falha se os
arquivos em disco divergirem do que o script produz. Para atualizar
quando a OpenPix mexer na API: troque o arquivo em vendor/, rode make
openpix-regen, e o diff mostra exatamente o que o terceiro mudou.
Os modelos carregam no primeiro uso, não no import
Construir as 684 classes Pydantic custa perto de um terço de segundo.
Importar o pacote só para usar to_cents não deveria pagar isso, então a
metade gerada resolve por PEP 562.
Medido em 2026-10-02 nesta máquina, com tempest_fastapi_sdk já
importado, mediana de 7 processos novos: ~14 ms para importar o
subpacote, ~320 ms no primeiro acesso a um nome gerado no Python
3.11 (~310 ms no 3.13) e ~0,03 ms nos seguintes. Os números variam
com a máquina; o que não varia é a ordem de grandeza entre eles — quem só
usa to_cents nunca paga o primeiro acesso.
Recapitulando¶
- Um
HTTPClientpor processo, criado no lifespan, com o AppID emdefault_headerse a base URL vinda deOpenPixEnvironment. correlationIDé o id do seu pedido — é ele que amarra criação, webhook, consulta e estorno.- Abrir a cobrança é
create_chargecomreturn_existing=True; a resposta trazbr_code,qr_code_imageepayment_link_url, e você escolhe pela interface. - O webhook avisa, a API confirma.
make_openpix_webhook_dependency()verifica e entrega o evento tipado;get_chargeé o que autoriza liberar — a chave da OpenPix é RSA-1024. - O handler é idempotente, porque a mesma entrega chega mais de uma vez.
- Um job de reconciliação varre
status=ACTIVEe fecha o que o webhook perdeu. - Dinheiro em centavo inteiro:
reais_to_centspara criar,to_centspara ler,cents_to_reaispara exibir. - Compare
statuscom==, nunca comis— o campo chega comostr.