Stripe API Integration: Checkout, Webhooks, and Safe Fulfillment

Quick Overview
Design a Stripe Checkout integration with verified webhooks, recoverable retries, subscription state, and a checked offline Python fulfillment example.
A Stripe integration coordinates your application, a payment API, a browser, and asynchronous notifications. A successful browser redirect is a useful user experience signal; your server still needs verified payment state and a repeatable fulfillment process.
This guide focuses on a one-time hosted Checkout purchase, then explains how subscriptions differ. The examples use synthetic identifiers and an offline database. They illustrate application design without creating accounts, making API calls, or charging a card.
Start with the public hosted Checkout integration prompt to practice explaining the entire path from Buy click to fulfilled order.
1. Choose the payment flow and record its object relationships
| Product requirement | Starting point | State your application must distinguish |
|---|---|---|
| A hosted purchase page | Checkout Session in payment mode | Session completion, payment status, and fulfillment |
| Recurring access | Checkout Session in subscription mode or a Billing integration | Subscription, invoice, payment, and entitlement state |
| Save a payment method for later | SetupIntent or a suitable setup flow | Successful setup and an actual later payment |
| Customer-managed billing changes | Customer Portal | The resulting subscription and invoice changes |
Stripe's current Payment Intents guide recommends Checkout Sessions with the Payment Element for most integrations. A lower-level PaymentIntent integration remains a separate design choice when its control is needed. UI customization alone does not require choosing that lower-level API.
A Checkout Session can create an associated PaymentIntent. Store that relationship instead of independently creating another payment object for the same purchase attempt. A SetupIntent configures future payment use without creating a charge. The Customer Portal helps customers manage billing, while your application handles the resulting state changes.
Sketch ownership and failure boundaries before writing handlers. The system architecture article gives broader reliability context.
2. Authorize the order and calculate the purchase on the server
The browser can select a product, but it must not decide the amount you charge or which account receives access. Authenticate the caller, authorize access to the order, and resolve the product and quantity against your server's catalog. Store an immutable purchase-attempt record with the expected currency, pricing policy, and Stripe object IDs.
For a hosted Session, the create parameters can look like this. They are shown as JSON for readability: API v1 takes form-encoded request bodies, and the SDKs accept the same structure as a dictionary. The Price identifier is a placeholder selected by the server, not a value copied directly from an untrusted request:
{
"mode": "payment",
"line_items": [
{"price": "price_from_server_catalog", "quantity": 1}
],
"client_reference_id": "order_demo",
"success_url": "https://example.test/orders/order_demo?session_id={CHECKOUT_SESSION_ID}",
"cancel_url": "https://example.test/orders/order_demo"
}
Use the Checkout Session creation reference for the parameters supported by your selected UI mode and API version. Persist the returned Session ID against the attempt. On a return-page request, authorize the viewer before showing order details or retrieving a Session on their behalf.
Represent amounts using the currency rules in Stripe's currencies reference. USD 29.99 is represented as 2999 minor units, but not every currency uses two decimal places. Taxes, discounts, adjustable quantities, and currency conversion also change how an expected total must be reconciled. Avoid binary floating-point arithmetic for money.
Keep secret and restricted keys on the server. Publishable keys serve client-side integrations; webhook signing secrets are separate from API keys and belong to their endpoint and environment. Stripe's key documentation explains these boundaries. Hosted collection can reduce direct card-data handling, while PCI compliance remains a shared responsibility.
3. Make an uncertain create request recoverable
Generate an opaque idempotency key for a logical create operation, persist it with the authorized attempt, and reuse it when retrying that same operation with the same parameters. A network timeout does not establish whether Stripe created the object. Generating a new key immediately can create a second attempt while the first already exists.
Stripe's API v1 idempotency contract saves the first result after endpoint execution begins, including some error results. Parameter-validation failures and concurrent conflicts have different behavior. Keys can be pruned after they are at least 24 hours old; a reused key after pruning can initiate a new request. This retention period is not a durable business deduplication strategy.
Preserve your own order-to-object mapping and reconcile uncertain outcomes. Use a new operation only after deciding that the business action is genuinely different. Keep personal data out of idempotency keys. The REST API preparation article covers the wider retry and HTTP-method distinctions.
4. Verify the webhook, then durably accept it
This small Python helper applies to API v1 snapshot event payloads. Pass the original request bytes, the Stripe-Signature header, and the endpoint's signing secret to the official SDK:
import stripe
def verified_event(raw_body: bytes, signature: str, endpoint_secret: str):
return stripe.Webhook.construct_event(
raw_body, signature, endpoint_secret
)
The helper was checked offline with synthetic signed payloads using stripe-python 15.2.1. It performs no Stripe API request. The webhook documentation explains raw-body verification, timestamp tolerance, retries, and event formats. Parsing JSON need not destroy the original bytes if your framework preserves them, but reconstructing JSON and passing different bytes to signature verification will fail. Thin notifications use a different SDK parsing and retrieval path.
After verification, validate the account and environment, route supported event types, and persist accepted work in a durable inbox or equivalent transactional queue. Return success after durable acceptance or completed safe processing. A background thread started after an early acknowledgement can disappear with the process.
An acknowledgement means you accepted delivery, not that the customer has received the product. Track retryable processing failures, dead-lettered work, and reconciliation separately. Deliberately ignored event types can be acknowledged; a business event you cannot interpret needs a recoverable quarantine or failure path rather than silent loss.
5. Check payment status before fulfilling Checkout
An immediate payment can be eligible for fulfillment when Checkout completes. Delayed payment methods can complete the Session while payment remains unpaid, then produce checkout.session.async_payment_succeeded or a failure notification later. A Session's completion and its payment status are different fields.
Follow Stripe's Checkout fulfillment guide: retrieve the relevant Session through your server, check its payment status, reconcile purchased items, and record fulfillment safely. A zero-value purchase can have no_payment_required; decide that business policy explicitly rather than treating it as a paid charge.
The landing page may call the same server-side fulfillment function to improve responsiveness, provided authorization and server-side checks still apply. Webhooks remain necessary when the browser never returns. Make both paths converge on the same guarded operation.
The following original example models one fixed-total order, one account, and one environment. Its input Session is a plain dictionary representing a trusted server retrieval after verification and event routing. In stripe-python 15 and later, SDK objects no longer support dictionary methods such as .get(), so convert a retrieved Session with session.to_dict() before passing it in. It excludes taxes, discounts, refunds, subscriptions, and external side effects. Save it as fulfillment_demo.py and run it with Python 3.
import sqlite3
def record_paid(db, event_id, order_id, session):
if not isinstance(event_id, str) or not event_id:
raise ValueError("event id required")
if session.get("mode") != "payment":
raise ValueError("one-time payments only")
if session.get("payment_status") != "paid":
return "pending"
with db:
expected = db.execute(
"SELECT session_id, amount, currency FROM orders WHERE id = ?",
(order_id,),
).fetchone()
observed = (session["id"], session["amount_total"], session["currency"])
if expected is None or expected != observed:
raise ValueError("order and payment do not match")
inserted = db.execute(
"INSERT INTO processed(event_id) VALUES (?) "
"ON CONFLICT(event_id) DO NOTHING", (event_id,),
).rowcount
if not inserted:
return "duplicate"
db.execute("UPDATE orders SET paid = 1 WHERE id = ?", (order_id,))
db.execute(
"INSERT INTO outbox(order_id, action) VALUES (?, 'fulfill') "
"ON CONFLICT(order_id, action) DO NOTHING", (order_id,),
)
return "accepted"
def demo_database(path=":memory:"):
db = sqlite3.connect(path)
db.executescript("""
CREATE TABLE orders (
id TEXT PRIMARY KEY, session_id TEXT NOT NULL,
amount INTEGER NOT NULL, currency TEXT NOT NULL,
paid INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE processed (event_id TEXT PRIMARY KEY);
CREATE TABLE outbox (
order_id TEXT NOT NULL, action TEXT NOT NULL,
UNIQUE(order_id, action)
);
INSERT INTO orders(id, session_id, amount, currency)
VALUES ('order_demo', 'cs_demo', 2999, 'usd');
""")
return db
if __name__ == "__main__":
db = demo_database()
session = {"id": "cs_demo", "mode": "payment",
"payment_status": "paid", "amount_total": 2999,
"currency": "usd"}
print(record_paid(db, "evt_first", "order_demo", session))
print(record_paid(db, "evt_first", "order_demo", session))
print(record_paid(db, "evt_second", "order_demo", session))
print(db.execute("SELECT COUNT(*) FROM outbox").fetchone()[0])
db.close()
The output is accepted, duplicate, accepted, then 1. Repeated delivery of one event is deduplicated by its receipt. Two distinct events describing the same purchase still produce one fulfillment intent because the outbox has a business uniqueness constraint. The receipt, paid flag, and outbox write commit together; a failed transaction must not leave a receipt that suppresses a legitimate retry.
The default in-memory database is a disposable teaching fixture. Production needs durable storage, concurrency control, and a worker that can recover safely after a crash. An outbox records intent; it does not make an external shipment or email happen exactly once. Its consumer also needs idempotency and recovery rules. Refunds and disputes require separate state transitions rather than reusing this one-way fulfillment gate.
6. Model recurring access separately from a single paid order
Subscriptions add invoices, trial rules, renewal failures, plan changes, and cancellation timing. Store the customer, subscription, authorized product or price, relevant period information, and local entitlement decisions. A checkout event does not prove every later renewal succeeded.
Stripe's subscription lifecycle guide explains states such as incomplete, trialing, active, past_due, and canceled. Importantly, active does not prove that every invoice is paid, and collection methods can differ. Build entitlement policy around the product sold, billing configuration, and terms your application actually offers.
Use subscription webhook guidance to handle invoice and lifecycle changes. A failed attempt can lead to a retry or configured recovery state; cancellation scheduled for period end differs from cancellation that has taken effect. Trials are their own access policy. Select a subscription integration design that matches your pricing and billing model before reducing these states to a boolean.
Events can arrive late or out of order. Fetch relevant current objects when necessary and serialize or conditionally apply local updates so that an older worker cannot overwrite a newer reconciliation result. Sorting solely by event timestamps is insufficient. Practice the distinction between a fulfillment flag and a financial system of record with the public payments ledger design prompt.
7. Version and test the boundaries that can drift
Record the SDK version, API version for outgoing calls, event format, and configured webhook endpoint version. They are related settings, not one universal switch. Stripe's versioning reference describes SDK version pinning and endpoint-version behavior. Test upgrades against the payload shapes you actually support.
Do not automatically acknowledge and discard an unfamiliar business-event schema. Preserve enough authenticated context for investigation and replay, alert on unsupported versions, and decide acknowledgement behavior through an explicit recovery design. Silent success can permanently hide a missed access update.
| Test or failure | Evidence to inspect |
|---|---|
| Redirect never arrives | The order is still processed through verified server-side state |
| Delayed payment succeeds later | No early fulfillment; one intent after eligible payment |
| Duplicate and distinct repeat events | Receipt deduplication and business uniqueness both hold |
| Process crashes during acceptance | A transaction or durable queue preserves recoverable work |
| Session, amount, or currency mismatches | No fulfillment intent is committed |
| A stale event or worker arrives | It cannot overwrite a newer local entitlement decision |
| A version changes | Contract tests detect unsupported fields and recovery is observable |
Offline tests can check local rules and signature handling. A separate Stripe sandbox exercise is needed to validate real API requests, authentication flows, delayed payment methods, and endpoint configuration. Do not manufacture failed live invoices or additional real charges as a debugging shortcut.
Use a recurring reconciliation process to compare local expectations with relevant Stripe objects. Repair a missing local entitlement or fulfillment record through the normal guarded workflow; do not create another charge to fix a data mismatch. Log correlation identifiers without logging secret keys, client secrets, or unnecessary customer data.
For a broader interview exercise, use the public payments API design prompt. The inspection-in-testing article offers a way to review requirements before implementation.
Comments (0)