Examples
Real-world usage patterns for the Payment Service.
One-time Payment
Sell a single product for a fixed price. The user clicks "Buy", gets redirected to Stripe Checkout, pays, and comes back to a success page.
Stripe dashboard setup
- Products → Add product, name it ("Premium eBook", "Pro License", etc.).
- Under pricing, choose One off (not recurring).
- Set the amount (e.g.
$29.00 USD). - Save and copy the Price ID. It looks like
price_1ABC....
Backend: trigger checkout
import httpx
async def start_checkout(price_id: str) -> str:
"""Create a checkout session and return the redirect URL."""
async with httpx.AsyncClient() as client:
response = await client.post(
"http://localhost:8000/api/v1/payment/checkout",
json={
"price_id": price_id,
"mode": "payment",
# success_url and cancel_url are optional here; when omitted
# they fall back to PAYMENT_SUCCESS_URL / PAYMENT_CANCEL_URL
# settings, which default to the bundled pages at
# /payment/success and /payment/cancel. Override by passing
# explicit values below.
},
)
response.raise_for_status()
return response.json()["checkout_url"]
Frontend: redirect the user
# In a Flet button handler
async def on_buy_clicked(e: ft.ControlEvent) -> None:
checkout_url = await start_checkout("price_1ABC...")
e.page.launch_url(checkout_url)
Confirming payment
By default, after a successful charge Stripe redirects the user to the bundled page at /payment/success?session_id=cs_test_.... It's styled to match the Aegis palette and works with no extra wiring.
To replace that page with your own UX (e.g., to look up the transaction and show a confirmation number), set PAYMENT_SUCCESS_URL in .env to your own route and add a handler like:
@app.get("/thanks")
async def payment_success(session_id: str, db: AsyncSession = Depends(get_async_db)):
service = PaymentService(db)
# Transaction is populated by the webhook handler;
# this page may show a loading state until it arrives.
txn = await service.get_transaction_by_session_id(session_id)
if txn and txn.status == "succeeded":
return {"status": "confirmed", "amount": txn.amount}
return {"status": "processing"}
The canonical source of truth is the webhook, not the redirect. Users can close the tab before redirecting, but Stripe will always send the checkout.session.completed event, so business-critical logic (granting access, sending receipts) should live in the webhook handler, not the success page.
Subscription
Recurring billing for monthly or yearly plans.
Stripe dashboard setup
- Products → Add product.
- Choose Recurring pricing.
- Set the billing period (monthly, yearly) and amount.
- Copy the Price ID.
Trigger checkout with subscription mode
The only difference from one-time payments is "mode": "subscription":
await client.post(
"http://localhost:8000/api/v1/payment/checkout",
json={
"price_id": "price_recurring_ABC...",
"mode": "subscription",
},
)
Mode must match price type
Sending mode: "payment" with a recurring price (or vice versa) returns a 400 with Stripe's error: "You specified 'payment' mode but passed a recurring price." Always match the mode to the Price configuration.
Listing active subscriptions
Cancelling at period end
async def cancel_user_subscription(db: AsyncSession, subscription_id: int) -> None:
await client.post(
f"http://localhost:8000/api/v1/payment/subscriptions/{subscription_id}/cancel"
)
The subscription keeps billing until current_period_end, then Stripe stops. The local record sets cancel_at_period_end=true so your UI can display "Cancels on April 30".
Refunding a Charge
Full refund:
curl -X POST http://localhost:8000/api/v1/payment/refund/42 \
-H "Content-Type: application/json" \
-d '{"reason": "requested_by_customer"}'
Partial refund (amount in cents):
curl -X POST http://localhost:8000/api/v1/payment/refund/42 \
-H "Content-Type: application/json" \
-d '{"amount": 500, "reason": "requested_by_customer"}'
The refund creates a separate payment_transaction record of type="refund" linked to the original charge. The original transaction's status becomes refunded or partially_refunded.
Listening to Webhook Events Locally
Open two terminals:
Copy the whsec_... printed by stripe listen into .env as STRIPE_WEBHOOK_SECRET, then restart the webserver. Now real Stripe events (and synthetic ones via stripe trigger <event>) flow to your local handler.
Triggering specific events
In a third terminal, fire any handled event directly. This is useful for testing code paths without running a full checkout:
stripe trigger checkout.session.completed
stripe trigger payment_intent.succeeded
stripe trigger invoice.payment_failed
stripe trigger customer.subscription.deleted
stripe trigger charge.refunded
Handling Fraud and Disputes
Even with Stripe Radar, some fraud slips through as chargebacks. The payment service tracks the full lifecycle in a payment_dispute table so you can react in code.
Events the service handles automatically
| Event | What happens |
|---|---|
radar.early_fraud_warning.created |
New payment_dispute row with status warning_issued. Chargeback is usually 1-30 days away; this is your window to refund proactively and avoid the dispute fee. |
charge.dispute.created |
New payment_dispute row with status needs_response. evidence_due_by is set to your response deadline. |
charge.dispute.updated |
Existing row updated (status, evidence deadline, reason). |
charge.dispute.closed |
Row updated to won, lost, charge_refunded, or warning_closed. |
Reacting to an early fraud warning
The standard play is to refund proactively before the chargeback lands. Fighting a chargeback costs ~$15 per dispute plus reputation damage; proactive refunds cost you only the original amount.
from app.services.payment.constants import DisputeStatus
from app.services.payment.payment_service import PaymentService
class FraudAwarePaymentService(PaymentService):
async def _handle_early_fraud_warning(self, event) -> None:
# Let the base class record the warning in the DB
await super()._handle_early_fraud_warning(event)
# Then revoke the user's access and issue a refund
charge_id = event.data.get("charge", "")
txn = await self._find_transaction_by_charge_id(charge_id)
if not txn:
return
await self._revoke_access_for_charge(txn)
await self.refund_transaction(
transaction_id=txn.id,
reason="Early fraud warning received",
)
Querying open disputes
List disputes that need attention (from the CLI, an admin page, or a cron job):
Or in code:
service = PaymentService(db)
open_disputes = await service.get_disputes(status="open")
for d in open_disputes:
if d.evidence_due_by and d.evidence_due_by < datetime.now():
logger.error(
"Dispute %s evidence deadline passed without response!",
d.provider_dispute_id,
)
Submitting evidence for a chargeback
The service records disputes but doesn't submit evidence for you; that's a manual step via the Stripe dashboard for most projects. If you want to automate it, use the Stripe API directly:
import stripe
stripe.Dispute.modify(
"dp_test_abc",
evidence={
"customer_email_address": "customer@example.com",
"customer_name": "Jane Doe",
"receipt": "https://yourapp.com/receipts/42",
"service_date": "2026-04-01",
"uncategorized_text": "Customer received the product. See attached delivery confirmation.",
},
submit=True,
)
This moves the dispute from needs_response to under_review on Stripe's side; the charge.dispute.updated webhook will then arrive and sync your local row.
What not to build
- Don't write your own fraud scoring on top of Radar. Stripe has a billion-transaction model; you won't beat them. Your job is to react to their signal, not re-derive it.
- Don't auto-submit evidence without human review for low-volume projects. False positives (you submitting evidence on a legitimate dispute) get you flagged by card networks.
Extending Webhook Handling
The default webhook handler persists common events to the database. To add custom application logic (send a welcome email on first payment, grant a feature flag on subscription start, etc.), override the specific event handler rather than the top-level handle_webhook — each _handle_* method receives the full WebhookEvent with the original Stripe payload, while handle_webhook returns only a small acknowledgement dict.
# In your own application code, subclass PaymentService and replace
# the _get_provider path so this subclass is used.
class MyPaymentService(PaymentService):
async def _handle_checkout_completed(self, event) -> None:
# Persist the transaction first via the base class
await super()._handle_checkout_completed(event)
# Then your side effects, using fields from event.data directly
session_id = event.data.get("id", "")
customer_id = event.data.get("customer")
if customer_id:
await self._grant_premium_access(customer_id)
await self._send_receipt_email(session_id)
If you need to fan out on event type in one place instead of overriding each handler, subclass _process_event:
async def _process_event(self, event) -> None:
await super()._process_event(event)
if event.event_type == "customer.subscription.created":
await self._announce_new_subscriber(event.data)
Both patterns keep the base-class persistence and dispatch intact.
Adding a New Payment Provider
The service is designed for multi-provider support. To add Paddle, PayPal, or any other:
- Create
app/services/payment/providers/paddle.py(or similar) implementingBasePaymentProvider. - Register it in
PaymentService._get_provider()based on a newPAYMENT_PROVIDERenv var. - Add its env vars to
app/core/config.pyand.env.example.
The BasePaymentProvider interface is intentionally small. Five methods cover checkout, transactions, refunds, customers, and webhooks, so a new provider typically lands in a few hundred lines.