# White label — agencies and accounting firms

Run many client businesses from one Orbit account. A **firm** owns a roster of
**client tenants**, each with its own completely separate books; firm staff are
granted membership on the clients they work on. Branding (name, logo, support
addresses, custom domain) is set per tenant.

Base URL: `https://api.accountingorbit.com/api/v1`
Auth: `Authorization: Bearer ao_...` — see [/docs/auth.md](/docs/auth.md).
Errors and pagination: [/docs/conventions.md](/docs/conventions.md).

---

## Can an agency provision a client programmatically? Yes.

`POST /firm/clients` creates a brand-new client tenant with its own chart of
accounts and attaches it to your firm, in one call. This is a real API, not a
UI-only flow — the example below was executed against a running backend.

What is **not** available over the API in v1:

- **Adding staff by API creates no user.** `POST /firm/members` attaches an
  **existing** user account by email. There is no endpoint that creates a user
  for someone who has never signed up; they must register first (or be invited
  through the collaboration flow, which sends them an email).
- **Custom branding is plan-gated.** Setting `logo_url`, `custom_domain`,
  `support_email` or `support_url` on a tenant without the white-label
  entitlement returns `402`.
- **There is no firm-level branding API.** Branding is per tenant, so branding a
  client means switching into that tenant and writing its settings.

---

## The object model

### Firm

Resolved automatically from your active tenant — there is no "create firm"
call outside signup. Fields you can read:

| Field | Notes |
|---|---|
| `client_slots` | How many client tenants the plan allows |
| `staff_seats` | How many staff members |
| `plan_tier` | `free` \| `pro` \| `scale` |
| `trial_slots` | Client slots that can be opened on trial |

### Firm client

| Field | Type | Notes |
|---|---|---|
| `id` / `client_tenant_id` | integer | The client's tenant id — the value you pass everywhere else |
| `name` | string | |
| `base_currency` | string | |
| `fiscal_year_start_month` | integer | 1–12 |
| `plan` | string | The client's own plan |
| `status` | string | `active` for a live client |
| `role` | string | Your role on that client tenant |
| `created_at` | timestamp | |

### Brand config (per tenant)

| Field | Type |
|---|---|
| `company_name` | string |
| `logo_url` | string \| null |
| `favicon_url` | string \| null |
| `primary_color` | string \| null |
| `support_email` | string \| null |
| `support_url` | string \| null |
| `domain` | string \| null — the verified custom-domain hostname |
| `email_identity_name` | string |
| `email_identity_domain` | string |
| `legal_url` | string \| null |

---

## Provisioning a client

### `POST /firm/clients`

```bash
curl -X POST https://api.accountingorbit.com/api/v1/firm/clients \
  -H "Authorization: Bearer $ORBIT_KEY" -H "Content-Type: application/json" \
  -d '{"name":"Bright Bakery LLC","base_currency":"USD",
       "fiscal_year_start_month":1,"source":"partner-portal"}'
```

```json
{
  "tenant_id": 458,
  "name": "Bright Bakery LLC",
  "base_currency": "USD",
  "fiscal_year_start_month": 1,
  "status": "active"
}
```

Body fields: `name` (**required**), `base_currency` (default `"USD"`),
`fiscal_year_start_month` (default `1`), `source` (free-form attribution
string), `use_trial` (boolean — consume a trial slot instead of a paid one).

Empty `name` → `400 "name is required"`. Out of client slots → `409` with the
service's message.

`tenant_id` is the handle for everything that follows.

### `GET /firm/clients`

```bash
curl https://api.accountingorbit.com/api/v1/firm/clients \
  -H "Authorization: Bearer $ORBIT_KEY"
```

```json
{
  "clients": [
    {
      "id": 458,
      "client_tenant_id": 458,
      "name": "Bright Bakery LLC",
      "base_currency": "USD",
      "fiscal_year_start_month": 1,
      "plan": "free",
      "status": "active",
      "role": "admin",
      "created_at": "2026-08-11 00:25:33.627776"
    }
  ]
}
```

