15 KiB
Billing & Webhooks Refactor: Move to AppView
Motivation
The current billing model is per-hold: each hold operator runs their own Stripe integration, manages their own tiers, and users pay each hold separately. This creates problems:
- Multi-hold confusion: A user on 3 holds could have 3 separate Stripe subscriptions with no unified view
- Orphaned subscriptions: Users can end up paying for holds they no longer use after switching their active hold
- Complex UI: The settings page needs to surface billing per-hold, with separate "Manage Billing" links for each
- Captain-only billing: Only hold captains can set up Stripe. Self-hosted hold operators who want to charge users would need their own Stripe account per hold
The proposed model is per-appview: a single Stripe integration on the appview, one subscription per user, covering all holds that appview manages.
Current Architecture
User ──Settings UI──→ AppView ──XRPC──→ Hold ──Stripe API──→ Stripe
↑
Stripe Webhooks
What lives where today
| Component | Location | Notes |
|---|---|---|
| Stripe customer management | Hold (pkg/hold/billing/) |
Build tag: -tags billing |
| Stripe checkout/portal | Hold XRPC endpoints | Authenticated via service token |
| Stripe webhook receiver | Hold (stripeWebhook endpoint) |
Updates crew tier on subscription change |
| Tier definitions + pricing | Hold config (quotas.yaml, billing section) |
Captain configures |
| Quota enforcement | Hold (pkg/hold/quota/) |
Checks tier limit on push |
| Storage quota calculation | Hold PDS layer records | Deduped per-user |
| Subscription UI | AppView handlers | Proxies all calls to hold |
| Webhook management (scan) | Hold PDS + SQLite | URL/secret in SQLite, metadata in PDS record |
| Webhook dispatch | Hold (scan_broadcaster.go) |
Sends on scan completion |
| Sailor webhook record | User's PDS | Links to hold's private webhook record |
Proposed Architecture
User ──Settings UI──→ AppView ──Stripe API──→ Stripe
│ ↑
│ Stripe Webhooks
│
├──XRPC──→ Hold A (quota enforcement, scan results)
├──XRPC──→ Hold B
└──XRPC──→ Hold C
AppView signs attestation
│
└──→ Hold stores in PDS (trust anchor)
What moves to AppView
| Component | From | To | Notes |
|---|---|---|---|
| Stripe customer management | Hold | AppView | One customer per user, not per hold |
| Stripe checkout/portal | Hold | AppView | Single subscription covers all holds |
| Stripe webhook receiver | Hold | AppView | AppView updates tier across all holds |
| Tier definitions + pricing | Hold config | AppView config | AppView defines billing tiers |
| Scan webhooks (storage + dispatch) | Hold | AppView | AppView has user context, scan data comes via Jetstream/XRPC |
What stays on the hold
| Component | Notes |
|---|---|
| Quota enforcement | Hold still checks tier limit on push |
| Storage quota calculation | Layer records stay in hold PDS |
| Tier definitions (quota only) | Hold defines storage limits per tier, no pricing |
| Scan execution + results | Scanner still talks to hold, results stored in hold PDS |
| Crew tier field | Source of truth for enforcement, updated by appview |
Billing Model
One subscription, all holds
A user pays the appview once. Their subscription tier applies across every hold the appview manages.
AppView billing tiers: [Free] [Tier 1] [Tier 2]
│ │ │
▼ ▼ ▼
Hold A tiers (3GB/10GB/50GB): deckhand bosun quartermaster
Hold B tiers (5GB/20GB/∞): deckhand bosun quartermaster
Tier pairing
The appview defines N billing slots. Each hold defines its own tier list with storage quotas. The appview maps its billing slots to each hold's lowest N tiers by rank order.
- AppView doesn't need to know tier names — just "slot 1, slot 2, slot 3"
- Each hold independently decides what storage limit each tier gets
- The settings UI shows the range: "5-10 GB depending on region" or "minimum 5 GB"
Hold captains who want to charge
If a hold captain wants to charge their own users (not through the shared appview), they spin up their own appview instance with their own Stripe account. The billing code stays the same — it just runs on their appview instead of the shared one.
AppView-Hold Trust Model
Problem
The appview needs to tell holds "user X is tier Y." The hold needs to trust that instruction. If domains change, the hold needs to verify the appview's identity.
Attestation handshake
- Hold config already has
server.appview_url(preferred appview) - AppView config gains a
managed_holdslist (DIDs of holds it manages) - On first connection, the appview signs an attestation with its private key:
{ "$type": "io.atcr.appview.attestation", "appviewDid": "did:web:atcr.io", "holdDid": "did:web:hold01.atcr.io", "issuedAt": "2026-02-23T...", "signature": "<signed with appview's P-256 key>" } - The hold stores this attestation in its embedded PDS
- On subsequent requests, the hold can challenge the appview: present the attestation, appview proves it holds the matching private key
- If the appview's domain changes, the attestation (tied to DID, not URL) remains valid
Trust verification flow
AppView boots → checks managed_holds list
→ for each hold:
→ calls hold's describeServer endpoint to verify DID
→ signs attestation { appviewDid, holdDid, issuedAt }
→ sends to hold via XRPC
→ hold stores in PDS as io.atcr.hold.appview record
Hold receives tier update from appview:
→ checks: does this request come from my preferred appview?
→ verifies: signature on stored attestation matches appview's current key
→ if valid: updates crew tier
→ if invalid: rejects, logs warning
Key material
- AppView: P-256 key (already exists at
/var/lib/atcr/oauth/client.key, used for OAuth) - Hold: K-256 key (PDS signing key)
- Attestation is signed by appview's P-256 key, verifiable by anyone with the appview's public key (available via DID document)
Webhooks: Move to AppView
Why move
Scan webhooks currently live on the hold, but:
- The webhook payload needs user handles, repository names, tags — all resolved by the appview
- The hold only has DIDs and digests
- The appview already processes scan records via Jetstream (backfill + live)
- Webhook secrets shouldn't need to live on every hold the user pushes to
New flow
Scanner completes scan
→ Hold stores scan record in PDS
→ Jetstream delivers scan record to AppView
→ AppView resolves user handle, repo name, tags
→ AppView dispatches webhooks with full context
What changes
| Aspect | Current (hold) | Proposed (appview) |
|---|---|---|
| Webhook storage | Hold SQLite + PDS record | AppView DB + user's PDS record |
| Webhook secrets | Hold SQLite (webhook_secrets table) |
AppView DB |
| Dispatch trigger | scan_broadcaster.go on scan completion |
Jetstream processor on io.atcr.hold.scan record |
| Payload enrichment | Hold fetches handle from appview metadata | AppView has full context natively |
| Discord/Slack formatting | Hold (webhooks.go) |
AppView (same code, moved) |
| Tier-based limits | Hold quota manager | AppView billing tier |
| XRPC endpoints | Hold (listWebhooks, addWebhook, etc.) |
AppView API endpoints (already exist as proxies) |
Webhook record changes
The io.atcr.sailor.webhook record in the user's PDS stays. It already stores holdDid and triggers. The privateCid field (linking to hold's internal record) becomes unnecessary since appview owns the full webhook now.
The io.atcr.hold.webhook record in the hold's PDS is no longer needed. Webhooks are appview-scoped, not hold-scoped.
Migration path
- AppView gains webhook storage in its own DB (new table)
- AppView gains webhook dispatch in its Jetstream processor
- Hold's webhook endpoints deprecated (return 410 Gone after transition period)
- Existing hold webhook records migrated via one-time script reading from hold XRPC + user PDS
Config Changes
AppView config additions
server:
# Existing
default_hold_did: "did:web:hold01.atcr.io"
# New
managed_holds:
- "did:web:hold01.atcr.io"
- "did:plc:abc123..."
# New section
billing:
enabled: true
currency: usd
success_url: "{base_url}/settings#storage"
cancel_url: "{base_url}/settings#storage"
tiers:
- name: "Free"
# No stripe_price = free tier
- name: "Standard"
stripe_price_monthly: price_xxx
stripe_price_yearly: price_yyy
- name: "Pro"
stripe_price_monthly: price_xxx
stripe_price_yearly: price_yyy
AppView environment additions
STRIPE_SECRET_KEY=sk_live_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
Hold config changes
# Removed
billing:
# entire section removed from hold config
# Stays (quota enforcement only)
quota:
tiers:
- name: deckhand
quota: 5GB
- name: bosun
quota: 50GB
- name: quartermaster
quota: 100GB
defaults:
new_crew_tier: deckhand
The hold no longer has Stripe config. It just defines storage limits per tier and enforces them.
AppView DB Schema Additions
-- Webhook configurations (moved from hold SQLite)
CREATE TABLE webhooks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_did TEXT NOT NULL,
url TEXT NOT NULL,
secret_hash TEXT, -- bcrypt hash of HMAC secret
triggers INTEGER NOT NULL DEFAULT 1, -- bitmask: first=1, all=2, changed=4
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_did, url)
);
-- Billing: track which holds have been attested
CREATE TABLE hold_attestations (
hold_did TEXT PRIMARY KEY,
attestation_cid TEXT NOT NULL, -- CID of attestation record in hold's PDS
issued_at DATETIME NOT NULL,
verified_at DATETIME
);
Stripe customer/subscription data continues to live in Stripe (queried via API, cached in memory). No local subscription table needed — same pattern as current hold billing, just on appview.
Implementation Phases
Phase 1: Trust foundation
- Add
managed_holdsto appview config - Implement attestation signing (appview) and storage (hold)
- Add attestation verification to hold's tier-update endpoint
- New XRPC endpoint on hold:
io.atcr.hold.updateCrewTier(appview-authenticated)
Phase 2: Billing migration
- Move Stripe integration from hold to appview (reuse
pkg/hold/billing/code) - AppView billing uses
-tags billingbuild tag (same pattern) - Implement tier pairing: appview billing slots mapped to hold tier lists
- New appview endpoints: checkout, portal, stripe webhook receiver
- Settings UI: single subscription section (not per-hold)
Phase 3: Webhook migration ✅
- Add webhook + scans tables to appview DB
- Implement webhook dispatch in appview's Jetstream processor
- Move Discord/Slack formatting code to
pkg/appview/webhooks/ - Deprecate hold webhook XRPC endpoints (X-Deprecated header)
- Webhooks now user-scoped (global across all holds) in appview DB
- Scan records cached from Jetstream for change detection
Phase 4: Cleanup ✅
- Removed hold webhook XRPC endpoints, dispatch code, and
webhooks.go - Removed
io.atcr.hold.webhookandio.atcr.sailor.webhookrecord types + lexicons - Removed
webhook_secretsSQLite schema from scan_broadcaster - Removed
MaxWebhooks/WebhookAllTriggersfrom hold quota config - Removed sailor webhook from OAuth scopes
Settings UI Impact
The storage tab simplifies significantly:
┌──────────────────────────────────────────────────────┐
│ Active Hold: [▼ hold01.atcr.io (Crew) ] │
└──────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ Subscription: Standard ($5/mo) [Manage Billing] │
│ Storage: 3-5 GB depending on region │
└──────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ ★ hold01.atcr.io [Active] [Crew] [Online] │
│ Tier: bosun · 281.5 MB / 5.0 GB (5%) │
│ ▸ Webhooks (2 configured) │
└──────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ Other Holds Role Status Storage │
│ hold02.atcr.io Crew ● 230 MB / 3 GB │
│ hold03.atcr.io Owner ● No data │
└──────────────────────────────────────────────────────┘
Key changes:
- One subscription section at the top (not per-hold)
- Webhooks section under active hold card (managed by appview now)
- No "Paid" badge per hold — subscription is global
- Storage range shown on subscription card ("3-5 GB depending on region")
- Per-hold quota still shown (each hold enforces its own limit for the user's tier)
Open Questions
-
Tier list endpoint: Holds need a new XRPC endpoint that returns their tier list with quotas (without pricing). The appview calls this to build the "3-5 GB depending on region" display. Something like
io.atcr.hold.listTiers. -
Existing Stripe customers: Holds with existing Stripe subscriptions need a migration plan. Options: honor existing subscriptions until they expire, or bulk-migrate customers to appview's Stripe account.
-
Webhook delivery guarantees: Moving dispatch to appview adds latency (scan record → Jetstream → appview → webhook). For time-sensitive notifications, consider the hold sending a lightweight "scan completed" signal directly to appview via XRPC rather than waiting for Jetstream propagation.
-
Self-hosted appviews: The attestation model assumes one appview per set of holds. If multiple appviews try to manage the same hold, the hold should only trust the most recent attestation (or maintain a list).