Skip to content

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.

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"
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.

Create one Proxy client and one Stripe client per application process. Keep both secret keys server-only.

import os
import stripe
from 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 params

Do 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 JsonResponse
from django.shortcuts import get_object_or_404
from 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.

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 partial
from fastapi.responses import JSONResponse
from 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.

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 factTested
Stripe request API2023-10-16
stripe-python8.8.0

Restricted-key reads

Setup Intents: Read, Subscriptions: Read, Invoices: Read, Payment Intents: Read. Leave Customer and Payment Methods access set to None.

OperationRead
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 profileRequired eventsOptional compatibility events
2022-11-15 through 2024-10-27customer.subscription.created
customer.subscription.deleted
customer.subscription.trial_will_end
customer.subscription.updated
invoice.finalization_failed
invoice.marked_uncollectible
invoice.paid
invoice.payment_action_required
invoice.payment_failed
invoice.voided
payment_intent.canceled
payment_intent.payment_failed
payment_intent.processing
payment_intent.succeeded
setup_intent.canceled
setup_intent.created
setup_intent.requires_action
setup_intent.setup_failed
setup_intent.succeeded
customer.subscription.paused
customer.subscription.resumed
2024-10-28.acacia and newercustomer.subscription.created
customer.subscription.deleted
customer.subscription.trial_will_end
customer.subscription.updated
invoice.finalization_failed
invoice.marked_uncollectible
invoice.paid
invoice.payment_action_required
invoice.payment_failed
invoice.voided
payment_intent.canceled
payment_intent.payment_failed
payment_intent.processing
payment_intent.succeeded
setup_intent.canceled
setup_intent.created
setup_intent.requires_action
setup_intent.setup_failed
setup_intent.succeeded
customer.subscription.paused
customer.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

  1. Open one durable handoff in two authorized payer views and confirm both recover the same SetupIntent and cart.
  2. Confirm the SetupIntent, call the Subscription endpoint concurrently, and verify Stripe contains only one physical Subscription.
  3. 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.
  4. Confirm a foreign or cross-Customer SetupIntent fails before Subscription creation.
  5. Retry and reorder signed lifecycle wakeups; confirm your current-state entitlement transition remains idempotent and SetupIntent success never grants access.