Stripe (cartão, assinatura, Checkout)¶
Todo serviço nosso que cobra cartão reescrevia a mesma camada Stripe:
cliente HTTP síncrono chamado de dentro de rota async, schemas
redigitados à mão, e uma verificação de webhook que cada um derivava do
seu jeito. tempest_fastapi_sdk.integrations.payment.stripe entrega essa
camada pronta.
Instalação
Nenhum extra. A integração usa o HTTPClient que o SDK já tem —
uv add "tempest-fastapi-sdk[http]" se você ainda não puxou o
httpx.
Quando usar isto
Cobrança internacional com cartão, assinatura ou Checkout hospedado. Para Pix, use OpenPix, que é gerada da spec do provedor.
O caminho mínimo¶
import asyncio
from decimal import Decimal
from tempest_fastapi_sdk.integrations.payment.stripe import (
StripeClient,
stripe_http_client,
to_minor_units,
)
async def main() -> None:
"""Run this example."""
client = StripeClient(stripe_http_client("sk_test_..."))
intent = await client.payment_intents.create(
{
"amount": to_minor_units(Decimal("199.90"), "brl"),
"currency": "brl",
"automatic_payment_methods": {"enabled": True},
"metadata": {"order_id": "1042"},
}
)
print(intent.id, intent.status, intent.client_secret)
asyncio.run(main())
Três coisas aconteceram sem você pedir: o corpo saiu form-encoded com
notação de colchetes (metadata[order_id]=1042), a requisição levou um
Idempotency-Key, e o header Stripe-Version fixou a versão da API.
client_secret é credencial
Ele autoriza confirmar aquele pagamento. Vai na resposta para o dono do pedido e em mais lugar nenhum — nunca em log, nunca em telemetria.
Como funciona, peça por peça¶
O corpo é form-encoded, não JSON¶
A Stripe não aceita JSON em escrita: todas as 588 operações de
escrita da spec declaram application/x-www-form-urlencoded. Aninhamento
vira colchete:
from tempest_fastapi_sdk import form_encode
print(
form_encode(
{
"mode": "payment",
"line_items": [{"price": "price_123", "quantity": 2}],
"metadata": {"order_id": "1042"},
}
)
)
{'mode': 'payment',
'line_items[0][price]': 'price_123',
'line_items[0][quantity]': '2',
'metadata[order_id]': '1042'}
form_encode é público e serve a qualquer API form-encoded — e o
gerador de integrações do SDK agora usa a mesma função: uma operação
cujo requestBody declara form sai com data=form_encode(payload) em
vez de json=payload. Antes disso, um cliente gerado contra a Stripe
tinha 100% das escritas rejeitadas.
Detalhes que o form_encode decide por você:
| Valor | Vai como | Por quê |
|---|---|---|
True / False |
true / false |
str(True) mandaria "True" |
None |
não vai | string vazia é valor real, ela limpa o campo |
Decimal("10.50") |
10.50 |
passar por float perde centavo |
Enum |
o .value |
str() mandaria "Classe.MEMBRO" |
datetime |
ISO-8601 |
Dinheiro: moeda de zero decimais existe¶
A Stripe cobra em menor unidade. Para a maioria isso é centavo, mas para JPY, KRW, VND e outras 13 a menor unidade é a unidade — e dividir tudo por 100 é um bug de cobrança silencioso que nenhum teste em BRL pega.
from decimal import Decimal
from tempest_fastapi_sdk.integrations.payment.stripe import (
from_minor_units,
to_minor_units,
)
print(to_minor_units(Decimal("10.50"), "brl")) # 1050
print(to_minor_units(Decimal("1050"), "jpy")) # 1050 <- não 105000
print(from_minor_units(1050, "jpy")) # Decimal('1050')
print(to_minor_units(Decimal("10.505"), "bhd")) # 10505 (três decimais)
As duas tabelas (ZERO_DECIMAL_CURRENCIES, THREE_DECIMAL_CURRENCIES)
vêm da documentação da Stripe e são travadas por teste, então uma mudança
lá aparece como falha aqui — não como cobrança cem vezes maior.
Webhook: o que a Stripe assina não é o corpo¶
O header é t=<timestamp>,v1=<hmac>, e o HMAC cobre f"{t}.{body}".
Assinar só o corpo é o erro mais comum de verificação escrita à mão — e
ele falha silenciosamente como 401 em toda entrega.
from typing import Any
from fastapi import APIRouter, Depends
from tempest_fastapi_sdk.integrations.payment.stripe import (
StripeEvent,
StripeWebhookEvent,
make_stripe_webhook_dependency,
)
from src.core.settings import settings
router = APIRouter(prefix="/webhooks", tags=["webhooks"])
verified = make_stripe_webhook_dependency(settings.STRIPE_WEBHOOK_SECRET)
@router.post("/stripe", include_in_schema=False)
async def stripe_webhook(
event: StripeWebhookEvent = Depends(verified),
) -> dict[str, Any]:
"""Recebe uma entrega já verificada e tipada."""
if event.event is StripeEvent.PAYMENT_INTENT_SUCCEEDED:
return {"handled": True, "intent": event.data_object["id"]}
return {"handled": False, "type": event.event_type}
- Assinatura inválida, ausente ou fora da janela de 5 minutos → 401, antes do seu handler.
- Rotação de segredo funciona: a Stripe manda um
v1por segredo ativo e qualquer um que bata é aceito. - Tipo de evento desconhecido não derruba a rota —
eventficaNoneeevent_typeguarda a string. Um release da Stripe não vira incidente seu. event_idexiste para deduplicar: entregas repetem por desenho.
Assinatura verificada não é autorização
Ela prova que a entrega veio da Stripe. Não prova que o estado que
você vai mudar é o atual — entregas repetem e chegam fora de ordem.
Antes de liberar mercadoria em payment_intent.succeeded, releia o
intent pela API.
Para testar sem inventar a assinatura errada, o módulo exporta o assinador:
import json
from tempest_fastapi_sdk.integrations.payment.stripe import sign_payload
body = json.dumps({"id": "evt_1", "type": "payment_intent.succeeded"}).encode()
header = sign_payload(body, "whsec_test", timestamp=1_770_000_000)
Os 265 tipos de evento, como enum¶
StripeEvent é gerado da spec — do parâmetro enabled_events de
POST /v1/webhook_endpoints, que é onde a Stripe enumera os tipos (o
objeto event não os enumera). Um teste de drift falha se o arquivo for
editado à mão.
from tempest_fastapi_sdk.integrations.payment.stripe import StripeEvent
print(StripeEvent.INVOICE_PAID.value) # invoice.paid
print(StripeEvent.has_value("customer.created")) # True
Idempotência: por que toda escrita leva chave¶
Um timeout no POST /v1/payment_intents deixa você sem saber se o
dinheiro se moveu. Retry sem chave cria um segundo pagamento; com chave,
a Stripe repete a resposta original por 24 horas. O cliente põe um UUID4
em toda escrita — e você troca por uma chave sua quando quiser que a
janela cubra o seu fluxo:
import asyncio
from tempest_fastapi_sdk.integrations.payment.stripe import (
StripeClient,
stripe_http_client,
)
async def main() -> None:
"""Run this example."""
client = StripeClient(stripe_http_client("sk_test_..."))
order_id = "1042"
await client.payment_intents.create(
{"amount": 19990, "currency": "brl"},
idempotency_key=f"order-{order_id}",
)
asyncio.run(main())
Paginação por cursor¶
import asyncio
from tempest_fastapi_sdk.integrations.payment.stripe import (
StripeClient,
stripe_http_client,
)
async def main() -> None:
"""Run this example."""
client = StripeClient(stripe_http_client("sk_test_..."))
filters = {"created": {"gte": 1_770_000_000}}
async for customer in client.customers.auto_paginate(filters):
print(customer.id, customer.email)
asyncio.run(main())
O cursor é o id do último item, não um offset, então a varredura continua correta enquanto objetos são criados por baixo dela.
Erros que dizem o que houve¶
import asyncio
from tempest_fastapi_sdk.integrations.payment.stripe import (
StripeClient,
StripeError,
stripe_http_client,
)
async def main() -> None:
"""Run this example."""
client = StripeClient(stripe_http_client("sk_test_..."))
try:
await client.payment_intents.create({"amount": 19990, "currency": "brl"})
except StripeError as error:
print(error.status_code) # 402
print(error.error_type) # card_error
print(error.code) # card_declined
print(error.decline_code) # insufficient_funds
print(error.request_id) # req_... (o que o suporte da Stripe pede)
asyncio.run(main())
Modelos finos, e nada se perde¶
Os modelos nomeiam os campos que carregam decisão — status, amount,
currency, os ids que ligam objetos — e usam extra="allow". O resto do
objeto continua acessível:
import asyncio
from tempest_fastapi_sdk.integrations.payment.stripe import (
StripeClient,
stripe_http_client,
)
async def main() -> None:
"""Run this example."""
client = StripeClient(stripe_http_client("sk_test_..."))
intent = await client.payment_intents.retrieve("pi_123")
print(intent.status) # tipado
print((intent.model_extra or {}).get("next_action")) # preservado
asyncio.run(main())
Campo expansível (customer, payment_intent, …) é str: sem
expand, a Stripe manda o id. Peça o objeto e leia do model_extra:
import asyncio
from tempest_fastapi_sdk.integrations.payment.stripe import (
StripeClient,
stripe_http_client,
)
async def main() -> None:
"""Run this example."""
client = StripeClient(stripe_http_client("sk_test_..."))
intent = await client.payment_intents.retrieve(
"pi_123", params={"expand": ["customer"]}
)
print(intent.customer)
asyncio.run(main())
Por que este cliente é escrito à mão, e não gerado
O SDK gera integrações de OpenAPI — a OpenPix é gerada. A spec da
Stripe não sobrevive à viagem, e isso foi medido na
2026-07-29.dahlia:
- gerar a superfície inteira dá
schemas.pycom 3,3 MB / 81 mil linhas, e importar custa 5,8 s e 492 MB de RSS; - fatiar por recurso não resolve:
/v1/pricessozinho alcança 864 dos 1440 schemas, e os dez recursos centrais juntos, 1020. Não existe subconjunto pequeno.
Então o que vem da spec continua vindo dela — versão da API, base URL
e os 265 eventos, via make stripe-fetch — e o resto é código que
cabe na cabeça. Os números e como refazê-los estão em
scripts/regen_stripe.py.
Configuração¶
from tempest_fastapi_sdk import BaseAppSettings
from tempest_fastapi_sdk.integrations.payment.stripe import (
StripeClient,
stripe_http_client,
)
class Settings(BaseAppSettings):
"""Settings do serviço."""
STRIPE_API_KEY: str = ""
STRIPE_WEBHOOK_SECRET: str = ""
settings = Settings()
client = StripeClient(stripe_http_client(settings.STRIPE_API_KEY))
O segredo do webhook é por endpoint, não por conta: um serviço com endpoint de teste e de produção tem dois.
Testando¶
Injete um httpx.MockTransport e inspecione a requisição que sairia:
from urllib.parse import parse_qs
import httpx
from tempest_fastapi_sdk.integrations.payment.stripe import (
StripeClient,
stripe_http_client,
)
requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
"""Registra a requisição e devolve um cliente falso."""
requests.append(request)
return httpx.Response(200, json={"id": "cus_1", "object": "customer"})
async def test_create_customer() -> None:
"""A escrita sai form-encoded com colchetes."""
client = StripeClient(
stripe_http_client("sk_test_x", transport=httpx.MockTransport(handler))
)
await client.customers.create({"email": "ana@example.com", "metadata": {"o": "1"}})
assert parse_qs(requests[-1].content.decode()) == {
"email": ["ana@example.com"],
"metadata[o]": ["1"],
}
Recap¶
StripeClientsobre oHTTPClientdo SDK: retry, circuit breaker eStripe-Versionfixo vêm de um lugar só.- Escrita é form-encoded com colchetes (
form_encode, público e usado também pelo gerador) e levaIdempotency-Keypor padrão. - Dinheiro respeita moeda de zero e de três decimais.
- Webhook verifica sobre
t.body, com janela de replay, rotação de segredo e evento desconhecido que não derruba a rota. - 265 eventos como enum, gerados da spec com teste de drift.
- Modelos finos com
extra="allow"— o cliente é escrito à mão porque gerar a spec inteira custa 3,3 MB e 492 MB de RSS, medidos.