Files
at-container-registry/docs/BILLING.md
T
2026-05-18 22:10:20 -05:00

7.4 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 billing to 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:

  1. Stripe Dashboard → Products → Select product
  2. Look at Pricing section
  3. 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

  1. Start appview with billing enabled (-tags billing)
  2. Start Stripe CLI webhook forwarding
  3. Navigate to AppView settings page
  4. Click "Upgrade" on a tier
  5. Complete Stripe checkout (use test card 4242 4242 4242 4242)
  6. Webhook fires → appview updates crew tier on the user's hold
  7. Refresh settings to see new tier

Webhook Events

The canonical list of subscribed events lives in pkg/billing/events.go as the SubscribedEvents slice. Use it when configuring a Stripe Dashboard webhook endpoint or auditing an existing one.

Event Action
checkout.session.completed No-op (subscription.created does the tier work)
customer.subscription.created Set crew tier from price ID
customer.subscription.updated Update tier; handles past_due (keep), unpaid / incomplete_expired (downgrade), incomplete (await)
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 only (Stripe Smart Retries handle retry + customer email)
charge.dispute.created Log only (Stripe emails the account owner by default)

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_KEY to 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
  • Set STRIPE_WEBHOOK_SECRET from Dashboard webhook settings
  • Update quotas.yaml with live price IDs
  • Build appview with -tags billing
  • Test with a real payment (can refund immediately)

Troubleshooting

Webhook signature verification failed

  • Ensure STRIPE_WEBHOOK_SECRET matches the webhook endpoint in Stripe Dashboard
  • For local dev, use the secret from stripe listen output

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.yaml matches Stripe
  • Ensure billing.enabled: true in appview config
  • Confirm appview was built with -tags billing (otherwise /api/stripe/webhook returns 404)

"Billing not enabled" error

  • Build with -tags billing
  • Set billing.enabled: true in quotas.yaml
  • Ensure STRIPE_SECRET_KEY is set