Soft-deleted clients are excluded. Note the response is an object with a
`clients` key, not a bare array.

### `GET /firm/client/{tenant_id}/summary`

```json
{
  "tenant_id": 458,
  "name": "Bright Bakery LLC",
  "entry_count": 0,
  "revenue_this_month": 0.0,
  "member_count": 1,
  "last_activity": null,
  "base_currency": "USD"
}
```

Calling it for a tenant you are not a member of → `403 "not a member of tenant N"`.
A tenant id that does not exist → `404 "tenant not found"`.

### `DELETE /firm/clients/{tenant_id}`

```json
{"message": "client detached"}
```

A **soft** detach — the client's books remain intact and the tenant continues to
exist. Nothing is destroyed. Unknown client → `404 "client not found"`.

---

## Seats and usage

### `GET /firm/usage`

```json
{"clients_used": 0, "client_slots": 50, "staff_used": 0, "staff_seats": 10}
```

Check this **before** provisioning in a loop — `POST /firm/clients` returns
`409` when you are out of slots.

### `POST /firm/seats`

```bash
curl -X POST https://api.accountingorbit.com/api/v1/firm/seats \
  -H "Authorization: Bearer $ORBIT_KEY" -H "Content-Type: application/json" \
  -d '{"seats":75,"staff_seats":15}'
```

```json
{"client_slots": 75, "staff_seats": 15}
```

`seats` is the client-slot limit; `staff_seats` is the staff limit. Either
below `1` → `400`.

### `GET /firm/billing`

Full billing profile for the firm — plan tier, base price, per-client price,
trial slots and current usage.

---

## Staff

| Endpoint | Body / params | Notes |
|---|---|---|
| `GET /firm/members` | — | The firm's staff list |
| `POST /firm/members` | `{"email": "...", "role": "accountant"}` | Attaches an **existing** user. Unknown email → `404`. Already a member → `409`. |
| `DELETE /firm/members/{user_id}` | — | `404 "membership not found"` if not a member |

```json
{"user_id": 88, "email": "staff@firm.test", "role": "accountant"}
```

To bring in someone who has no Orbit account, use the collaboration invite flow
instead (next section) — it emails them a token.

---

## Per-entity access and permissions

Firm membership is separate from **tenant** membership. Access to a specific
client's books is a `UserTenant` row, granted per tenant with a role.

| Endpoint | Body | Purpose |
|---|---|---|
| `GET /permissions` | — | The full role → resource → action permission grid |
| `GET /collaborators` | — | Who has access to the current tenant |
| `POST /collaboration/invite` | `{email, role?, permissions?, reason?}` | Invite someone to the **current** tenant (`role` defaults to `accountant`) |
| `POST /collaboration/invite-temporary` | `{email, reason, role?, permission_scope?, expiry_days?}` | Time-boxed access; `expiry_days` defaults to `30`. `reason` is **required**. |
| `GET /collaboration/invites` | — | Pending invites |
| `DELETE /collaboration/invites/{invite_id}` | — | Revoke a pending invite |
| `POST /collaboration/accept` | `{token}` | Accept an invite |
| `POST /collaboration/promote/{user_id}` | `{role}` | Change someone's role |
| `POST /collaboration/remove/{user_id}` | — | Revoke access |
| `GET /settings/access/{user_id}` / `POST /settings/access/{user_id}` | — | Per-user module access overrides |

Invites are **per tenant**, and every invite endpoint acts on the tenant your
credential is currently scoped to. To invite a bookkeeper to one client only,
switch to that client tenant first (see below), then invite.

### Segregation of duties

Firms can enforce four-eyes rules per action:

| Endpoint | Purpose |
|---|---|
| `GET /settings/duty-policies` | List configured policies |
| `PUT /settings/duty-policies` | Create or update one |
| `DELETE /settings/duty-policies/{action}` | Remove one |

