Every Dangercorn app ships with Stripe integration. Most self-host users never turn it on. The ones that do — agencies running an app for clients, consultants building a practice, small operations who want a payment link per invoice — flip one env var and have working billing in under 5 minutes.

Making this work without forcing every self-host user to set up Stripe was more fiddly than I expected. Here's the pattern we landed on.

The Core Principle

Stripe features are opt-in. If STRIPE_SECRET_KEY is not set in the environment, the app runs without any billing surface. Payment-related UI is hidden. Payment-related endpoints return 404. There's no "configure Stripe" nag screen.

If STRIPE_SECRET_KEY is set, the full billing surface appears. Invoice generation. Payment links. Deposit collection. Stripe webhooks. All of it.

STRIPE_ENABLED = bool(os.environ.get("STRIPE_SECRET_KEY"))

if STRIPE_ENABLED:
    import stripe
    stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
    app.register_blueprint(billing_bp, url_prefix="/billing")

@app.context_processor
def inject_feature_flags():
    return dict(STRIPE_ENABLED=STRIPE_ENABLED, ...)

In Jinja templates, the billing UI is gated:

{% if STRIPE_ENABLED %}
  <a href="{{ url_for('billing.create_invoice') }}">Generate invoice</a>
{% endif %}

The Config Detection Problem

A subtle bug we hit early: Stripe test keys and live keys have the same shape (sk_test_... vs sk_live_...). An app configured with a test key still "works" for payments, but payments don't actually clear. Some self-hosters started with test keys for development, forgot to switch, and collected invoices that never moved real money.

We added explicit mode detection:

def stripe_mode():
    if not STRIPE_ENABLED:
        return "disabled"
    if stripe.api_key.startswith("sk_test_"):
        return "test"
    if stripe.api_key.startswith("sk_live_"):
        return "live"
    return "unknown"

# Shown in admin dashboard
if stripe_mode() == "test":
    flash("Stripe is in TEST mode. Live transactions will not clear.", "warning")

Every app's admin page now prominently displays "TEST MODE" in yellow when running against a test key. A few self-hosters have thanked me for this after the fact.

Webhook Verification

Stripe webhooks need to be signature-verified. Every Dangercorn app has a single webhook endpoint (/billing/webhook) that verifies the signature and dispatches to the right handler based on event type.

@billing_bp.route("/webhook", methods=["POST"])
def webhook():
    payload = request.data
    sig_header = request.headers.get("Stripe-Signature")
    try:
        event = stripe.Webhook.construct_event(
            payload, sig_header, WEBHOOK_SECRET
        )
    except stripe.error.SignatureVerificationError:
        abort(400)
    _dispatch_event(event)
    return "", 200

The dispatcher pattern:

HANDLERS = {
    "checkout.session.completed": _on_checkout_completed,
    "invoice.paid": _on_invoice_paid,
    "invoice.payment_failed": _on_payment_failed,
    "customer.subscription.deleted": _on_sub_canceled,
}

def _dispatch_event(event):
    handler = HANDLERS.get(event["type"])
    if handler:
        handler(event["data"]["object"])

Most apps only handle 3-5 event types. The rest are silently ignored with a log entry.

Payment Links vs Checkout

There are three ways to collect payments via Stripe:

  1. Payment Links. Stripe-hosted URL. You generate once, share the URL. Client pays. Webhook confirms. Zero code on your side.
  2. Checkout Sessions. Stripe-hosted checkout page. You generate per-transaction with specific line items. Client pays. Webhook confirms. Minimal code.
  3. Payment Intents. Fully custom UI. You build your own form, Stripe.js handles card collection, your server confirms. Maximum control.

Dangercorn apps default to Checkout Sessions for one-off invoices and Payment Links for subscriptions / recurring. Payment Intents only when absolutely needed (fully branded checkout experiences). Checkout Sessions give you most of the power of Payment Intents with 10% of the code and 0% of the PCI scope.

Subscription Tiers

Apps that have subscription tiers (Hosted Pro, Multi-property, etc.) use Stripe Prices + Customers + Subscriptions. The local app stores a mirror:

CREATE TABLE subscription (
  id INTEGER PRIMARY KEY,
  stripe_subscription_id TEXT UNIQUE NOT NULL,
  stripe_customer_id TEXT NOT NULL,
  plan_name TEXT NOT NULL,       -- 'hosted_pro', 'multi_property'
  status TEXT NOT NULL,          -- 'active', 'past_due', 'canceled'
  current_period_end TIMESTAMP,
  created_at TIMESTAMP NOT NULL
);

Webhooks keep the local table in sync. The local plan_name drives feature-flag checks inside the app. Stripe is the source of truth for billing state; the local table is the source of truth for what the app shows the user.

The Refund Flow

Refunds are initiated from the admin dashboard. The app calls Stripe's refund API, which fires back a charge.refunded webhook, which updates the local record. Refund reasons are logged with a change_reason column on the invoice table so there's an audit trail later.

We've had to do maybe 30 refunds across the whole portfolio in 18 months. All of them for legitimate reasons (customer misunderstanding, duplicate charge, subscription that should have been canceled). No Stripe disputes — which I'll take as a small signal that the refund-when-asked policy is the right default.

Test Mode Defaults

Apps that ship with Stripe enabled default to test-mode data in the admin: sample invoice, sample subscription, sample refund. Self-hosters can play with the UI before they decide to configure a real key. Once the real key is set, the test data is cleared on first startup.

What's Not Here

We don't do:

Each of these could be added if a vertical needs it. They're not defaults.

Optional features should be genuinely optional. A self-host user running cheesemaking at home should never see a 'configure Stripe' prompt. An agency running it for clients should have billing working in one env var.

Related

The template walkthrough. Why every app has a self-host tier. Single-user auth patterns. For apps that actively use this: inkbook (deposits), cateringpro (deposits + balance), boxops (subscriptions).

The Stripe-Specific Choice

Why Stripe and not Square, PayPal, or Lemon Squeezy? For us:

We've considered adding Square as an option for verticals where the customer already has a Square account (restaurants, retail). The wiring is straightforward; we just haven't had the demand. If a vertical's customers ask for Square integration consistently, we add it. So far, Stripe has been sufficient.