eleven / ocho Public
π€ Community, tiers & paid memberships β design
Status: design, pre-implementation. This documents the plan for three stacked features β open "community" groups, membership tiers, and paid subscriptions (Stripe Connect, optionally the app stores) β and pins the decisions so each layer can land as its own small PR. Nothing here is built yet; when a layer ships, move its section toward present tense and note the PR.
The one-line test that shaped the whole design: each layer must ship real value even if the layers above it never land. Open groups are useful with no tiers; tiers are useful with no billing; billing is a bolt-on assigner of tiers, never a rewrite.
Why this fits the E2E rule
Billing state is roster metadata β the same class as the admin token and branding: who is a member, what they've paid, until when. None of it touches content keys, and the server already knows membership. Message content, screen names, and file bytes stay opaque ciphertext throughout.
What's genuinely new is a disclosed exception (alongside link previews and GIF search, CLAUDE.md Β§ the one rule): Stripe β and therefore the admin β sees a paying member's real payment identity, and the server learns uid β tier β paid-until. Tier names and prices are plaintext by necessity: they must render at checkout and on join screens where no key exists yet. That's the branding carve-out (admin-set, never user content), applied to commerce config.
Roles are not tiers
The roster already has users.role (member/admin), and its migration carries a deliberate invariant (db.go): "Authorisation only β a role grants no key material." Keep that.
| | Roles (exist today) | Tiers (new) | |---|---|---| | Govern | what you can do (admin API, announcements) | what you can open (which groups decrypt) | | Key material | none, by invariant | exactly one tier key each | | Cardinality | one per member | a member holds a set | | Examples | admin, later moderator | silver, gold, founding-member |
Don't map one onto the other. admin is not a tier you subscribe to; silver is not a permission set. A subscription is one assigner of a tier among several (manual, invite-type, Stripe, app store) β which is what keeps billing out of the crypto layer entirely.
Layer 1 β Community: open groups
An open group is a thread any member of the space can discover and join without being invited. People outside the space still see nothing β "open" means open to members.
Mechanism. Today every thread key is wrapped per-member (thread_keys, one row per member, granted by a client wrapping to the joiner's pubkey). An open group instead wraps its AES key under the instance key β which every member already holds (instance_keys, the all-members key used for profile photos). One wrapped blob per open thread, decryptable by the whole roster by construction:
- Discovery. A token-gated directory endpoint lists open groups. Names/ descriptions are encrypted under the instance key, so every member's client can render the directory; the server still can't read them.
- Join. Self-serve and instant β nobody else needs to be online, because the joiner already holds the instance key. Joining just registers membership (fan-out, push, unread); the key was already theirs to unwrap.
- Leave/kick. Membership rows only. (A kicked member still holds the instance key, so open groups are honest about their trust level: open to every member, always. Content that needs excludability belongs in an ordinary invited thread.)
Zero new cryptography β it's the instance-key pattern applied to a thread key, plus a visibility flag and a Discover surface. Web, iOS and Mac land together (iOS first, per convention); the wrap/unwrap path is client crypto in web/static/crypto.js + LChatCore.
Layer 2 β Tiers
A tier generalises the instance key: a named key, wrapped to each entitled member instead of to everyone.
- Tables:
tiers(id, name, ordering, later a price hook) anduser_tiers(member β tier, plus how they got it and until when β see grace, below). - Tier keys reuse the instance-key machinery verbatim: one wrapped blob per entitled member, granted by an online key-holder's client when the server prods it (the
instance_key_grantedpattern inmain.go). Same "waiting for your key" UX when no holder is online; the bot sidecar can act as an always-on key steward where the admin has one (same scoped, disclosed trust choice as adding a bot to a thread βdocs/bots.md). - Tier-gated groups. An open group may wrap its thread key under a tier key instead of the instance key. "Create a group for all silver members" is exactly that β and auto-membership is inherent, not a sync job: the moment someone holds the silver key they can open every silver-gated group. Joining is just registering for fan-out and push.
- Assignment is manual first. The Admin app grows a Tiers section: create tiers, assign/revoke members by hand. This ships as a complete, billing-free feature (curated member groups) and proves every mechanism billing later drives.
Revocation & the grace period
Entitlements carry paid_until (or nothing, for hand-assigned tiers, which never lapse). Lapse β the standard 30-day grace β revocation:
- The server deletes the member's tier-key row and their membership rows in tier-gated groups, and stops serving those threads' ciphertext to them.
- This is service-level revocation, the same strength as removing a member today (
removeUserdeletes tokens + key rows; it does not rekey). Be honest in member-facing copy: a lapsed member keeps what their device already downloaded β true of every E2EE system. - Later, optional: lazy epoch rotation β a client mints
silver-key-v2, rewraps to current holders, and new groups/wraps use v2. An upgrade, not a prerequisite; nothing in layers 1β3 depends on it.
Layer 3 β Paid subscriptions
One entitlement, pluggable sources
The instance stores one source-agnostic record per paying member: uid β tier, status, paid_until, grace_deadline, source. Three writers, identical machinery below the line:
| source | how entitlement changes arrive | |---|---| | stripe | Stripe Connect webhooks, relayed by the broker | | apple | App Store Server Notifications v2 (signed JWS), verified against the App Store Server API; purchases carry an appAccountToken UUID we mint per uid, so attribution is exact | | google | Play Real-Time Developer Notifications (Pub/Sub), verified with the Play Developer API β later, same shape |
A member who subscribes on iPhone is entitled on web and Mac too: entitlement lives on the instance, not the device.
Stripe Connect (the primary path)
- Onboarding rides the connect broker (
connect.elevenmessenger.com, thedocs/integrations.mdpattern): the Stripe platform secret lives only on the broker; instances hold just their connected-account id (acct_β¦, not a secret) and a bearer token β exactly the push-relay shape (docs/push-relay.md), for the same reason. - Standard connected accounts, direct charges. The admin is merchant of record: money lands in their Stripe account, tax/VAT/refunds are theirs (Stripe Tax on their account). That's the right liability position for the platform.
- Tiers become Products/Prices on the connected account. Subscribe = Stripe Checkout (hosted β card data never touches us) for a subscription with
application_fee_percent: 10. - Commission: 10%, market rate (Substack 10%, Patreon 8β12%). Stripe's ~2.9% + 30Β’ sits underneath and the admin pays it; show both numbers honestly in the admin UI.
- Webhooks land at one platform-level Connect endpoint on the broker and are relayed to the instance as minimal entitlement changes.
Our own space plans: Stripe-only, no IAP (decided 2026-07-13)
The section above is about creator tiers β members paying a space's admin. For Eleven's own space plans (Family/Community/Business, sold by us) the decision is simpler and firmer: Stripe Checkout from the home page is the only purchase path; we do not offer IAP for space plans. Why:
- Wrong identity spine. The buyer is a home account (passkey) upgrading a space whose benefits reach every member on every platform. IAP binds the entitlement to an Apple ID β reconciling that back into the cloud ledger means receipt validation, App Store Server Notifications, and refund sync: a second source of billing truth beside the one Stripe webhook we have.
- The fee scales with price. Apple's 15% is β¬0.75 on a β¬5/y Family space but ~β¬150 on a β¬999/y Business space; Stripe is ~β¬0.33 and ~β¬15.
- The link-out era removed the main upside. Since the May 2025 US ruling, the iOS app can link to web checkout in the US storefront with no fee and no entitlement; the EU allows it under fee-and-reporting terms we deliberately don't take (below); other storefronts stay quiet.
Steering posture (what renders where β this is the part that keeps us clean under App Store rule 3.1.1 and the EU DMA fee terms):
- Per-client UI can gate by platform/storefront, and does: the space menu's Upgrade entry shows on the web, and on iOS only where a storefront is in the allowlist (US to start β the clean no-fee, no-reporting regime; the EU stays off it so we owe Apple neither commission nor monthly transaction reporting).
- Chat content cannot gate β a message renders on every device the member owns, so a payment link posted in chat reaches storefronts where steering is still restricted. Therefore Eleven and the other first-party bots never post checkout/payment links in chat, and never proactively upsell. A bot we ship is our content β "a bot said it" is still the app steering, and E2EE hiding the message from Apple is not a compliance posture (our prompts and Eleven's knowledge file are published). When a member asks how to upgrade, Eleven describes the path β the home page β without a link.
- Proactive upsell belongs to channels Apple has no claim on: the home page, the web shell, email.
App-store billing (optional, beside Stripe β never instead)
IAP products belong to our bundle ID, so this path is inherently central and optional per admin. Structural differences to be clear-eyed about:
- Apple is merchant of record. Apple collects, handles VAT/refunds/ chargebacks, takes 15β30% (15% under the Small Business Program and on year-2+ subscriptions), and remits to Eleven Messenger ~30β45 days later. We then owe the admin a payout.
- Payout rail = the same Stripe connected account. IAP proceeds go out as Stripe
Transfers to the admin'sacct_β¦, minus commission. One onboarding, one dashboard, two inflows. (Patreon's model β and Patreon is the App Review precedent that creator subscriptions via IAP are an accepted category.) - Price ladder, generic products. Admins set arbitrary prices; Apple wants pre-registered products. Mint a lattice of generic subscriptions ("Membership β $4.99/mo", "$9.99/mo", β¦) once via the App Store Connect API and snap each tier to the nearest rung. Generic names are also the privacy answer: IAP products are per-app and visible in receipts, so "Membership $9.99" leaks nothing about which space or tier.
- The math is worse and we say so. Stripe path: admin nets ~87%. IAP path: ~60β75% after Apple + commission. Offer a "+ app-store fee" auto-markup toggle (default on) so in-app prices compensate, and show the admin both nets.
- Link-out softens it. Under the US Epic injunction and the EU DMA, the iOS app can link to web checkout with no Apple commission in those regions. IAP then becomes what it should be β the frictionless option for members who prefer it, not a toll gate.
The broker grows into the billing hub: Stripe platform keys + App Store Server API key (+ Play service account, later) live there, all webhooks and notifications land there, and instances receive only entitlement changes over their bearer-token channel.
Self-hosters
BYO Stripe keys, 0% commission β the self-host escape hatch is explicit and embraced, not fought. The broker path is what hosted/cloud spaces get as zero-config convenience, and it's where the commission applies. IAP is only meaningful through our app-store listings, so it inherently routes through the hub; a self-hosted instance simply doesn't offer it.
What each party learns
| party | learns | never learns | |---|---|---| | the server | uid β tier β paid-until; tier names/prices; which uids are in which open groups | message content, screen names, file bytes β unchanged | | the admin | who subscribes, real payment identity (their Stripe dashboard) | anything beyond threads they're in β unchanged | | Stripe | the member's payment identity, the admin's account, amounts | which space beyond the admin's account metadata; nothing about content | | Apple/Google | that a user of the app bought "Membership $X" (generic product) | which space, which tier name, anything about content |
Build order
> Status (2026-07): 1β5 SHIPPED. Open groups, default groups, tiers > ("access levels"), level-gated groups, cohort invites, and the visibility > policy are on main and in production. Stripe Connect member subscriptions > (5) are built (subscriptions.go): the entitlement engine on user_tiers, > the PayProvider seam + stripeConnect (10% application fee, hand-rolled > like billing.go), member subscribe/change/cancel/portal + the Membership > UI, admin price + account linking, the webhook, and the 30-day graceβrevoke > sweep. Dormant until LCHAT_STRIPE_CONNECT_KEY + _WEBHOOK_SECRET are > set and a space links a connected account. The member Membership surface > ships on all three clients β web (showMembership in shell.js) and native > (MembershipView in LChatCore, wired into iOS Settings + the Mac Settings > window). Account linking is OAuth (decided 2026-07-15 β no pasted > acct_β¦ ids): the admin's "Connect Stripe account" button rides the connect > broker's consent trip with a billing.-marked state; the broker exchanges > the code itself, keeps nothing, and forwards only the stripe_user_id, > which the admin's own browser session links via the tierAdmin-gated > POST /api/admin/billing/account. No token ever reaches the browser, the > instance, or rests on the broker β direct charges need only the platform > key + the account id. Deferred: moving the Stripe platform secret onto the > broker (the hub is instance-env for now β a provider swap), Stripe-hosted > Connect onboarding for admins without a Stripe account, native admin > level management (create/price/link β web-only for now; the native app > tracks no admin role), and (6) app-store IAP.
- This doc β pin the decisions.
- Open groups β
visibilityflag, instance-key-wrapped thread key, directory endpoint + Discover surface, self-join. iOS + Mac + web together. Complete feature, no money. - Tiers β
tiers/user_tiers, tier keys on the instance-key grant pattern, Admin app management UI, tier-gated groups. Complete feature, no money. - Grace/lapse β
paid_until+ 30-day grace + revoke, generic over how the tier was assigned. Possibly folded into 3. - Billing hub: Stripe Connect β broker onboarding, checkout, webhooks, 10% application fee.
- App-store source β Apple first, Google later.
Open questions
- Does the 10% apply flat on IAP revenue (on top of Apple's cut) or drop to soften the stack?
- Tier-gated groups only at first β when (if ever) can a whole space be subscription-gated? Start with groups; space-level paywalls compose with free members existing and can come later behind a flag.
- Is the sidecar-as-key-steward default-on for subscription tiers? It makes grants instant instead of waiting for a human key-holder's client, at the cost of one more disclosed key holder.
- Payout ledger UX for the IAP path ("Apple revenue clears in ~40 days") needs a surface eventually β where?