Known action names in use: `pay_invoice`, `pay_bill`, `approve_entry`,
`reconcile`. When a policy is active, the same user cannot both originate and
approve/pay/reconcile the item — see [/docs/bills.md](/docs/bills.md) and
[/docs/invoices.md](/docs/invoices.md).

---

## Working inside a client's books

Two ways to act on a client tenant:

1. **Switch the session.** `GET /auth/tenants` lists the tenants your user
   belongs to; `POST /auth/switch-tenant` re-scopes your credential. Every
   endpoint then operates on that client. This is the general answer and it
   works for the whole API.
2. **Pass `tenant_id` where it is supported.** Only a small number of read
   endpoints accept it — notably
   `GET /statements/balance-sheet?as_of=&tenant_id=` and
   `GET /statements/income-statement?start=&end=&tenant_id=`, and
   `GET /firm/client/{tenant_id}/summary`. Everything else uses your credential's
   active tenant. Do not assume `?tenant_id=` works on an endpoint that does not
   document it — it will be silently ignored, and you will read the wrong books.

Consolidated reporting across entities lives under `/consolidation/*` and is
outside the scope of this page.

---

## Branding a tenant

Branding lives in tenant settings, which are plain key/value pairs.

### `GET /settings/brand`

```bash
curl https://api.accountingorbit.com/api/v1/settings/brand \
  -H "Authorization: Bearer $ORBIT_KEY"
```

```json
{
  "company_name": "Accounting Orbit",
  "logo_url": null,
  "favicon_url": null,
  "primary_color": null,
  "support_email": "support@example.com",
  "support_url": null,
  "domain": null,
  "email_identity_name": "Accounting Orbit",
  "email_identity_domain": "example.com",
  "legal_url": null
}
```

### `PATCH /settings`

```bash
curl -X PATCH https://api.accountingorbit.com/api/v1/settings \
  -H "Authorization: Bearer $ORBIT_KEY" -H "Content-Type: application/json" \
  -d '{"settings":{"company_name":"Bright Bakery LLC",
                   "logo_url":"https://cdn.example.com/bakery.png",
                   "support_email":"help@brightbakery.test"}}'
```

```json
{"updated": 3}
```

The body is always `{"settings": {key: value}}` with **string values**.

**Plan gate.** These four keys require a white-label entitlement:
`custom_domain`, `logo_url`, `support_email`, `support_url`. Without it:

```json
{"detail": "Custom branding requires a Pro plan — upgrade in Billing."}
```
→ `402`. `company_name` is deliberately **not** gated — it is basic company
setup and works on every plan.

`timezone` must be a valid IANA name (`GET /settings/timezones` lists them);
a typo returns `422` rather than silently reverting the tenant to UTC and
shifting every posting date.

Other branding endpoints: `POST /settings/brand/logo` (multipart logo upload),
`GET /settings` (all settings as a flat key/value dict).

### Custom domains

| Endpoint | Purpose |
|---|---|
| `GET /settings/domains` | List registered domains |
| `POST /settings/domains` | Register a hostname |
| `POST /settings/domains/{domain_id}/verify` | Verify ownership |
| `DELETE /settings/domains/{domain_id}` | Remove |

Once a domain is verified, `GET /public/brand?hostname=...` resolves that
tenant's branding **unauthenticated**, for the pre-login screen. It returns only
public branding fields, never anything private.

---

## Agency signup

`POST /agency/signup` creates a firm, its tenant and its first admin user in one
unauthenticated call:

```json
{"name":"Ledger Partners LLC","email":"owner@ledgerpartners.test",
 "password":"<min 8 chars>","plan_tier":"free","source":"partner-referral"}
```

`plan_tier` is `free` \| `pro` \| `scale`; anything else → `400`.

Free tier activates instantly:

```json
{"status": "active", "firm_id": 12, "plan_tier": "free",
 "trial_slots": 3, "client_slots": 50, "email": "owner@ledgerpartners.test"}
```

