CustomDomain Docs

Plans & quotas

The plan catalog and entitlements, trials and subscriptions, how metered monthly domain usage and quotas work, and how Stripe checkout and the portal fit in.

Plans

The public catalog is returned by GET /v1/plans (along with a billing: { configured, mode, trial_days } block). The figures it returns are the defaults: the operator of a deployment can change a plan's price (price_cents), its domain allowance (domains_per_year, and so domains_per_month), its trial (trial_days) and whether it blocks at quota (hard_cap) without a release. Read them from GET /v1/plans rather than hard coding them. billing.trial_days is the default plan's trial.

Entitlements gate which products a tenant may use. Enforced today: Power (serving domains through the CustomDomain™ edge: the /v1/connections/{id}/power* surface, the application's default origin, and an application_url sent with POST /v1/connections) and Secure (the /v1/ssl* surface); an unentitled tenant gets 402 plan_upgrade_required. WhiteLabel (widget branding) is enforced by dropping the branding, as described below. Connect is included on every plan and needs no gate. Monitor is listed as an entitlement on Premium and Enterprise, but nothing enforces it: every connection that has monitor on (the default) is monitored, on every plan. The deployment-level MONITOR_ALERTS_ENABLED flag governs alert delivery for everyone.

PlanPriceDomains / year (sold)Enforced allowanceRefused at quota?EntitlementsMotion
starter$10/mo1010 in any 12 monthsyesConnectself-serve, 14 day free trial
startup$149/mo60050 a monthnoConnectself-serve, 14 day free trial
growth$649/mo60050 a monthnoConnect · Power · Secure/SSLself-serve, 14 day free trial
premiumdemo-gated12,0001,000 a monthno+ Monitor (listed, not enforced)contact sales
enterprisecustom12,000+1,000 a monthno+ white-labelcontact sales

Prices and the domain columns above are transcribed from GET /v1/plans (price_cents, domains_per_year, and quota over quota_window_months). If this table and that endpoint ever disagree, the endpoint is right and this page is stale.

Starter replaced the Free plan on 2026-09-29. free is retired: no new workspace is put on it, GET /v1/plans no longer lists it and Checkout refuses it. Workspaces that were on Free keep working while they move to a paid plan; see The Free plan's retirement.

Two numbers, and which one gates you

A tier is sold annually. The meter counts domain connections per UTC calendar month, and the quota gate compares a count against the plan's quota over its quota_window_months:

  • Starter (and the retired Free): 10 over a rolling 12 months. The gate adds this month's count to the 11 months before it, so a Starter workspace can connect several domains in one month as long as it stays within 10 in any 12 months. (Before November 2, 2026 Starter was held to 1 a month.)
  • Every other tier: its monthly slice, domains_per_month, which is ceil(domains_per_year / 12), over 1 month. Startup's 600/year is therefore 50 in any one month. Ceiling division means the annualized entitlement never shrinks below what was sold.

Whether that monthly number blocks takes both a plan property and a deployment switch. The plan property is hard_cap, and it is true only on starter (and the retired free); see Overage for why no larger tier is ever refused. The switch is BILLING_ENFORCE_QUOTA: infra/docker-compose.prod.yml ships it defaulted to 0, so the hosted service runs meter only and refuses nothing at quota; 1 turns enforcement on. Both must hold. The gate also fails open by design even when enabled: a billing lookup error allows the connect rather than blocking a customer during a billing outage. The gate is enforceQuota in services/control-plane/internal/api/billing.go; the billing service applies the same hard_cap rule in ConnectDomain, so the two cannot disagree.

All of these are published on GET /v1/plans (domains_per_year, domains_per_month, quota and quota_window_months). GET /v1/billing/usage compares its quota_used (the count over the window) with quota; its used stays this month's count.

Self-serve plans (starter, startup, growth) are bought through Stripe Checkout. premium and enterprise route to a "book a demo" CTA and have no public price.

White-label is enforced server-side, not just hidden in the console. A direct widget open() always applies whatever branding you configure: that's your own client-side config, and the control plane never sees it. But when a connect is forwarded through a share link and a teammate resumes it (loadSharedFlow), the control plane checks the sharing tenant's plan and strips any whiteLabel branding from the resumed session unless it's on enterprise; the flow itself still works, it just falls back to default, unbranded styling. See Widget SDK reference.

Not included, despite older copy: SSO and SCIM are not built and no tier grants them. No /scim/v2/* endpoint exists, and the console's authentication supports email/password, optional Google and GitHub sign-in, and WebAuthn passkeys; there is no SAML/OIDC provider. Monitor is DNS record drift detection only; nothing probes a domain or origin for reachability, status code or latency, so there is no uptime monitoring. Drift alerts are additionally off unless the deployment sets MONITOR_ALERTS_ENABLED; see Webhooks.

Subscriptions and trials

Every workspace needs an active subscription (or a free trial of one) to connect new domains and to hold new credentials: creating an API key, creating an application and rotating an application's secret. The one exception is a new workspace's first ten domains, which are free (see The first ten domains are free). Domains that are already connected are never touched by billing: live traffic, certificate renewals, rechecks and monitoring carry on whatever the billing state, and credentials that already exist keep working.

  • A new workspace can look around first. Signing up does not require a card: you can check domains, design and preview your widget and read the docs without one, and your first ten domains connect without one. The setup flow is offered once, after all ten are connected and the tenth is live, and skipping it sticks. The card is asked for where it is needed, when you create an API key, create an application or connect an eleventh domain, and that starts a 14 day free trial in a checkout on the page itself, not a pop-up. The order summary, the total and the buttons are the console's own, in light or dark; only the card fields are Stripe's. The first charge happens when the trial ends; canceling before then costs nothing. Until the trial starts, new credentials are refused, and so is every connection after the first.
  • Promotion codes are typed into the order summary of the checkout, or sent as promotion_code when you start one yourself (POST /v1/billing/checkout), or applied to a running subscription with POST /v1/billing/promotion-code. Both accept only codes that belong to this deployment (made through the operator console, or limited to its products in Stripe) and answer 404 for any other code on a shared Stripe account, the same as for a code that does not exist, so a code cannot be told apart from a stranger's. Stripe's own code field is never shown on the checkout. A checkout started with a code carries it as its one discount, and a Checkout Session cannot be given a discount after it is created, so adding or taking off a code in the order summary starts a new checkout in its place: the card fields of the old one are empty again, and nothing is charged by either until the payment is confirmed.
  • Changing plans on a running subscription is POST /v1/billing/change-plan; Stripe prorates the difference. During a trial, the new price applies from the trial's end.
  • Card updates and cancellation happen in the Stripe Billing Portal (POST /v1/billing/portal, with flow: "payment_method_update" or "subscription_cancel" to open straight at that task).

Billing states

GET /v1/billing/status (owners and admins) evaluates the workspace into one of eight states. Only the last two refuse new connections.

StateMeaningNew connections
trialingIn the free trial of a paid plan; the first charge is at trial_ends_at.allowed
activeA paid subscription in good standing.allowed
past_dueA payment failed and Stripe is retrying it. Owners and admins are asked to update the card; nothing else changes.allowed
compedComplimentary access granted by CustomDomain™ (until comped_until, or with no end).allowed
graceA workspace that was on Free, inside its 14 day grace period.allowed
legacy_freeA Free workspace whose grace has not started yet.allowed
blockedNo subscription after the trial, grace or comp (reason: setup_required, grace_ended, comp_ended), or a subscription Stripe gave up on after its retries (unpaid).refused (except a new workspace's first ten domains)
canceledThe subscription ended.refused

GET /v1/billing/status also returns payment_method, what the workspace is charged on, or null when there is none. type is card, link or cashapp. A card has brand, last4, exp_month and exp_year, and wallet (apple_pay or google_pay) when it was paid through a wallet. A Link account has the email of the account (the card behind it is never shown), and Cash App Pay has only its type. Any of them counts as a payment method on file, so a workspace that paid with Link is not shown "No card on file" and is not refused by the card check on agent purchases. Checkout opens with the owner's email already filled in.

What a refusal looks like

The control plane refuses at one place, before a connection is created, for every surface that can start one: the console, the API, bulk connect, the MCP server, the embedded widget and the start of a domain purchase. Creating an API key (POST /v1/applications/{id}/keys), creating an application (POST /v1/applications with an API key) and rotating an application's secret (POST /v1/applications/{id}/client-secret:rotate) are refused the same way while a workspace has no trial or subscription, so a widget you embed never meets a billing refusal in front of your own customers. Reading, listing, checking domains and the billing routes themselves stay open.

  • The workspace itself (an API key, an agent token, an app-scoped token) gets 402 billing_required, with the reason and where to fix it in details.
  • The tenant's end users in the embedded widget get 402 connections_unavailable and a neutral sentence that says nothing about the tenant's plan or billing. The widget maps both to PlanLimitError on its customdomain:error event.

The switch is BILLING_REQUIRE_SUBSCRIPTION (default 1). With it set to 0, or on a deployment where no Starter price is configured (so nobody could subscribe), nothing is refused. The check fails open on a lookup error.

The first ten domains are free

A brand new workspace, one that has never had a subscription or a trial, connects its first ten domains without a card, whether it connects from the console, the API, the MCP server or the embedded widget, so you can see your domains work before you are asked for one. While the workspace has fewer than ten pending, propagating or live connections, a new connection (and the start of a domain purchase) goes through. A connection that failed does not count, posting the same domain again returns it with 200, as it always did, and removing a domain frees its place at once. The eleventh domain is refused with 402 billing_required, whose title says a card is needed to connect more domains. Creating an API key or an application still needs the trial.

GET /v1/billing/notice and GET /v1/billing/status carry first_domain_free: true for such a workspace (owners and admins only), with free_domains, how many domains it may connect before a card (ten). While a free domain is left restricted is false and the console says "Your first 10 domains are free"; once all of them are connected restricted is true and the notice says a card is for more domains.

The console asks owners and admins for a card only once every free place is used. With one to nine of the ten connected nobody is sent to the setup flow and no card prompt appears; you keep connecting. Once the tenth is connected, the live card and the domain page say a card is for more domains, and the setup flow is offered once, the next time the console loads after the tenth is live (not while it is still connecting). Skipping it sticks, invited teammates are never sent there, and a billing read that fails never keeps anyone out of the console. With an allowance of one, all of this happens after that one domain, as before. GET /v1/billing/standing says the same to an agent or a server holding an API key.

A domain that was a typo holds a free place until it is removed: remove it from Domains, which frees its place at once. (When a workspace's allowance is one, the Connect page also offers to remove the one domain still waiting for its records and connect the new one in the same step.)

Support can set a different number of free domains for a workspace, from 1 to 50, and the same reads then say how many ("Your first 3 domains are free"); setting it back to 0 returns the workspace to the default of ten. It changes nothing for a workspace the rule does not cover.

The free domains stay for as long as the workspace has no subscription, because billing never touches a live domain. The rule never applies to a workspace that had a trial, a subscription, a comp or the Free plan. Its switch is BILLING_FIRST_DOMAIN_FREE (default 1); with it set to 0, or switched off at runtime, a new workspace is refused its first connection exactly as it was before.

The Free plan's retirement

A workspace that was on Free keeps its 10 domains a year while it moves:

  1. The grace starts the first time an owner or admin signs in to the console after the change, or at the workspace's first connection attempt, whichever comes first. (Starting it at deploy time would have run the clock down for workspaces nobody had looked at.) Owners and admins are emailed when it starts.
  2. For 14 days everything keeps working. Owners and admins see the setup flow when they sign in to their own workspace (with a "Not now" for the rest of that session; someone who joined the workspace by invite gets the banner instead), a banner on every console page, and email reminders with 7, 3 and 1 days left.
  3. Subscribing during the grace costs nothing before the grace ends: the subscription starts with a trial that runs exactly to the grace's end.
  4. After the grace, without a subscription, new connections are refused. Live domains keep serving.

Payment failures

A failed payment moves the subscription to past_due: owners and admins get an email with a link to update the card in the Billing Portal, and a banner, while Stripe retries on its own schedule. Nothing is refused during the retries. If every retry fails, what happens next is the Stripe account's setting ("if all retries for a payment fail"): cancel the subscription or mark it unpaid both end in new connections being refused (canceled, or blocked with reason: "unpaid"), with an email first. Leaving it past due would never restrict anything. An unpaid subscription is fixed with a working card, which pays the open invoice; a second checkout is refused (409).

When a paid plan ends

When a paid subscription ends (canceled, or unpaid after its retries), the workspace's connected domains keep serving for 30 days. Owners and admins are emailed when the 30 days start, with the date, and again 7 days and 1 day before. After that the domains show a neutral hold page until a paid plan is active again, and then they serve again at once.

  • A past_due plan (Stripe still retrying), a running trial, a workspace that never subscribed and a comped workspace never lapse.
  • The hold page is a plain 503 that says "This site is temporarily unavailable." It carries no brand and no link, asks search engines not to index it, and sets Retry-After to an hour. Certificates keep renewing, so the domain works the moment the plan is back.
  • A workspace is never held before its owners got the first email.

Who sees billing

Billing belongs to a workspace's owners and admins (billing:manage). The billing routes (status, invoices, checkout, portal, plan changes, promotion codes, the grace start) answer members, viewers and widget tokens with 403, and the console shows members and viewers a neutral note ("ask an owner or admin") instead of the billing page. Two reads stay open to every member: GET /v1/billing/usage gives them the counts without the billing fields, and GET /v1/billing/notice tells them only whether billing needs attention and whether new connections are paused, never the plan, dates or amounts.

Billing emails

Owners and admins of the workspace get these, each exactly once:

EmailWhen
Trial startedThe trial begins (Checkout completed).
Trial ending3 days before the first charge.
Payment failedA charge fails; links to the card update in the Billing Portal.
Free plan endingThe grace starts, and again with 7, 3 and 1 days left.
New connections pausedThe workspace becomes restricted (grace or comp ended, or unpaid after retries).
Subscription active againA restricted or past due workspace is back in good standing.
Subscription canceledThe subscription ends. Once lapsed plans are on, the first lapse email below goes instead.
Domains pause on a dateA paid plan ended: the 30 days start. It gives the date the domains pause.
Domains pause soon7 days and 1 day before the domains pause.
Domains pausedThe 30 days are over and the domains show the hold page.

Metered usage

GET /v1/billing/usage reports the caller tenant's metered domain connections for the period against the plan's included quota. The tenant is derived from the authenticated credential, never a query parameter, and ?period= defaults to the current UTC calendar month.

{
  "org": "ten_1a2b3c",
  "period": "2026-07",
  "used": 412,
  "mode": "live",
  "plan": "growth",
  "plan_name": "Growth",
  "quota": 50,
  "limit": 50,
  "quota_used": 412,
  "quota_window_months": 1,
  "metered": true,
  "hard_cap": false,
  "status": "active",
  "billing_state": "active",
  "quota_state": "at_limit",
  "quota_enforced": false,
  "remaining": 0,
  "has_billing_account": true
}

used is this month's count. Compare quota_used against quota: quota_used is the count over the last quota_window_months months (12 on Starter, so this month plus the 11 before it; 1 on every other tier, where it equals used), and quota is the plan's allowance over that window (0 means unlimited). limit is an alias carrying the identical value: the published schema had always declared limit while the handler only ever emitted quota, so both are sent now and either may be read. status, has_billing_account and billing_state are billing data and are only sent to a credential that holds billing:manage.

quota_state is the server's own verdict: ok, approaching (at or past 80% of quota) or at_limit. remaining is quota - quota_used floored at zero, omitted entirely on an unlimited plan.

hard_cap is the plan's refusal policy (true only on starter), and quota_enforced reports whether a 402 is actually possible right now: it needs hard_cap and the deployment's BILLING_ENFORCE_QUOTA, the second of which a client cannot see. On every larger tier quota_enforced is false at any usage. The example above is a growth tenant at 412 used against a quota of 50: at_limit, quota_enforced: false, and nothing refused. Treat at_limit as "over the line", never as "blocked"; check quota_enforced for that.

Overage

No plan above Starter is ever refused a connection at quota. On startup, growth, premium and enterprise, a connection past the monthly quota is allowed, recorded as overage, and surfaced, never blocked. starter is the only tier that returns 402 quota_exceeded at its quota (where the deployment enforces quotas at all).

The reason is the blast radius of the refusal, not generosity. The quota gate sits on the connection path that the embedded widget calls with an end user credential, so a 402 does not land on the tenant's admin; it lands on their end user, mid-connect, inside the customer's own signup funnel. Breaking a paying customer's funnel to collect overage is a bad trade at any quota, and doubly so here because no code path has ever reported usage to Stripe: the Stripe client has no usage record or meter event call at all, so the debt being enforced was never billed in the first place.

Overage is instead recorded and surfaced: /v1/billing/usage reports quota_state and remaining, the console warns at 80% and again past quota, and the usage band feeds the CRM so the account team can right-size the plan. An overage becomes a conversation, not an outage.

metered on growth/enterprise describes how a tier is priced and carries no enforcement meaning at all. Read hard_cap for a plan's refusal policy.

Retraction. An earlier revision of this page said every tier was hard capped, and promised that metered tiers would only stop blocking in the same change that started charging for overage. That promise is withdrawn. Paid tiers stopped blocking, and no charging was introduced: the change is strictly in the customer's favour, which is why it shipped without the notice period that promise described. If usage based billing is ever built (Stripe Billing Meters; the legacy Usage Records API is deprecated), it will be announced before it takes effect.

Checkout & portal

# Start a Stripe Checkout for a self-serve plan (a new workspace gets its 14 day trial)
curl -X POST https://api.customdomain.ai/v1/billing/checkout \
  -H "Authorization: Bearer <KEY>" -H "Content-Type: application/json" \
  -d '{"plan":"starter","success_url":"https://app.acme.example/ok","cancel_url":"https://app.acme.example/cancel"}'
# → { "id": "cs_...", "url": "https://checkout.stripe.com/...", "trial_days": 14, "first_charge_at": "..." }

# Open the Stripe Billing Portal at the card update (requires a prior checkout)
curl -X POST https://api.customdomain.ai/v1/billing/portal \
  -H "Authorization: Bearer <KEY>" -H "Content-Type: application/json" \
  -d '{"return_url":"https://app.acme.example/billing","flow":"payment_method_update"}'

Pass return_url instead of success_url and cancel_url for Stripe Embedded Checkout; the response then carries a client_secret to mount. Checkout and the portal require STRIPE_SECRET_KEY and the STRIPE_PRICE_* ids (STRIPE_PRICE_STARTER, STRIPE_PRICE_STARTUP, STRIPE_PRICE_GROWTH); without them these routes return 503 service_unavailable while the plan catalog stays public. A workspace that already has a running subscription is refused a second checkout with 409. Enforcement is on the control plane's write path only, never on the edge ask hot path, so serving traffic never blocks on billing.

promotion_code is optional: the code as a customer types it (at most 64 characters, never a Stripe promo_ id). The control plane checks it before Stripe is asked for a session, takes it only when it is one of this deployment's own (see Promotion codes above), and makes it the session's one discount. A code that does not exist, ran out, or belongs to another product on the same Stripe account is a 404, and no session is created; a code of this deployment that Stripe will not give this order (a first order only code, a minimum amount) is a 400. Stripe's own code field is never switched on, and a session cannot be given a discount after it is created, so to change the code, start the checkout again with the new one (or none) and mount the new client_secret.

curl -X POST https://api.customdomain.ai/v1/billing/checkout \
  -H "Authorization: Bearer <KEY>" -H "Content-Type: application/json" \
  -d '{"plan":"starter","return_url":"https://app.acme.example/done","promotion_code":"LAUNCH50"}'

ui_mode is optional and picks how the session is shown. Leave it out (or send embedded; null and an empty string count as leaving it out) and nothing changes: Stripe Embedded Checkout, an iframe Stripe styles itself, when return_url is set, and the hosted redirect when only success_url and cancel_url are. elements is Elements with the Checkout Sessions API: you render the whole page (the plan, the price, the trial, the promotion code box, the totals) and mount only Stripe's Payment Element with the returned client_secret, so the page can wear your own theme. It needs return_url. An elements session is created on Stripe API version 2026-08-26.dahlia, pinned on that one request. This is the one place the exact version is written down (the API reference says only that it is the dahlia release train), so mount the session with a Stripe.js of the dahlia train. Every response carries ui_mode, the mode the session really has: embedded, elements, or hosted for the redirect flow. An elements request answers embedded when Stripe cannot create an Elements session, so read the response before you mount anything. hosted is only ever an answer: a client that keeps the response's ui_mode must not send it back, because any value other than embedded and elements is a 400. The same field works on POST /v1/billing/setup (save a card) and POST /v1/registrar/checkout (buy a domain).

curl -X POST https://api.customdomain.ai/v1/billing/checkout \
  -H "Authorization: Bearer <KEY>" -H "Content-Type: application/json" \
  -d '{"plan":"starter","return_url":"https://app.acme.example/billing/return?session_id={CHECKOUT_SESSION_ID}","ui_mode":"elements"}'
# → { "id": "cs_...", "client_secret": "cs_..._secret_...", "ui_mode": "elements", "trial_days": 14, "first_charge_at": "..." }

When the subscription is recorded

Starting a checkout grants nothing. POST /v1/billing/checkout validates the tier (unknown ids, the retired free and the two contact-sales tiers are rejected with 400) and opens a Stripe session stamped with the tenant and tier. The subscription is recorded only from a session that reads status: "complete" and payment_status: "paid" (or no_payment_required, which is what a trial reads), confirmed either by Stripe's signature-verified checkout.session.completed event or by POST /v1/billing/checkout:confirm, which the console calls when Stripe returns the browser and which reads the session from Stripe itself. An abandoned, expired or unpaid session leaves the tenant exactly where it was.

Operators: STRIPE_WEBHOOK_SECRET is required, and the webhook endpoint must subscribe to checkout.session.completed, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, customer.subscription.trial_will_end, invoice.paid and invoice.payment_failed. Without them renewals, payment failures and cancellations never reach the workspace's state.

Operators: set STRIPE_PRODUCT_IDS (comma separated prod_... ids) on every deployment that makes promotion codes. The Stripe account may be shared with other products, and a coupon without a product list would discount all of them, so with it empty, making a promotion code in the operator console is refused (503) and creates nothing. The same ids decide which hand-made Stripe codes customers can redeem. To turn the in-page checkout off, set CHECKOUT_UI_MODE=embedded on the console and recreate its container (no rebuild); the console then asks for Stripe's own embedded checkout. Deploy the control plane before a console that sends ui_mode.

customer.subscription.* events apply Stripe's subscription (its status, trial end, period end, price and cancellation) to the workspace, and the state above follows from it.

On this page