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:
- Payment Links. Stripe-hosted URL. You generate once, share the URL. Client pays. Webhook confirms. Zero code on your side.
- Checkout Sessions. Stripe-hosted checkout page. You generate per-transaction with specific line items. Client pays. Webhook confirms. Minimal code.
- 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:
- ACH debits. Card-only. ACH has different failure modes and we haven't needed them.
- Tax calculation. Stripe Tax is powerful but most of our apps are sold in a single jurisdiction. Manual tax configuration is fine.
- Invoicing platforms (Stripe Invoicing Hub). We generate our own PDF invoices; Stripe Invoicing is redundant.
- Radar (fraud). Most of our apps have low transaction volume; Radar is overkill.
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:
- Developer experience. Stripe's docs and SDKs are still the cleanest in the industry. Time-to-first-charge in a new app is measured in hours.
- Webhook reliability. Stripe webhooks are well-engineered and well-documented. Other processors' webhooks vary in quality.
- Test mode parity. Stripe test mode behaves like live mode for almost every interaction. Other processors fall down here.
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.