Stripe handles subscriptions and payments. Three tiers with monthly and yearly pricing. Plan gates control web UI access, MCP tool access, account limits, and storage quotas. MCP access requires Creator tier or above (Starter is web-only).
- Plan tiers
- Stripe subscription flow
- Webhook events
- Subscription status
- Plan gating
- MCP monthly quotas
- Upload limits
- Customer portal
- Usage tracking
- Subscription lifecycle
- Future: x402 (deferred)
- Source files referenced
| Tier | Monthly | Yearly (~40% off) | Connected accounts | Storage | Web UI | MCP access |
|---|---|---|---|---|---|---|
| Starter | $9 | $64 | 5 | 5 GB | Yes | No |
| Creator (popular) | $18 | $129 | 15 | 15 GB | Yes | Yes (quota-limited) |
| Pro | $27 | $194 | 999 (unlimited) | 45 GB | Yes | Yes (unlimited) |
Starter users get full web UI access but zero MCP tool access. This is the hybrid pricing model: web for everyone, MCP for Creator and above. All 18 MCP tools require Creator minimum via ACCESS_PLAN_GATE.
Stripe price IDs are environment-specific (dev vs prod). The code uses NODE_ENV to select the correct set. priceIdToTier() in plans.ts builds the PRICE_ID_TO_TIER map at module load from both dev and prod price ID arrays.
sequenceDiagram
participant User as Browser
participant Action as checkOutSession
participant Stripe as Stripe API
participant Webhook as /api/webhooks/stripe
participant DB as Supabase
User->>Action: Select plan (priceId)
Action->>Action: Clerk auth + rate limit (15/60s)
Action->>DB: Fetch stripe_customer_id from users
Action->>Stripe: stripe.checkout.sessions.create
Stripe-->>Action: checkout URL
Action-->>User: Redirect to Stripe Checkout
User->>Stripe: Complete payment
Stripe->>Webhook: customer.subscription.created
Webhook->>Webhook: claimWebhookEvent (idempotency)
Webhook->>DB: UPSERT stripe_subscriptions
Webhook->>DB: Resume cancelled posts, promote OAuth clients
Webhook->>Webhook: Invalidate caches
Webhook->>Webhook: releaseWebhookEvent
Stripe->>Webhook: invoice.payment_succeeded
Webhook->>DB: INSERT stripe_invoices (amount_paid_cents)
Webhook processing uses a claimWebhookEvent/releaseWebhookEvent pattern to guarantee idempotent handling of each Stripe event.
src/app/api/webhooks/stripe/route.ts (268 lines) processes five event types:
| Event | Action |
|---|---|
customer.subscription.created |
Upsert stripe_subscriptions, resume system-cancelled posts, promote OAuth clients, invalidate caches |
customer.subscription.updated |
Upsert stripe_subscriptions (plan changes, status changes), invalidate cache |
customer.subscription.deleted |
Set status to cancelled, demote OAuth clients, cancel future scheduled posts, invalidate caches |
invoice.payment_succeeded |
Upsert stripe_invoices with amount_paid_cents |
invoice.payment_failed |
Upsert stripe_invoices with status failed |
checkActiveSubscription returns isActive=true if the most recent subscription has any of these statuses:
activetrialing
Returns false for past_due, cancelled, or no subscription. Defaults to false on error (fail-closed).
Checked by checkAccountLimits:
| Tier | Max connected accounts |
|---|---|
| Starter | 5 |
| Creator | 15 |
| Pro | 999 |
| Free (no sub) | 0 |
All 18 MCP tools are gated by ACCESS_PLAN_GATE, which requires Creator minimum:
| Tier | MCP access | Notes |
|---|---|---|
| Starter | Blocked | Web UI only. All MCP tool calls return an upgrade prompt. |
| Creator | All 18 tools | Subject to monthly quotas (see below). |
| Pro | All 18 tools | Unlimited usage (no quotas). |
REST API endpoints share the same quota system and plan gates. The withRestEndpoint middleware resolves the principal's plan and enforces the same tier and quota checks that MCP uses.
| Tier | Storage cap |
|---|---|
| Starter | 5 GB |
| Creator | 15 GB |
| Pro | 45 GB |
Storage quota is cumulative and checked during upload URL generation.
Defined in MONTHLY_CAPS from entitlement.ts. Enforced atomically via atomic_increment_quota Postgres RPC. Starter is blocked from all MCP tools at the gate level (not via quotas).
| Action | Starter | Creator | Pro |
|---|---|---|---|
schedule_post |
blocked | 500/mo | unlimited |
post_now |
blocked | 500/mo | unlimited |
request_upload_url |
blocked | 500/mo | unlimited |
attach_media_from_url |
blocked | 500/mo | unlimited |
bulk_schedule |
blocked | 200/mo | unlimited |
bulk_post_now |
blocked | 500/mo | unlimited |
generate_post_draft |
blocked | 100/mo | unlimited |
Starter shows "blocked" because ACCESS_PLAN_GATE rejects all MCP calls before quota checks run. The MONTHLY_CAPS for Starter are 0 across the board, but the gate check fires first.
generate_post_draft is available to Creator at 100/mo. Previous versions restricted this to Pro only.
All plans share the same per-file size caps:
| Type | Max per file |
|---|---|
| Image | 8 MB |
| Video | 250 MB |
createCustomerPortal creates a Stripe Billing Portal session. Rate limited at 20 requests per 60 seconds. Requires an active subscription. Return URL: /create.
The usage_quotas table stores per-principal monthly counts:
| Column | Description |
|---|---|
principal_id |
FK to principals |
period |
Date, first of month (e.g., 2026-05-01). All readers use currentQuotaPeriod(). |
action |
Action name (e.g., schedule_post) |
count |
Current count for this period |
Incremented atomically by atomic_increment_quota on every quota-gated MCP tool call. Period resets on the first of each month.
When a user's Stripe subscription reaches period_end, the webhook handler:
- Sets
stripe_subscriptions.status = 'cancelled' - Demotes the user's verified OAuth clients to unverified (
demoteOauthClientsOnCancel) - Cancels all future scheduled posts, tagging each with
cancelled_by_sub_at = now()(cancelFutureScheduledPostsOnSubCancel) - Invalidates subscription and entitlement caches
The user retains access to the dashboard and can resubscribe. Manual cancellations of posts made before the sub cancel are left untouched (they have cancelled_by_sub_at IS NULL).
When the user resubscribes, the webhook handler:
- INSERTs the new
stripe_subscriptionsrow - Resumes system-cancelled posts (
resumeCancelledPostsOnResubscribe). Posts whose originalscheduled_athas elapsed are bumped tonow() + 1 hourviabumpPastScheduleToFuture. - Re-promotes previously-demoted OAuth clients (
promoteOauthClientsOnResubscribe) up to the per-user cap of 5.
If the user does not resubscribe within 7 days, the daily cron cleanup-cancelled-posts-after-grace (05:00 UTC) deletes their system-cancelled posts. Orphan media in storage is picked up by sweep-orphan-storage-files (03:00 UTC) the following day.
The cron re-checks subscription status before deletion as a guard against webhook delivery failures.
flowchart TD
A[Subscription cancelled] --> B[Webhook fires]
B --> C[Mark status cancelled]
B --> D[Demote OAuth clients]
B --> E[Cancel future posts<br>cancelled_by_sub_at = now]
E --> F{User resubscribes<br>within 7 days?}
F -- Yes --> G[Resume cancelled posts]
G --> H[Bump past dates to now + 1hr]
F -- Yes --> I[Re-promote OAuth clients<br>up to cap of 5]
F -- No --> J[cleanup-cancelled-posts-after-grace<br>deletes posts at 05:00 UTC]
J --> K[sweep-orphan-storage-files<br>cleans media at 03:00 UTC next day]
AI agents access dedicated /api/x402/* routes and pay USDC per action on Base or Solana. No Stripe subscription required. Auth is via X-PAYMENT header (signed wallet payment).
| Action | USDC | Description |
|---|---|---|
register |
$1.00 | One-time wallet registration |
connect_account |
$0.50 | OAuth connection or re-auth |
post.text |
$0.50 | Single text post |
post.image |
$0.75 | Single image post |
post.video |
$1.00 | Single video post |
upload_url |
$0.10 | Mint signed upload URL |
reschedule |
$0.10 | Reschedule one post |
cancel |
$0.001 | Cancel scheduled posts |
delete |
$0.001 | Hard delete posts |
list_connections |
$0.001 | Read social connections |
list_posts |
$0.001 | Read scheduled posts |
list_history |
$0.001 | Read content history |
Prices are stored in the pricing_actions table. Seed SQL: /x402_pricing_actions_seed.sql.
- Refundable: Atomic DB insert failures after on-chain settlement trigger automatic refund.
- Non-refundable: Publish failures at Inngest execute time (post.text, post.image, post.video). Pay-per-attempt model.
- Refund records stored in
x402_refunds. On-chain tx hash included in error response.
Status flow: settled (default after INSERT) -> refunded (handler failure + refund) or failed (non-refundable error).
Wallet users get 5 GB aggregate storage (same as Starter tier, independent constant WALLET_STORAGE_LIMIT). Per-file caps: 8 MB image, 250 MB video.
| File | Purpose |
|---|---|
src/lib/types/plans.ts |
Plan tier definitions, price ID mappings, priceIdToTier() |
src/app/api/webhooks/stripe/route.ts |
Stripe webhook handler (268 lines) |
src/actions/server/stripe/checkUserSubscription.ts |
checkActiveSubscription, subscription status checks |
src/actions/server/stripe/customerPortal.ts |
createCustomerPortal, Stripe Billing Portal session |
src/actions/server/connections/checkAccountLimits.ts |
Account limit enforcement per tier |
src/lib/mcp/_shared/entitlement.ts |
MONTHLY_CAPS, ACCESS_PLAN_GATE, MCP tier gating |
src/lib/mcp/_shared/currentQuotaPeriod.ts |
currentQuotaPeriod(), period format for usage tracking |
src/lib/api/rest/middleware/withRestEndpoint.ts |
REST API auth middleware (enforces same plan gates) |
See also: docs/AUTH.md (subscription gate in auth flow), docs/MCP.md (per-tool quotas and tier gates), docs/STORAGE.md (storage caps per plan)