Paid tiers return a Stripe Checkout URL and complete via webhook:

```json
{"status": "checkout", "checkout_url": "https://checkout.stripe.com/...", "firm_id": 12}
```

Email already registered → `409 "email already registered"`. Stripe not
configured for that tier → `503`.

This endpoint takes a plaintext password, so it is **not** an API-key route —
it is the signup form's backend. Provision *clients* with `POST /firm/clients`,
not with this.

Related agency endpoints: `GET /agency/tiers`, `POST /agency/tiers`,
`PATCH /agency/tiers/{id}`, `DELETE /agency/tiers/{id}` (your own client-facing
price tiers); `GET /agency/trials`, `POST /agency/trials/purchase`;
`POST /agency/billing/report-usage`; `GET /agency/marketing/roi`,
`POST /agency/marketing/spend`.

---

## Failure cases specific to firms

| Status | Body | Cause |
|---|---|---|
| 400 | `{"detail": "name is required"}` | `POST /firm/clients` with a blank name |
| 400 | `{"detail": "seats must be >= 1"}` / `"staff_seats must be >= 1"` | Bad seat count |
| 400 | `{"detail": "plan_tier must be free, pro, or scale"}` | Bad signup tier |
| 402 | `{"detail": "Custom branding requires a Pro plan — upgrade in Billing."}` | White-label settings key on an ineligible plan |
| 403 | `{"detail": "not a member of tenant N"}` | Client summary for a tenant you have no membership on |
| 404 | `{"detail": "tenant not found"}` / `"client not found"` / `"membership not found"` | Unknown id, or not yours |
| 409 | `{"detail": "email already registered"}` | Agency signup with a taken email |
| 409 | (service message) | Out of client slots or staff seats; member already attached |
| 422 | `{"detail": "'<x>' is not a valid IANA timezone name. Use a value from GET /settings/timezones."}` | Bad timezone |
| 503 | `{"detail": "Stripe price not configured for pro tier"}` | Paid signup on a server without Stripe prices |

Cross-tenant reads are refused, not filtered: a request for another firm's
client comes back `403`/`404`, never an empty list you might mistake for
"no data".

---

## Typical integration sequence

An agency's system onboarding a new client, end to end:

1. `GET /firm/usage` — confirm `clients_used < client_slots`. If not,
   `POST /firm/seats` to raise the limit (or buy trial slots with
   `POST /agency/trials/purchase`).
2. `POST /firm/clients` with `{"name": "<client business name>",
   "base_currency": "USD", "fiscal_year_start_month": 1}`.
   Keep `tenant_id` from the response. The client's chart of accounts already
   exists.
3. `POST /auth/switch-tenant` to scope your credential to that `tenant_id`
   (confirm with `GET /auth/tenants`). Everything below happens inside the
   client's books.
4. Brand it: `PATCH /settings` with `{"settings": {"company_name": "..."}}`.
   Add `logo_url` / `support_email` only if the plan allows — otherwise expect
   `402`.
5. Give people access: `POST /collaboration/invite`
   `{"email": "owner@client.test", "role": "admin"}` for the client, and
   `POST /collaboration/invite-temporary` with a `reason` and `expiry_days` for
   time-boxed contractors. Staff who already have Orbit accounts can instead be
   attached at firm level with `POST /firm/members`.
6. Optionally enforce four-eyes: `PUT /settings/duty-policies` for
   `pay_bill` / `approve_entry`.
7. Load the books — [/docs/bank-imports.md](/docs/bank-imports.md),
   [/docs/receipts.md](/docs/receipts.md),
   [/docs/invoices.md](/docs/invoices.md), [/docs/bills.md](/docs/bills.md).
8. Switch back to the firm tenant and monitor the roster with
   `GET /firm/clients` and `GET /firm/client/{tenant_id}/summary`.

When a client needs an external CPA to sign off, see
[/docs/review.md](/docs/review.md).
