Files
at-container-registry/docs/BILLING.md
Evan Jarrett 5d3b6c2047 begin billing
2026-02-03 20:54:35 -06:00

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 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:

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

  1. Start hold with billing enabled
  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 → hold updates crew tier
  7. 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_KEY to 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
  • Set STRIPE_WEBHOOK_SECRET from Dashboard webhook settings
  • Update quotas.yaml with live price IDs
  • Build hold 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 hold logs for webhook processing errors
  • Verify price ID in quotas.yaml matches Stripe
  • Ensure billing.enabled: true in config

"Billing not enabled" error

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