Plans and billing
Every workspace is on a plan: Free or Pro. Personal and team workspaces alike; a workspace's Owners pay for it. Design notes: docs/superpowers/specs/2026-10-13-billing-design.md.
Two layers keep cost bounded (below): cost ceilings that are always on, and plan quotas that apply when limits are enforced. A third set of limits is per account, not per workspace, and is always on too: how many workspaces one person may own, and the daily caps and Turnstile check at the door (Abuse protection).
Plans#
| Free | Pro | |
|---|---|---|
| Price | US$ 0 | US$ 29/month per workspace (founder prices below) |
| Members | 1 (the Owner) | as many as you invite |
| Asks | 100 a month | 5,000 a month (fair use) |
| Events | 1,000 a month | 50,000 a month (fair use) |
| Metric reports | 10,000 a month | 250,000 a month (fair use) |
| Image storage | 200 MB | 20 GB |
| Test API key traffic | 100 asks, 1,000 events, 10,000 metric reports a month | the cost ceilings only |
| Emails Logu sends for the workspace | 300 a month | the cost ceiling only (5,000) |
| Image uploads | 400 a month | the cost ceiling only (20,000) |
| WhatsApp template messages | none | 100 a month |
| Projects | 5 | 100 |
| Metrics (names) | 20 | 500 |
| Agents | 2 | no cap |
| Webhook endpoints | none | up to 10 |
| History in the app | the last 30 days | everything kept (180 days) |
| Deliveries (push, browser, Telegram, email) | all | all |
Monthly counts are per calendar month in UTC and reset on the 1st. Retention is 180 days on every
plan: a Free workspace that upgrades sees its older history again. Every number is a constant in
the protocol (FREE_PLAN_LIMITS, PLAN_QUOTAS, COST_CEILINGS, PRICING in
packages/protocol/src/billing.ts): change it there.
Founder prices. The first 100 workspaces that subscribe pay US$ 9/month, the next ones up to
1,000 in all pay US$ 19/month, then US$ 29. The price is locked for as long as that
subscription stays active; cancel and subscribe again and you get the price of the day. A checkout
holds its spot for about 45 minutes (a 30-minute Checkout Session plus a grace period); an abandoned
one gives it back. A member holds at most one open reservation across all their workspaces, and
may start at most 5 Checkout Sessions an hour (see Founder slots below). The landing page and
Settings → Plan show the current price and the spots left (GET /v1/billing/pricing).
Cost ceilings and plan quotas#
Cost was unbounded: Pro had no volume cap, WhatsApp and email were uncapped, image storage was unmetered, test API keys weren't counted. Two layers fix that. Both are per workspace, checked on the server, counted in one statement with the increment (concurrent requests can't go over), and a thing that is refused is never created.
| Cost ceilings | Plan quotas | |
|---|---|---|
| Applies | always, whatever BILLING_ENFORCED says |
only when BILLING_ENFORCED is "true" |
| Meant to be | generous: no honest workspace meets one | what the plan includes |
| Over it | 402 ceiling_reached |
402 plan_limit |
| Numbers | COST_CEILINGS |
PLAN_QUOTAS (Free, Pro), times QUOTA_FACTOR per price tier |
A meter's limit is the lower of the two. Counting never stops while limits aren't enforced, so turning enforcement on starts from the true usage.
The constants#
Every number, and where it lives. All in packages/protocol/src/billing.ts unless it says
otherwise. A ceiling or a quota with no value for a plan means that layer doesn't apply to it.
| Meter | Cost ceiling (COST_CEILINGS) |
Free quota | Pro quota | Counted in |
|---|---|---|---|---|
| Asks a month | 20,000 asksPerMonth |
100 | 5,000 | workspace_usage.asks |
| Events a month | 200,000 eventsPerMonth |
1,000 | 50,000 | workspace_usage.events |
| Metric reports a month | 1,000,000 metricsPerMonth |
10,000 | 250,000 | workspace_usage.metrics |
| Test asks a month (test API keys) | 5,000 testAsksPerMonth |
100 | none | workspace_usage.test_asks |
| Test events a month | 50,000 testEventsPerMonth |
1,000 | none | workspace_usage.test_events |
| Test metric reports a month | 250,000 testMetricsPerMonth |
10,000 | none | workspace_usage.test_metrics |
| WhatsApp template messages a month | 500 whatsappPerMonth |
0 | 100 | workspace_usage.whatsapp |
| Emails Logu sends for the workspace a month | 5,000 emailsPerMonth |
300 | none | workspace_usage.emails |
| Emails one member receives a day (across workspaces) | 100 emailsPerMemberPerDay |
none | none | member_counter (kind emails) |
| Image uploads a month | 20,000 imagesPerMonth |
400 | none | workspace_usage.images |
| Image storage (bytes held now) | 25 GB storageBytes |
200 MB | 20 GB | workspace_storage.bytes |
| Projects | 500 projects |
5 | 100 | counted from project |
| Metrics (names; a test metric counts too) | 2,000 metricNames |
20 | 500 | counted from metric |
| Active API keys of one agent | 20 apiKeysPerAgent |
none | none | counted from api_key |
| Requests a minute per workspace (all its API keys and connectors) | 3,000 RATE_LIMITS.workspaceRequestsPerMinute |
n/a | n/a | WORKSPACE_RATE_LIMITER (apps/api/wrangler.jsonc, namespace 4109) |
| Checkout Sessions a member starts an hour | 5 RATE_LIMITS.checkoutsPerHour |
n/a | n/a | member_counter (kind checkout) |
| Quota warning share | 80% QUOTA_WARNING_RATIO |
|||
| Quota factor per price tier | QUOTA_FACTOR: founder_9 1, founder_19 1, pro_29 1 |
Storage uses binary units (1 GB = 1,024 MB). The per-key rate limits (RATE_LIMITS: 60 asks, 600
events and metrics, 120 images and 1,200 requests a minute) are unchanged; the workspace limit
counts the requests of every key together, so more API keys don't lift it.
What a limit looks like#
Going over a limit answers 402 with what was hit and a hint (docs/errors.md). Nothing is
created: an ask over a limit was not asked, and the agent is told so (the SDKs raise
PlanLimitError, for both codes; MCP tools return a tool error starting plan_limit: or
ceiling_reached:). Agents must not treat it as an answer, and must tell their human.
plan_limit: the plan's quota (Free and Pro). Hint: upgrade (Free) or ask the Logu team to raise the workspace's quota (Pro). Pro has no overage billing yet.ceiling_reached: Logu's ceiling, which upgrading doesn't lift. Hint: tell your human, don't retry before the 1st, the Logu team can raise it for a workspace.
What each meter refuses:
- Asks, events, metric reports (and their test meters), image uploads: the request is refused
until the 1st (UTC). A refused request isn't counted, and a retry of an accepted one
(same
Idempotency-Key) isn't counted twice. - Image storage: the upload is refused and nothing is stored; sending the ask or event without the
image works. Storage counts unused uploads too (deleted after 24 hours) and gives bytes back when
images are deleted by that cleanup or by retention (180 days). The counter is kept incrementally
and reconciled against the
attachmentrows by the daily maintenance. - Projects, metric names, API keys: the request that would create one more is refused; the ones
that exist keep working. Logu's own notices go to a project named
loguthat isn't counted. - Requests a minute per workspace: 429
rate_limitedwithRetry-After, like the per-key limits. - A third agent on Free (
POST /v1/agents, connecting a new agent withlogu loginor OAuth): refused; connecting as an existing agent still works. Inviting members, and creating webhook endpoints: refused on Free. Members and endpoints a workspace already has stay; its endpoints are paused (no webhook deliveries) while it's on Free. - History: members see the last 30 days of asks, events and the activity log on Free (asks still waiting always show). Agents read everything.
Delivery channels never block an ask#
WhatsApp template messages (Meta bills each one; a free-form message inside the 24-hour window is free and isn't counted) and emails are capped, not refused. When a channel's cap is reached the ask is created and lands in the app and on every other delivery; only that channel skips the send. Free has no WhatsApp templates (quota 0); Pro has 100 a month. A skipped send:
- bumps the ops counter
delivery_capped(delivery_capped_5min theopsproject); - writes one
delivery.cappedactivity entry a month per channel (data:channel,reason:quota,ceilingormember_daily); - shows Owners a note on Settings → Plan ("WhatsApp template messages were skipped this month…").
Emails are counted for asks, reminders, "Send a test" and invitations (an invitation whose email is capped still returns its link to share by hand). Sign-in and confirmation emails belong to no workspace: they are limited per IP address and per email address, and a global daily cap bounds the ones sent to addresses with no account or no verified email (Abuse protection). A send the provider rejects gives its unit back.
WhatsApp templates are only for an ask and its reminder. Outside the 24-hour window an ask (and its reminder) goes out as a template, which is billed; an outcome ("Ana decided") and an event have no template: inside the window they are free-form messages, outside it they are not sent over WhatsApp (they stay in the app, on push and on the other deliveries, and a skipped one costs nothing and counts nowhere). That is two billed templates per ask per person where it was three. See delivery.md.
A WhatsApp credit pack (extra template messages sold apart from the plan) is not built: the flag
WHATSAPP_CREDIT_PACK.enabled (false) and messagesPerPack in the protocol mark where it plugs
in; today nothing reads a workspace's credits.
Telling the Owners#
Only for plan quotas, so only while limits are enforced: when a meter reaches 80% and again at 100% of its quota, the workspace's Owners get a notice, once a month per meter:
- an activity entry (
billing.quota_warningat 80%,billing.quota_reachedat 100%); - an event from Logu in the project
logu(like a threshold alert), delivered to the Owners following each one's delivery settings (push, Telegram, quiet hours, a muted project). Never an agent path: agents can't decide, change or read any of it.
A cost ceiling reached writes billing.ceiling_reached and the same event, once a month per
meter, and bumps the ops counter ceiling_hit. Settings → Plan shows every meter: the bar turns
to a warning (an icon and "Nearly full") from 80% and to "Full" at 100%.
Raising a limit for one workspace#
An operator sets limits by hand in D1, never through the API: workspace_subscription.limit_overrides
is a JSON object with any of the names in the table above (asksPerMonth, storageBytes, …). Each
key replaces both the ceiling and the plan quota of that name for that workspace (so a Pro
workspace that needs 30,000 asks gets {"asksPerMonth": 30000}). Values are whole numbers of
0 or more; anything invalid (bad JSON, an unknown name, a negative number) is ignored, so a typo
can't lift a ceiling. No API route reads or writes it, and the API never shows it: Owners see the
effective numbers on Settings → Plan.
pnpm --filter @logu/api exec wrangler d1 execute logu-db --remote --command \
"INSERT INTO workspace_subscription (workspace_id, limit_overrides)
VALUES ('wsp_…', '{\"asksPerMonth\": 30000, \"storageBytes\": 53687091200}')
ON CONFLICT (workspace_id) DO UPDATE SET limit_overrides = excluded.limit_overrides"
Undo with UPDATE workspace_subscription SET limit_overrides = NULL WHERE workspace_id = 'wsp_…'.
Overrides apply at the next request; they work on Free and Pro, with or without BILLING_ENFORCED.
Founder slots: hoarding#
A founder price is a scarce thing, so a checkout can't be used to sit on spots:
- A member holds at most one open reservation across all their workspaces (
founder_slot.user_id). Starting a checkout for a second workspace while the first is open still works, at the list price, until the first is paid, expires (Stripe'scheckout.session.expired) or lapses. The workspace that holds the slot keeps it when it starts again. - A member may start at most 5 Checkout Sessions an hour (429
rate_limited). - A reservation still lasts about 45 minutes, and the claim is one atomic statement, so concurrent checkouts of one member get one slot.
Abuse protection#
Phase 1 of the cost plan (docs/business/cost-plan/, impl-01-abuse-protection.md). Per-workspace
ceilings only bound the bill if workspaces and accounts can't be made without limit, so there are
three controls at the account and the door. All are always on, whatever BILLING_ENFORCED
says.
Workspaces per account#
| Limit | Constant (packages/protocol/src/billing.ts) |
|
|---|---|---|
| Shared workspaces an account may own, no workspace on Pro or comped | 3 | WORKSPACE_LIMITS.ownedFree |
| The same, owning at least one workspace on Pro or comped (the personal one too) | 25 | WORKSPACE_LIMITS.ownedPaid |
| Workspaces created per account per UTC day | 5 | WORKSPACE_LIMITS.createdPerDay |
- Owned is a membership with the role Owner in a shared workspace; the personal workspace never counts. Being a Decider or Viewer of other people's workspaces never counts and is never blocked: an account at its limit still joins any number of workspaces it is invited to.
- A verified email is needed to create a workspace and to become the Owner of one (403
email_not_verified). Becoming an Owner (an Owner promoting a member, or accepting an invitation as Owner) counts against that person's limit too; an Owner invitation that finds the person at the limit stays pending and says why when accepted by hand (it is not accepted automatically at sign-in). - Accounts above a limit keep everything (grandfathered): nothing is removed, renamed or blocked, they just can't create or take more until they are under it again (delete a workspace, or have one on Pro).
- The count and the insert are one D1 batch (the workspace row goes in only while the account owns
fewer than its limit), and the daily count is claimed atomically in
member_counter(kindworkspaces, one row per account per UTC day, deleted after two days). Two requests at once can't both pass, and one that loses the race gives its daily unit back. - Errors (docs/errors.md), all with a hint and nothing created: a Free account at its 3 gets 402
plan_limit("Upgrade one of your workspaces to Pro to own up to 25, or write to the Logu team if you are an agency or consultant"); an account at 25, or at a number set by hand, gets 402ceiling_reached; the 6th creation of the day gets 429rate_limitedwithRetry-Afteruntil 00:00 UTC; an unverified email gets 403email_not_verified. The app's create-workspace dialog says each in the user's language (workspace_limit_*,workspace_create_*). - Activity: a creation is
workspace.created; a refusal isworkspace.create_refusedin the account's personal workspace (data.reason:owned_limit,daily_limit,email_not_verified), at most once a day per reason, so a script can't fill the log. - API keys and OAuth tokens (agents) can't create workspaces at all (
POST /v1/workspacesis for signed-in humans), and nothing in the API reads or writes the overrides below.
For agencies and consultants. Raise the numbers for one account by hand, in D1, never through
the API (invisible to agents and OAuth tokens). account_limit.overrides is a JSON object with
ownedWorkspaces (replaces 3 or 25) and/or workspacesPerDay (replaces 5), whole numbers of 0 or
more; anything invalid is ignored, so a typo can't lift a limit.
pnpm --filter @logu/api exec wrangler d1 execute logu-db --remote --command \
"INSERT INTO account_limit (user_id, overrides, note)
SELECT id, '{\"ownedWorkspaces\": 40, \"workspacesPerDay\": 10}', 'Acme agency, 2026-10-17'
FROM user WHERE email = 'ana@agency.example'
ON CONFLICT (user_id) DO UPDATE SET overrides = excluded.overrides, note = excluded.note"
Undo with DELETE FROM account_limit WHERE user_id = (SELECT id FROM user WHERE email = '…'). It
applies at the next request.
Rolling back. There is no flag: set WORKSPACE_LIMITS in the protocol high and deploy, or put
an override with large numbers on the accounts that need it.
Cloudflare Turnstile#
Cloudflare's free captcha on password sign-up, password sign-in and magic-link requests. Not on passkey sign-in, the magic link's click, the confirmation link, the device-code connect or the OAuth connect (those sit behind an existing session or prove an email), and never on anything an agent calls.
- Off until you set it up. The API enforces it only while the secret
TURNSTILE_SECRET_KEYis set, and the app shows it only when it was built withVITE_TURNSTILE_SITE_KEY. Unset (local development, tests, e2e, self-hosted servers) nothing changes. The code ships first; you turn it on. - What the API does. The app sends the widget's token in the
Turnstile-Tokenheader; the API checks it with Cloudflare'ssiteverify(afetchwith a 5-second timeout) after the per-IP and per-address rate limits, so a flood never reaches Cloudflare. No token or a rejected one: 403forbidden("Complete the verification and try again."). Cloudflare unreachable, slow or answering nonsense: fail closed, 503service_unavailable("Try again in a minute."). Only Cloudflare's result code is logged (turnstile.rejected,turnstile.siteverify), never the token, the secret or the address. - What the app does. The script from
challenges.cloudflare.comis loaded when a sign-in or sign-up screen opens (never in the initial bundle: the 180 KB budget is untouched), the widget runs on demand at each attempt withappearance: interaction-only(invisible unless Cloudflare wants an interaction, which then shows under the card), and every attempt (the resend of a link too) gets a fresh single-use token. It follows the app's light or dark theme and language (en,pt-br), the iframe is Cloudflare's accessible one, and a failure is said in the app's words (auth_error_turnstile). - CSP (
apps/web/public/_headers):script-src,connect-srcand a newframe-srcallow exactlyhttps://challenges.cloudflare.comand nothing else;test/csp.test.tskeeps it honest (no wildcard, nounsafe-inline, and the script's URL appears in the build only as that one URL). - The iOS and Android apps (Capacitor) load the same bundle, so the widget runs there too: add
localhostto the widget's hostnames (the web view's origin iscapacitor://localhoston iOS andhttps://localhoston Android). The over-the-air bundle carries the site key it was built with. An installed app from before this release has no widget: once the secret is set, its password sign-in and magic-link requests fail with the "couldn't confirm you're a person" message until it updates (sessions already signed in keep working). RaiseMIN_NATIVE_VERSIONonly if you want to force it. - Cloudflare's test keys (always passes
1x00000000000000000000AA/ secret1x0000000000000000000000000000000AA, always blocks2x…AB/2x…AA, dummy tokenXXXX.DUMMY.TOKEN.XXXX) appear only in tests, withsiteverifymocked.
What you do, once (free):
- Cloudflare dashboard → Turnstile → Add widget. Name "Logu sign-in"; hostnames
logu.devandlocalhost(for the apps; addlocalhostalso for local development if you want to try it); mode Managed; pre-clearance off. Copy the site key (public) and the secret key. - The site key is public and committed in
apps/web/.env.production(VITE_TURNSTILE_SITE_KEY), so every production build (Workers Builds,cap:sync, the over-the-air bundle) shows the widget; the e2e build overrides it with an empty value. Deploylogu-weband check the widget on logu.dev before the API asks for it (until the secret is set the API ignores the token). - Then set the secret on the API:
pnpm --filter @logu/api exec wrangler secret put TURNSTILE_SECRET_KEY. From the next request, the three routes need a token. - Try a sign-in, a sign-up and a magic link on logu.dev. To turn it off again, delete the secret
(
wrangler secret delete TURNSTILE_SECRET_KEY): no deploy needed, the app's widget keeps working and is simply not checked.
Global daily caps#
Whatever Turnstile and the rate limits let through, two counters bound a day (UTC), one D1 row each
in global_counter (deleted after two days):
| Cap | Default | Var in apps/api/wrangler.jsonc |
Counts |
|---|---|---|---|
| New accounts a day | 1,000 (GLOBAL_DAILY_CAPS.signups) |
SIGNUPS_PER_DAY_MAX |
a password sign-up of a new address, and a magic link that creates the account |
| Sign-in emails a day to addresses with no account or no verified email | 5,000 (GLOBAL_DAILY_CAPS.authEmails) |
AUTH_EMAILS_PER_DAY_MAX |
magic links, and the confirmation link of a sign-up, to such addresses |
- An existing account is never capped. Password sign-in, a magic link to an address that exists with a verified email, passkeys and sessions don't touch either counter, so an attacker who uses the whole cap locks no one out; they only stop strangers from joining until 00:00 UTC. (An existing account whose email was never confirmed does count against the email cap.)
- A refusal is a calm 429
rate_limited("New sign-ups are temporarily limited. Try again in a few hours." / "Sign-in emails to new addresses are temporarily limited…") withRetry-Afterto 00:00 UTC, and the hint says that existing accounts are unaffected. A magic link to an unknown address is refused before any email is sent while sign-ups are closed. Both counters are one atomic statement, and a sign-up that fails or an email the provider rejects gives its unit back. - The vars are numbers; empty or invalid means the default. They live in
wrangler.jsonc, so changing one is a deploy of the config (or the dashboard's variable, until the next deploy). - Monitoring (ops, observability.md):
signups_capped_5mandlogin_emails_capped_5mcount refusals and alert above 0;signups_1dandauth_emails_1dreport today's totals and alert at 500 and 2,500 (half of the defaults). The alerts are added to an existing ops workspace on the first cron run after the deploy.
Before turning BILLING_ENFORCED on#
Quotas only bite when BILLING_ENFORCED is "true". Before you flip it, list every Free workspace
that would be over a quota on day one (read-only, nothing is written):
pnpm --filter @logu/api billing:preflight
It runs apps/api/scripts/billing-preflight.sql against production D1 and prints four lists: Free
workspaces over a monthly quota in the last three months (asks, events, metric reports, test
traffic, WhatsApp, emails, image uploads), Free workspaces holding more than Free includes now
(image bytes, projects, metric names, agents, members), Free workspaces with webhook endpoints (they
would be paused), and accounts that own more than 3 shared workspaces with no Pro one. Contact or
comp the real ones, set limit_overrides where an exception makes sense, then set BILLING_ENFORCED
to "true" and deploy. Workspaces on Pro or comped are left out, and one with limit_overrides is
listed by the default quotas (look at its row). test/billing-preflight.test.ts keeps the SQL's
numbers equal to the protocol's.
Enforcement switch#
BILLING_ENFORCED (a var in apps/api/wrangler.jsonc) is "false": plan quotas aren't
applied, usage is still counted, and the app shows plans, usage and Upgrade with a note that
limits aren't applied yet. The cost ceilings and the per-account limits are on either way.
Run the pre-flight above, then set it to "true" and deploy to enforce the quotas.
Upgrading and managing (Owners)#
Settings → Plan → Upgrade opens Stripe Checkout at the current price; Manage opens Stripe's
Customer Portal (card, invoices, cancel). Cancelling keeps Pro until the end of the paid period. If
a renewal payment fails, the workspace stays on Pro while Stripe retries (past_due); when Stripe
gives up the subscription is cancelled and the workspace is Free. A workspace with an active
subscription can't be deleted until it's cancelled. The iOS and Android apps show the plan but
don't sell it: manage it on logu.dev.
| Route | Who | |
|---|---|---|
GET /v1/billing |
Owners | plan, subscription, this month's usage, Free limits, every meter (meters: used, quota, ceiling, state), channels capped this month (deliveryCapped), enforced |
POST /v1/billing/checkout |
Owners | { url } of a Checkout Session |
POST /v1/billing/portal |
Owners | { url } of a Customer Portal session |
GET /v1/billing/pricing |
anyone | current price and founder spots left |
POST /stripe/webhook |
Stripe (signed) | subscription lifecycle |
Activity log: billing.checkout_started, billing.subscribed, billing.updated,
billing.canceled, billing.payment_failed, billing.quota_warning, billing.quota_reached,
billing.ceiling_reached, delivery.capped, workspace.create_refused.
Setting up Stripe#
Nothing in Stripe is created by deploying. Run, with the Stripe key of the mode you want (test first):
CLOUDFLARE_ACCOUNT_ID=… pnpm stripe:setup https://api.logu.dev --dry-run # look first
CLOUDFLARE_ACCOUNT_ID=… pnpm stripe:setup https://api.logu.dev
It creates (or finds) everything below, stores the two secrets on logu-api and prints the Price
ids. By hand, it's:
- Product "Logu Pro" (metadata
logu=pro). - Three Prices on it, USD, recurring monthly: US$ 9 (lookup key
logu_pro_founder_9), US$ 19 (logu_pro_founder_19), US$ 29 (logu_pro_29). - Webhook endpoint
https://api.logu.dev/stripe/webhookwith the eventscheckout.session.completed,checkout.session.expired,customer.subscription.created,customer.subscription.updated,customer.subscription.deleted,invoice.payment_failed. - Customer Portal (Settings → Billing → Customer portal): payment method updates, invoice
history and cancellation (at the end of the billing period) on; switching plans off (it
would let a customer move to a founder price); default redirect
https://logu.dev/settings#plan. - Secrets on the Worker:
STRIPE_SECRET_KEY(a restricted key with write access to Checkout Sessions, Customer Portal sessions, Customers and Subscriptions is enough) andSTRIPE_WEBHOOK_SECRET(the endpoint'swhsec_…):pnpm --filter @logu/api exec wrangler secret put STRIPE_SECRET_KEY. - Vars in
apps/api/wrangler.jsonc:STRIPE_PRICE_FOUNDER_9,STRIPE_PRICE_FOUNDER_19,STRIPE_PRICE_PRO_29(theprice_…ids). With any of them or the key missing, Upgrade is disabled andPOST /v1/billing/checkoutanswers 503;/stripe/webhookanswers 404 without its secret.
Comping a workspace#
Pro without Stripe (the owner's own workspaces, partners) is a flag in D1, never an API route:
pnpm --filter @logu/api exec wrangler d1 execute logu-db --remote --command \
"INSERT INTO workspace_subscription (workspace_id, comped) VALUES ('wsp_…', 1)
ON CONFLICT (workspace_id) DO UPDATE SET comped = 1"
Undo with UPDATE workspace_subscription SET comped = 0 WHERE workspace_id = 'wsp_…'. A comped
workspace shows "Pro · Comped" and has nothing to buy; its quotas are the list price's. Find a
workspace's id in the app's URL (?workspace=) or with SELECT id, name FROM workspace WHERE name = '…'.
Data#
workspace_subscription (one row per workspace: Stripe customer and subscription, status, tier,
period end, comped, limit_overrides), founder_slot (slot 1–1,000: reserved by an open
checkout until it expires, taken once paid; user_id of the member who started it),
workspace_usage (per workspace and month: asks, events, metric reports, the three test meters,
WhatsApp template messages, emails, image uploads), workspace_storage (bytes of images held),
workspace_notice (what was told once this month: quota notices, ceilings, capped channels),
member_counter (emails a member received a day, checkouts a member started an hour, workspaces
an account created a day and the once-a-day refusals' markers; deleted after two days),
global_counter (new accounts and sign-in emails to new addresses, one row per name per UTC day;
deleted after two days), account_limit (an account's hand-set limits), stripe_event (handled webhook event ids, 30 days, so a redelivery never runs
twice).