7.1 KiB
Hold Service Billing Integration
Optional Stripe billing integration for hold services. Allows hold operators to charge for storage tiers via subscriptions.
Overview
- Compile-time optional: Build with
-tags billingto enable Stripe support - Hold owns billing: Each hold operator has their own Stripe account
- AppView aggregates UI: Fetches subscription info from holds, displays in settings
- Customer-DID mapping: DIDs stored in Stripe customer metadata (no extra database)
Architecture
User → AppView Settings UI → Hold XRPC endpoints → Stripe
↓
Stripe webhook → Hold → Update crew tier
Building with Billing Support
# Without billing (default)
go build ./cmd/hold
# With billing
go build -tags billing ./cmd/hold
# Docker with billing
docker build --build-arg BILLING_ENABLED=true -f Dockerfile.hold .
Configuration
Environment Variables
# Required for billing
STRIPE_SECRET_KEY=sk_live_xxx # or sk_test_xxx for testing
STRIPE_WEBHOOK_SECRET=whsec_xxx # from Stripe Dashboard or CLI
# Optional
STRIPE_PUBLISHABLE_KEY=pk_live_xxx # for client-side (not currently used)
quotas.yaml
tiers:
swabbie:
quota: 2GB
description: "Starter storage"
# No stripe_price = free tier
deckhand:
quota: 5GB
description: "Standard storage"
stripe_price_yearly: price_xxx # Price ID from Stripe
bosun:
quota: 10GB
description: "Mid-level storage"
stripe_price_monthly: price_xxx
stripe_price_yearly: price_xxx
defaults:
new_crew_tier: swabbie
plankowner_crew_tier: deckhand # Early adopters get this free
billing:
enabled: true
currency: usd
success_url: "{hold_url}/billing/success"
cancel_url: "{hold_url}/billing/cancel"
Stripe Price IDs
Use Price IDs (price_xxx), not Product IDs (prod_xxx).
To find Price IDs:
- Stripe Dashboard → Products → Select product
- Look at Pricing section
- Copy the Price ID
Or via API:
curl https://api.stripe.com/v1/prices?product=prod_xxx \
-u sk_test_xxx:
HTTP Endpoints
All billing routes live on the appview and only register when built with -tags billing.
| Route | Auth | Description |
|---|---|---|
POST /api/stripe/webhook |
Stripe signature | Handle subscription lifecycle events from Stripe |
GET /settings/subscription/checkout |
OAuth session | Redirect to a Stripe Checkout session for the selected tier |
GET /settings/subscription/portal |
OAuth session | Redirect to the Stripe billing portal for the current customer |
The settings page's "Subscription" panel is rendered server-side via the HTMX /settings/billing tab, which calls BillingManager.GetSubscriptionInfo(userDID) internally — there is no standalone JSON endpoint for it.
Local Development
Stripe CLI Setup
The Stripe CLI forwards webhooks to localhost:
# Install
brew install stripe/stripe-cli/stripe
# Or: https://stripe.com/docs/stripe-cli
# Login
stripe login
# Forward webhooks to local appview
stripe listen --forward-to localhost:5000/api/stripe/webhook
The CLI outputs a webhook signing secret:
Ready! Your webhook signing secret is whsec_xxxxxxxxxxxxx
Use that as STRIPE_WEBHOOK_SECRET for local dev.
Running Locally
# Terminal 1: Run appview with billing
export STRIPE_SECRET_KEY=sk_test_xxx
export STRIPE_WEBHOOK_SECRET=whsec_xxx # from 'stripe listen'
go run -tags billing ./cmd/appview serve --config config-appview.yaml
# Terminal 2: Forward webhooks
stripe listen --forward-to localhost:5000/api/stripe/webhook
# Terminal 3: Trigger test events
stripe trigger checkout.session.completed
stripe trigger customer.subscription.created
stripe trigger customer.subscription.updated
stripe trigger customer.subscription.paused
stripe trigger customer.subscription.resumed
stripe trigger customer.subscription.deleted
Testing the Flow
- Start appview with billing enabled (
-tags billing) - Start Stripe CLI webhook forwarding
- Navigate to AppView settings page
- Click "Upgrade" on a tier
- Complete Stripe checkout (use test card
4242 4242 4242 4242) - Webhook fires → appview updates crew tier on the user's hold
- Refresh settings to see new tier
Webhook Events
The appview billing manager handles these Stripe events:
| Event | Action |
|---|---|
checkout.session.completed |
Create/update subscription, set tier |
customer.subscription.created |
Set crew tier from price ID |
customer.subscription.updated |
Update crew tier if price changed |
customer.subscription.paused |
Downgrade to free tier |
customer.subscription.resumed |
Restore tier from subscription price |
customer.subscription.deleted |
Downgrade to free tier |
invoice.payment_failed |
Log warning (tier unchanged until canceled) |
Plankowners (Grandfathering)
Early adopters can be marked as "plankowners" to get a paid tier for free:
{
"$type": "io.atcr.hold.crew",
"member": "did:plc:xxx",
"tier": "deckhand",
"plankowner": true,
"permissions": ["blob:read", "blob:write"],
"addedAt": "2025-01-01T00:00:00Z"
}
Plankowners:
- Get
plankowner_crew_tier(e.g., deckhand) without paying - Still see upgrade options in UI if they want to support
- Can upgrade to higher tiers normally
Customer-DID Mapping
DIDs are stored in Stripe customer metadata:
{
"metadata": {
"user_did": "did:plc:xxx",
"hold_did": "did:web:hold.example.com"
}
}
The hold uses an in-memory cache (10 min TTL) to reduce Stripe API calls. On webhook events, the cache is invalidated for the affected customer.
Production Checklist
- Create Stripe products and prices in live mode
- Set
STRIPE_SECRET_KEYto live key (sk_live_xxx) - Configure webhook endpoint in Stripe Dashboard:
- URL:
https://your-appview.com/api/stripe/webhook - Events:
checkout.session.completed,customer.subscription.created,customer.subscription.updated,customer.subscription.paused,customer.subscription.resumed,customer.subscription.deleted,invoice.payment_failed
- URL:
- Set
STRIPE_WEBHOOK_SECRETfrom Dashboard webhook settings - Update
quotas.yamlwith live price IDs - Build appview with
-tags billing - Test with a real payment (can refund immediately)
Troubleshooting
Webhook signature verification failed
- Ensure
STRIPE_WEBHOOK_SECRETmatches the webhook endpoint in Stripe Dashboard - For local dev, use the secret from
stripe listenoutput
Customer not found
- Customer is created on first checkout
- Check Stripe Dashboard → Customers for the DID in metadata
Tier not updating after payment
- Check appview logs for webhook processing errors
- Verify price ID in
quotas.yamlmatches Stripe - Ensure
billing.enabled: truein appview config - Confirm appview was built with
-tags billing(otherwise/api/stripe/webhookreturns 404)
"Billing not enabled" error
- Build with
-tags billing - Set
billing.enabled: trueinquotas.yaml - Ensure
STRIPE_SECRET_KEYis set