Stripe (cards, subscriptions, Checkout)¶
Every service of ours that charges a card used to rewrite the same Stripe
layer: a synchronous HTTP client called from inside an async route,
hand-retyped schemas, and a webhook verification each one derived its own
way. tempest_fastapi_sdk.integrations.payment.stripe ships that layer.
Installation
No extra. The integration uses the HTTPClient the SDK already has —
uv add "tempest-fastapi-sdk[http]" if you have not pulled httpx
in yet.
When to use this
International card payments, subscriptions or hosted Checkout. For Pix, use OpenPix, which is generated from the provider's specification.
The minimal path¶
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())
Three things happened without being asked for: the body went out
form-encoded in bracket notation (metadata[order_id]=1042), the
request carried an Idempotency-Key, and the Stripe-Version header
pinned the API version.
client_secret is a credential
It authorizes confirming that payment. It belongs in the response to the order's own owner and nowhere else — never in a log, never in telemetry.
How it works, piece by piece¶
The body is form-encoded, not JSON¶
Stripe accepts no JSON on writes: all 588 write operations in the
specification declare application/x-www-form-urlencoded. Nesting is
brackets:
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 is public and serves any form-encoded API — and the SDK's
integration generator now uses the same function: an operation whose
requestBody declares form comes out with data=form_encode(payload)
instead of json=payload. Before that, a client generated against Stripe
had 100% of its writes rejected.
What form_encode decides for you:
| Value | Goes as | Why |
|---|---|---|
True / False |
true / false |
str(True) would send "True" |
None |
not sent | an empty string is a real value — it clears the field |
Decimal("10.50") |
10.50 |
going through float loses a cent |
Enum |
its .value |
str() would send "Class.MEMBER" |
datetime |
ISO-8601 |
Money: zero-decimal currencies exist¶
Stripe charges in the smallest unit. For most currencies that is a cent, but for JPY, KRW, VND and 13 others the smallest unit is the unit — and dividing everything by 100 is a silent billing bug no BRL-only test will catch.
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 <- not 105000
print(from_minor_units(1050, "jpy")) # Decimal('1050')
print(to_minor_units(Decimal("10.505"), "bhd")) # 10505 (three decimals)
Both tables (ZERO_DECIMAL_CURRENCIES, THREE_DECIMAL_CURRENCIES) come
from Stripe's documentation and are pinned by a test, so an upstream
change shows up as a failure here — not as a hundredfold charge.
Webhooks: what Stripe signs is not the body¶
The header is t=<timestamp>,v1=<hmac>, and the HMAC covers
f"{t}.{body}". Signing the body alone is the most common mistake in a
hand-rolled verification — and it fails silently, as a 401 on every
delivery.
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]:
"""Receive an already verified, typed delivery."""
if event.event is StripeEvent.PAYMENT_INTENT_SUCCEEDED:
return {"handled": True, "intent": event.data_object["id"]}
return {"handled": False, "type": event.event_type}
- An invalid, missing or out-of-window (5 minutes) signature is a 401 before your handler runs.
- Secret rotation works: Stripe sends one
v1per active secret and any match is accepted. - An unknown event type does not take the route down —
eventisNoneandevent_typekeeps the string. A Stripe release does not become your incident. event_idis there to deduplicate: deliveries repeat by design.
A verified signature is not authorization
It proves the delivery came from Stripe. It does not prove the state
you are about to change is current — deliveries repeat and arrive out
of order. Before releasing goods on payment_intent.succeeded,
re-read the intent through the API.
So a test never re-derives the signature wrongly, the module exports the signer:
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)
The 265 event types, as an enum¶
StripeEvent is generated from the specification — from the
enabled_events parameter of POST /v1/webhook_endpoints, which is
where Stripe enumerates the types (the event object does not). A drift
test fails if the file is edited by hand.
from tempest_fastapi_sdk.integrations.payment.stripe import StripeEvent
print(StripeEvent.INVOICE_PAID.value) # invoice.paid
print(StripeEvent.has_value("customer.created")) # True
Idempotency: why every write carries a key¶
A timeout on POST /v1/payment_intents leaves you not knowing whether
money moved. A retry without a key creates a second payment; with one,
Stripe replays the original response for 24 hours. The client puts a
UUID4 on every write — swap in your own when you want the window to cover
your flow:
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())
Cursor pagination¶
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())
The cursor is the last item's id, not an offset, so the walk stays correct while objects are created underneath it.
Errors that say what happened¶
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_... (what Stripe support asks for)
asyncio.run(main())
Thin models, nothing lost¶
The models name the fields that carry decisions — status, amount,
currency, the ids linking objects — and set extra="allow". The rest
of the object stays reachable:
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) # typed
print((intent.model_extra or {}).get("next_action")) # preserved
asyncio.run(main())
An expandable field (customer, payment_intent, …) is a str: without
expand, Stripe sends the id. Ask for the object and read it from
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())
Why this client is hand-written rather than generated
The SDK generates integrations from OpenAPI — OpenPix is generated.
Stripe's specification does not survive the trip, and that was
measured on 2026-07-29.dahlia:
- generating the full surface yields a
schemas.pyof 3.3 MB / 81k lines, and importing it costs 5.8 s and 492 MB of RSS; - slicing by resource does not help:
/v1/pricesalone reaches 864 of the 1440 schemas, and the ten core resources together reach - There is no small subset.
So what comes from the specification still comes from it — API
version, base URL and the 265 events, via make stripe-fetch — and
the rest is code that fits in your head. The numbers, and how to
reproduce them, are in scripts/regen_stripe.py.
Configuration¶
from tempest_fastapi_sdk import BaseAppSettings
from tempest_fastapi_sdk.integrations.payment.stripe import (
StripeClient,
stripe_http_client,
)
class Settings(BaseAppSettings):
"""Service settings."""
STRIPE_API_KEY: str = ""
STRIPE_WEBHOOK_SECRET: str = ""
settings = Settings()
client = StripeClient(stripe_http_client(settings.STRIPE_API_KEY))
The webhook secret is per endpoint, not per account: a service with a test and a live endpoint has two.
Testing¶
Inject an httpx.MockTransport and inspect the request that would go
out:
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:
"""Record the request and answer with a fake customer."""
requests.append(request)
return httpx.Response(200, json={"id": "cus_1", "object": "customer"})
async def test_create_customer() -> None:
"""The write goes out form-encoded, in bracket notation."""
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¶
StripeClientover the SDK'sHTTPClient: retry, circuit breaker and the pinnedStripe-Versioncome from one place.- Writes are form-encoded in bracket notation (
form_encode, public and now used by the generator too) and carry anIdempotency-Keyby default. - Money respects zero-decimal and three-decimal currencies.
- Webhooks verify over
t.body, with a replay window, secret rotation, and an unknown event type that does not take the route down. - 265 events as an enum, generated from the specification with a drift test.
- Thin models with
extra="allow"— the client is hand-written because generating the whole specification costs 3.3 MB and 492 MB of RSS, measured.