Stripe direct subscriptions with Python
Use proxy-checkout-stripe when your Python server already owns a Stripe Payment Element flow and creates Subscriptions after a succeeded SetupIntent. The public alpha implements SUB-SI (subscription.direct_setup_intent) only. It deliberately does not implement saved-PaymentMethod, Checkout, or direct PaymentIntent paths.
The package keeps every Stripe read and write in your process through an injected stripe.StripeClient. Proxy reserves one order-scoped acquisition, supplies contract metadata and deterministic idempotency keys, attaches the SetupIntent and Subscription, and returns typed outcomes. SetupIntent success and the helper response are never grant evidence.
Install
Section titled “Install”Version 0.2.0a1 supports Python 3.10 through 3.14. Its stripe extra installs the tested Stripe Python 8.8.0 version exactly.
python -m pip install "proxy-checkout-stripe[stripe]==0.2.0a1"uv add "proxy-checkout-stripe[stripe]==0.2.0a1"| Surface | Supported public-alpha version |
|---|---|
| Python | 3.10 through 3.14 |
| Stripe Python | 8.8.0 |
| Stripe request API | 2023-10-16 |
| Initial Invoice action | legacy_payment_intent |
| Synchronous frameworks | Direct request or service use |
| Async frameworks | Synchronous route or explicit complete-call worker-thread offload |
Do not rely on Stripe Python’s default API version. Pin 2023-10-16 on the client and pass the same compatibility declaration to both SDK calls. Do not set stripe_version on an individual SDK call; the SDK rejects it so every request uses the response shape tested for the configured StripeClient. The package also validates the newer confirmation_secret response shape, but newer Stripe Python versions are not supported until they have their own compatibility tests.
Configure the server clients
Section titled “Configure the server clients”Create one Proxy client and one Stripe client per application process. Keep both secret keys server-only.
import os
import stripefrom proxy_checkout_stripe import ProxyClient, StripeCompatibility
proxy = ProxyClient(os.environ["PROXY_SECRET_KEY"], timeout=15.0)stripe_client = stripe.StripeClient( os.environ["STRIPE_SECRET_KEY"], stripe_version="2023-10-16", max_network_retries=2, http_client=stripe.http_client.RequestsClient(timeout=20),)stripe_compatibility = StripeCompatibility( api_version="2023-10-16", server_sdk_version="8.8.0",)ProxyClient.timeout applies to each Proxy HTTP request, not the whole operation. Stripe’s request timeout and retries apply separately. Budget for several sequential Proxy and Stripe calls; deterministic idempotency prevents a second physical provider object when a create response is ambiguous.
Build Stripe parameters from the Proxy cart
Section titled “Build Stripe parameters from the Proxy cart”Both entry points call the builders with the immutable Proxy cart. Use the same pure builders for SetupIntent preparation and Subscription creation so concurrent callers and retries join the same reservation fingerprint.
def build_setup_intent_params(_context): return { "payment_method_types": ["card"], "usage": "off_session", }
def build_subscription_params(context): params: dict[str, object] = { "items": [ { "price": item["stripe_price_id"], "quantity": item["quantity"], } for item in context.cart["items"] ], } trial_days = context.cart.get("trial_days") if trial_days is not None: params["trial_period_days"] = trial_days return paramsDo not add acquisition metadata or Stripe idempotency keys yourself. The SDK owns Proxy’s reserved metadata keys and merges permitted merchant metadata deterministically.
Django example: prepare the Payment Element SetupIntent
Section titled “Django example: prepare the Payment Element SetupIntent”Authorize the route’s order before any Proxy reservation or Stripe mutation. Persist only the acquisition and SetupIntent identifiers against that trusted order; return the client secret only to the authorized Payment Element page.
from django.http import JsonResponsefrom django.shortcuts import get_object_or_404from proxy_checkout_stripe import ( ActionRequired, AlreadyPaid, ReadySetupIntent, Unavailable, open_setup_intent,)
def render_terminal_outcome(result): if isinstance(result, AlreadyPaid): return JsonResponse({"state": "already_paid"}) if isinstance(result, ActionRequired): return JsonResponse({"state": "merchant_action_required"}, status=409) if isinstance(result, Unavailable): return JsonResponse({"state": "unavailable"}, status=409) raise AssertionError("Unhandled Proxy outcome")
def prepare_payment_method(request, proxy_session_id): order = get_object_or_404( CheckoutOrder, proxy_session_id=proxy_session_id, user=request.user, ) result = open_setup_intent( proxy=proxy, stripe=stripe_client, proxy_session_id=order.proxy_session_id, customer_id=order.stripe_customer_id, build_setup_intent_params=build_setup_intent_params, build_subscription_params=build_subscription_params, initial_invoice_action_shape="legacy_payment_intent", compatibility=stripe_compatibility, ) if not isinstance(result, ReadySetupIntent): return render_terminal_outcome(result)
order.proxy_acquisition_attempt_id = result.acquisition_attempt_id order.stripe_setup_intent_id = result.setup_intent_id order.save(update_fields=[ "proxy_acquisition_attempt_id", "stripe_setup_intent_id", ]) return JsonResponse({ "setup_intent_client_secret": result.client_secret, })Confirm that client secret with your existing Stripe Payment Element using stripe.confirmSetup. After Stripe reports success, call the Subscription endpoint. The server retrieves the SetupIntent again and verifies its status, Proxy metadata, Customer, and attached PaymentMethod before creating anything.
Django example: create or recover the Subscription
Section titled “Django example: create or recover the Subscription”from proxy_checkout_stripe import ReadySubscription, open_subscription
def create_subscription(request, proxy_session_id): order = get_object_or_404( CheckoutOrder, proxy_session_id=proxy_session_id, user=request.user, ) result = open_subscription( proxy=proxy, stripe=stripe_client, proxy_session_id=order.proxy_session_id, acquisition_attempt_id=order.proxy_acquisition_attempt_id, setup_intent_id=order.stripe_setup_intent_id, build_setup_intent_params=build_setup_intent_params, build_subscription_params=build_subscription_params, initial_invoice_action_shape="legacy_payment_intent", compatibility=stripe_compatibility, ) if not isinstance(result, ReadySubscription): return render_terminal_outcome(result)
action = result.initial_invoice_action if action.kind == "client_secret": return JsonResponse({ "invoice_client_secret": action.client_secret, })
# A trial or other exact-zero initial Invoice normally has no client action. # Render pending state; do not fulfill from this response. return JsonResponse({"state": "pending_proxy_lifecycle"}, status=202)If initial_invoice_action.kind is client_secret, return only that secret to the authorized Stripe.js page and complete the initial Invoice authentication there. A trial or other exact-zero initial Invoice normally returns none, but neither branch grants access by itself.
Concurrent callers may each reach Stripe’s create method. They use the same Proxy-supplied idempotency key and identical request fingerprint, so they converge on one physical Subscription. Preserve the order-associated identifiers and retry the same operation; do not create a new Proxy session, choose a new Customer, or mutate the builders to recover an ambiguous response.
If Proxy returns a provider-options fingerprint conflict, do not mutate the builders to work around it. Retry the same operation or investigate why the order’s immutable Stripe options changed.
FastAPI and other ASGI servers
Section titled “FastAPI and other ASGI servers”Stripe Python 8.8.0 and this SDK perform blocking I/O. Prefer a normal FastAPI def route, which FastAPI runs in its worker pool. If the route must be async def, offload the complete SDK call rather than blocking the event loop or offloading individual Proxy/Stripe calls:
from functools import partialfrom fastapi.responses import JSONResponsefrom proxy_checkout_stripe import ( ActionRequired, AlreadyPaid, ReadySubscription, Unavailable,)from starlette.concurrency import run_in_threadpool
def render_fastapi_subscription_outcome(result): if isinstance(result, ReadySubscription): action = result.initial_invoice_action if action.kind == "client_secret": return {"invoice_client_secret": action.client_secret} return JSONResponse({"state": "pending_proxy_lifecycle"}, status_code=202) if isinstance(result, AlreadyPaid): return {"state": "already_paid"} if isinstance(result, ActionRequired): return JSONResponse({"state": "merchant_action_required"}, status_code=409) if isinstance(result, Unavailable): return JSONResponse({"state": "unavailable"}, status_code=409) raise AssertionError("Unhandled Proxy outcome")
@app.post("/proxy-sessions/{proxy_session_id}/subscription")async def create_subscription_async(proxy_session_id: str): order = await load_authorized_order_async(proxy_session_id) result = await run_in_threadpool( partial( open_subscription, proxy=proxy, stripe=stripe_client, proxy_session_id=order.proxy_session_id, acquisition_attempt_id=order.proxy_acquisition_attempt_id, setup_intent_id=order.stripe_setup_intent_id, build_setup_intent_params=build_setup_intent_params, build_subscription_params=build_subscription_params, initial_invoice_action_shape="legacy_payment_intent", compatibility=stripe_compatibility, ) ) return render_fastapi_subscription_outcome(result)Thread offload occupies one worker across several provider calls, and cancellation of the awaiting coroutine does not cancel an already-running blocking request. Apply host-level capacity limits and deadlines. Native aopen_setup_intent and aopen_subscription entry points are reserved for a future tested Stripe Python version with native async service methods; they are not present in this alpha.
Fulfillment and diagnostics
Section titled “Fulfillment and diagnostics”Wait for Proxy’s signed lifecycle notification, read current state, and run one merchant-owned idempotent entitlement transition. SetupIntent success only prepares a payment method. A trialing Subscription, exact-zero Invoice, positive initial Invoice response, or browser return does not independently prove Proxy’s current state or authorize fulfillment.
ProxyApiError and ProxyStripeAcquisitionError expose bounded request/acquisition/provider identifiers through diagnostic_context(). Log that bounded context when useful. Never log exception causes, API keys, raw Stripe objects, Customer or PaymentMethod details, or either client secret.
Replaying an already-attached Subscription does not create a replacement if its PaymentMethod was later detached. The SDK still verifies the Subscription’s recorded billing identity. If Stripe’s latest_invoice has advanced to a renewal, the helper reports no initial-Invoice action; handle renewal state through Proxy’s signed lifecycle flow.
This package rejects metered billing, transformed Price quantities, send_invoice, automatic tax, Connect routing, schedules, provider-finalized pricing, arbitrary proration or pending-Invoice behavior, and payer-selected promotion codes. For the complete direct-subscription lifecycle, renewal, cancellation, fee, and entitlement rules, return to Stripe direct subscriptions.
Capability subscription.direct_setup_intent uses capability manifest v2 and parser contract v2. Its current availability isgeneral_availability.
| Compatibility fact | Tested |
|---|---|
| Stripe request API | 2023-10-16 |
| stripe-python | 8.8.0 |
Restricted-key reads
Setup Intents: Read, Subscriptions: Read, Invoices: Read, Payment Intents: Read. Leave Customer and Payment Methods access set to None.
| Operation | Read |
|---|---|
setup_intents.retrieve | /v1/setup_intents/:id |
subscriptions.retrieve | /v1/subscriptions/:id |
subscription_items.list_by_subscription | /v1/subscription_items?subscription=:id&limit=100 |
invoices.retrieve | /v1/invoices/:id |
invoices.list_by_subscription | /v1/invoices?subscription=:id&limit=100 |
invoices.line_items | /v1/invoices/:id/lines |
payment_intents.retrieve | /v1/payment_intents/:id |
setup_intents.list_probe | /v1/setup_intents?limit=1 |
subscriptions.list_probe | /v1/subscriptions?limit=1 |
invoices.list_probe | /v1/invoices?limit=1 |
payment_intents.list_probe | /v1/payment_intents?limit=1 |
Webhook Endpoint events
| Endpoint API profile | Required events | Optional compatibility events |
|---|---|---|
2022-11-15 through 2024-10-27 | customer.subscription.createdcustomer.subscription.deletedcustomer.subscription.trial_will_endcustomer.subscription.updatedinvoice.finalization_failedinvoice.marked_uncollectibleinvoice.paidinvoice.payment_action_requiredinvoice.payment_failedinvoice.voidedpayment_intent.canceledpayment_intent.payment_failedpayment_intent.processingpayment_intent.succeededsetup_intent.canceledsetup_intent.createdsetup_intent.requires_actionsetup_intent.setup_failedsetup_intent.succeeded | customer.subscription.pausedcustomer.subscription.resumed |
2024-10-28.acacia and newer | customer.subscription.createdcustomer.subscription.deletedcustomer.subscription.trial_will_endcustomer.subscription.updatedinvoice.finalization_failedinvoice.marked_uncollectibleinvoice.paidinvoice.payment_action_requiredinvoice.payment_failedinvoice.voidedpayment_intent.canceledpayment_intent.payment_failedpayment_intent.processingpayment_intent.succeededsetup_intent.canceledsetup_intent.createdsetup_intent.requires_actionsetup_intent.setup_failedsetup_intent.succeeded | customer.subscription.pausedcustomer.subscription.resumed |
Proxy wakeups
provider_acquisition.cancelled provider_acquisition.prepared provider_acquisition.processing provider_acquisition.reconciliation_required provider_acquisition.requires_action proxy_session.expired proxy_session.merchant_action_required proxy_session.paid proxy_session.provisionable subscription.activated subscription.cancel_scheduled subscription.cancel_schedule_removed subscription.cancelled subscription.changed subscription.configuration_action_required subscription.invoice_finalization_failed subscription.invoice_uncollectible subscription.invoice_voided subscription.paused subscription.payment_action_required subscription.payment_failed subscription.resumed subscription.renewed subscription.trial_ending
Visible unsupported configurations
automatic_tax connect direct_trial_without_payment_method managed_payments metered_billing multiple_invoice_payments out_of_band_payment pending_invoice_items payment_records promotion_code_choice send_invoice subscription_schedules
Validate
Section titled “Validate”- Open one durable handoff in two authorized payer views and confirm both recover the same SetupIntent and cart.
- Confirm the SetupIntent, call the Subscription endpoint concurrently, and verify Stripe contains only one physical Subscription.
- Exercise your configured trial or zero-Invoice case, a positive initial Invoice requiring authentication, terminal Proxy outcomes, and a request retry after an ambiguous provider response.
- Confirm a foreign or cross-Customer SetupIntent fails before Subscription creation.
- Retry and reorder signed lifecycle wakeups; confirm your current-state entitlement transition remains idempotent and SetupIntent success never grants access.