Stripe Payments Integration

One-Time Payments with Checkout

35 min

Create a Checkout Session

import os
import stripe
from flask import redirect, url_for
from flask_login import login_required, current_user

stripe.api_key = os.environ["STRIPE_SECRET_KEY"]

@shop_bp.post("/buy/<int:project_id>")
@login_required
def buy(project_id):
    project = db.get_or_404(Project, project_id)
    session = stripe.checkout.Session.create(
        mode="payment",
        customer_email=current_user.email,
        line_items=[{
            "price_data": {
                "currency": project.currency,
                "unit_amount": to_minor_units(project.price, project.currency),
                "product_data": {"name": project.title},
            },
            "quantity": 1,
        }],
        metadata={"user_id": str(current_user.id), "project_id": str(project.id)},
        success_url=url_for("shop.success", _external=True) + "?session_id={CHECKOUT_SESSION_ID}",
        cancel_url=url_for("projects.detail", slug=project.slug, _external=True),
    )
    return redirect(session.url, code=303)
  • Trigger it from a <form method="post"> with a CSRF token, not a link.
  • Never take the price from the browser; look it up on the server.
  • Put identifiers in metadata so the webhook knows what was bought and by whom.

Amounts in minor units

Stripe amounts are integers in the currency's smallest unit: cents for USD, but JPY has no minor unit.

from decimal import Decimal, ROUND_HALF_UP
ZERO_DECIMAL = {"jpy", "krw", "vnd", "clp"}

def to_minor_units(amount, currency):
    value = Decimal(str(amount))
    if currency.lower() in ZERO_DECIMAL:
        return int(value.quantize(Decimal("1"), rounding=ROUND_HALF_UP))
    return int((value * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP))

int(19.99 * 100) gives 1998 because of floating-point error; rounding with Decimal gives the correct 1999. Check Stripe's documentation for the full list of zero-decimal currencies.

Success page

The success page can show "Thanks! Your payment is processing." and look up the session to display details, but must not grant access by itself: anyone can visit the success URL. Access is granted by the webhook (next module).

Saved prices

For a fixed catalogue, create Products and Prices in the dashboard or API and pass "price": "price_123" instead of price_data. It keeps reporting tidy and lets you change prices without code changes.

Exercise

Add a "Buy source code" button that creates a Checkout Session for a project, with the price looked up server-side, correct minor units, metadata and success/cancel URLs. Pay with a test card.

Check yourself

  • Why must the price come from the server?
  • What does int(19.99 * 100) return, and why?
  • Why is the success page not proof of payment?

Your progress is saved in this browser only.

Submit for review (10 points)