6.7 KiB
6.7 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:
XRPC Endpoints
| Endpoint | Auth | Description |
|---|---|---|
GET /xrpc/io.atcr.hold.getSubscriptionInfo |
Optional | Get tiers and user's current subscription |
POST /xrpc/io.atcr.hold.createCheckoutSession |
Required | Create Stripe checkout URL |
GET /xrpc/io.atcr.hold.getBillingPortalUrl |
Required | Get Stripe billing portal URL |
POST /xrpc/io.atcr.hold.stripeWebhook |
Stripe sig | Handle subscription events |
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 hold
stripe listen --forward-to localhost:8080/xrpc/io.atcr.hold.stripeWebhook
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 hold with billing
export STRIPE_SECRET_KEY=sk_test_xxx
export STRIPE_WEBHOOK_SECRET=whsec_xxx # from 'stripe listen'
export HOLD_PUBLIC_URL=http://localhost:8080
export STORAGE_DRIVER=filesystem
export HOLD_DATABASE_DIR=/tmp/hold-test
go run -tags billing ./cmd/hold
# Terminal 2: Forward webhooks
stripe listen --forward-to localhost:8080/xrpc/io.atcr.hold.stripeWebhook
# 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 hold with billing enabled
- 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 → hold updates crew tier
- Refresh settings to see new tier
Webhook Events
The hold 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-hold.com/xrpc/io.atcr.hold.stripeWebhook - 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 hold 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 hold logs for webhook processing errors
- Verify price ID in
quotas.yamlmatches Stripe - Ensure
billing.enabled: truein config
"Billing not enabled" error
- Build with
-tags billing - Set
billing.enabled: trueinquotas.yaml - Ensure
STRIPE_SECRET_KEYis set