Skip to content

Limits and usage

Every organization is unlimited until the operator says otherwise. Limits are set in the environment for all organizations, or in the database for one, and never by an organization’s own admins: they can read them, in Settings, Usage, next to what they use.

Limit Counts Checked when
storage Bytes of assets across the organization’s projects, and of the renditions its people made of them; deleted ones stop counting at once An upload is started, and again for its real size when it is filed
editors People with write or admin anywhere in it, and invitations that would make more Access is given, or an invitation made
projects Projects One is made
brands Brands, across its projects; a project’s default brand counts once it holds rules, pages or a release One is made
domains Custom domains, the app’s and its portals’, verified or not One is added
emails Emails a day: invitations, share links and tests. Sign-up codes, password resets and portal requests to its own admins never count. Counted in each process’s memory One would be sent: an invitation still gives its link to copy, sending a link or a test answers 429
features What it may use: agents (keys and connecting agents), shares (share and upload links), sso (the organization’s own single sign-on), branding (its own name and look), domains (custom domains), rebuilds (a site’s deploy hook called on each brand release), automations (automations), fixes (Fixes and Notify), templates (share cards and Office templates; BrandHub’s own card and press kit stay), digest (the weekly digest). Unset: everything A key, an agent connection or a link is made; single sign-on or branding is set up; a domain is added; a rebuild hook is set, and a release calls none; an automation is made, changed or tested, and none fires; Fixes is read or a notice sent; a card or a template is asked for; the digest is sent
readOnly Everyone there can look, nobody can change anything; its admins can still manage people and settings Every request
siteReview A built site that looks like phishing or malware (a page asking for a password, a card or a recovery phrase; a program to download) waits for you instead of going live. For organizations nobody vouches for yet, such as a free tier with open sign-up: set LIMIT_SITE_REVIEW, and "siteReview": false in a plan’s row, which would otherwise take the server’s. See Held for review A build is deployed
suspended A reason, or true: nothing it serves reaches the public, and it is read-only. Only in the database, see Suspending an organization Every request

Past a limit, the request is refused with 403 and the code limit_reached (or read_only), naming the limit. Nothing already there is taken away: lowering a limit only stops what would go further.

When the limits come with a plan, set BILLING_URL to where its admins manage it: Settings, Usage shows a “Manage plan” link there, a refusal names it, and GET /api/v1/usage returns it as billing. An organization with no limits row of its own is on the server’s limits, with no plan yet: its admins also see an “Upgrade” button in the app’s header, and GET /api/v1/me returns the address as upgrade. A Settings section whose feature is off (Branding, Domains, Single sign-on) then leads there, marked with a lock, and is left out of Settings when there is nowhere to go. Once someone has paid, send them back to /settings/organization/usage?billing=ok: the page reads the limits row fresh, past the minute’s cache, and says the plan is active.

Terminal window
LIMIT_STORAGE=10GB # or 500MB, 1.5TB, or a number of bytes
LIMIT_EDITORS=5
LIMIT_PROJECTS=3
LIMIT_BRANDS=5
LIMIT_DOMAINS=2
LIMIT_EMAILS=50 # a day
LIMIT_FEATURES=agents # comma-separated (agents, shares, sso, branding, domains, rebuilds, automations, fixes, templates, digest), or none
LIMIT_SITE_REVIEW=true # hold built sites that look like phishing for you to review

Each new organization gets the server’s limits, so with open sign-up anyone could make several to have them several times. LIMIT_ORGANIZATIONS caps that per person:

Terminal window
LIMIT_ORGANIZATIONS=1

Making an organization (POST /api/v1/organizations) is refused with limit_reached when the person is already admin of that many organizations on the server’s limits: ones with no limits row of their own, so no plan. An organization with its own row does not count, unless the row says "lapsing": true, and the one sign-up makes is never refused. Admin counts however it was given, invitations included.

A row in settings, with the key limits. What it names overrides the environment for that organization; what it leaves out keeps coming from there.

insert into settings (organization_id, key, value, updated_by)
values (
(select id from organizations where slug = 'acme'),
'limits',
'{"storage": "100GB", "editors": 20, "features": ["agents", "shares"]}',
'operator'
)
on conflict (organization_id, project_id, key)
do update set value = excluded.value, updated_by = excluded.updated_by, updated_at = now();

null for a limit lifts it, "readOnly": true freezes the organization, and deleting the row puts it back on the environment’s limits. updated_by names whoever wrote the row last, so a billing integration that writes these rows can leave alone the ones it did not write (an operator’s deal), and stop owning a row an operator wrote over. "lapsing": true keeps the row’s limits but stops it counting as a plan when someone makes another organization (LIMIT_ORGANIZATIONS): for a plan whose payment failed or was paused, while that is sorted out. Each server process reads settings at most once a minute, so a change made in the database applies within a minute; Settings, Usage reads them fresh.

For abuse (phishing, malware, content you must take down), write "suspended" into its limits row, with a reason:

insert into settings (organization_id, key, value, updated_by)
values (
(select id from organizations where slug = 'acme'),
'limits',
'{"suspended": "Phishing, report of 2026-10-05"}',
'operator'
)
on conflict (organization_id, project_id, key)
do update set value = settings.value || excluded.value, updated_at = now();

settings.value || excluded.value keeps what the row already held (a plan’s limits) and adds the flag, and updated_by stays: a billing integration that wrote the row still owns it, and should keep suspended whenever it rewrites or removes the row for a plan. While it is set, within a minute on every server process:

  • its portals, at every address ({slug}.PORTAL_DOMAIN, /p/{slug}, its own domains), its share and upload links, and its BrandHub listings answer 451 (Unavailable For Legal Reasons) with a plain page, or the error code suspended from the API, that names nobody, with Cache-Control: no-store. BrandHub leaves its brands out of every list;
  • its files at /a/{id} and /c/{id} answer 451 to everyone outside it, public or signed alike;
  • its people keep the app, read-only, as with readOnly; their API keys and agents still read. Settings, Usage shows its admins that it is suspended, and the reason: write one they may read.

Nobody in the organization can set or clear it: limits are never written through the API. To lift it, remove the key, and the row if nothing else is left in it (an empty row still counts as a plan of its own):

update settings set value = value - 'suspended', updated_at = now()
where key = 'limits' and project_id is null
and organization_id = (select id from organizations where slug = 'acme');
delete from settings
where key = 'limits' and project_id is null and value = '{}'::jsonb
and organization_id = (select id from organizations where slug = 'acme');

A cache in front of the server (a CDN) may still hold public files it kept before the suspension, for up to their max-age (an hour at most): purge it too.

Next to its limits, a row with the key notice puts a line across the top of the app for the organization’s admins, with a link to act on, until until (an ISO date, or null to keep it) or until the row is deleted. A plan that ends, a payment that failed:

insert into settings (organization_id, key, value, updated_by)
values (
(select id from organizations where slug = 'acme'),
'notice',
'{"text": "Your Team plan ends on October 30, 2026. After that the organization is back on Free.", "href": "https://billing.example/plan", "until": "2026-10-30T12:00:00Z"}',
'operator'
)
on conflict (organization_id, project_id, key)
do update set value = excluded.value, updated_by = excluded.updated_by, updated_at = now();

GET /api/v1/me returns it as notice for admins, null once it has passed. Whoever closes it in the app closes it for that tab only.

GET /api/v1/usage (Settings, Usage, for organization admins) returns the limits, what is used against each, and 30 days of delivery traffic: requests and bytes that asset URLs served, originals, renditions and downloads, per project and per day.