> ## Documentation Index
> Fetch the complete documentation index at: https://snowglobe.so/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Manage Organizations and Billing

> Create organizations, invite and audit members, and set usage quotas with the Snowglobe Billing Management API.

These walkthroughs show how to use the Snowglobe Billing Management API
(`/management/*`) to manage organizations, members, and quotas. Every example is
plain Python 3 with [`requests`](https://requests.readthedocs.io/).

The tutorials cover the following operator flows:

* [Quick start: onboard a customer end-to-end](#quick-start-onboard-a-customer-end-to-end)

1. [Create an organization](#flow-1-create-an-organization)
2. [Add users to an organization](#flow-2-add-users-to-an-organization)
3. [Remove users from an organization](#flow-3-remove-users-from-an-organization)
4. [Set quotas for an organization](#flow-4-set-quotas-for-an-organization)
5. [Set up a billing hierarchy (parent and child orgs)](#flow-5-set-up-a-billing-hierarchy-parent-and-child-orgs)

The mutating endpoints are: create org, create invitations, and set/delete
quota. Reads (get org, list orgs, list invitations, billing reports) are
available to any member.

## Prerequisites

* A Snowglobe account with `billing_admin` access. [Sign up here](https://snowglobe.so/app).
* A Snowglobe API key (see [Setup](#setup-shared-client) below).
* Python 3 with `requests` installed (`pip install requests`).

## Setup: shared client

Every request is authenticated with an API key sent in the `x-api-key` header.
Generate a key from the Snowglobe web app (under your account/organization
settings) and export it as an environment variable. All examples below reuse this
one client.

```python theme={null}
import os
import requests

BASE_URL = os.environ.get("SNOWGLOBE_BASE_URL", "https://api.snowglobe.guardrailsai.com")
API_KEY  = os.environ["SNOWGLOBE_API_KEY"]  # generated in the Snowglobe web app

session = requests.Session()
session.headers.update({
    "x-api-key": API_KEY,
    "Content-Type": "application/json",
})


def request(method: str, path: str, **kwargs) -> requests.Response:
    """Thin wrapper that raises with the server's body on any 4xx/5xx."""
    resp = session.request(method, f"{BASE_URL}{path}", **kwargs)
    if not resp.ok:
        raise RuntimeError(
            f"{method} {path} -> {resp.status_code}\n{resp.text}"
        )
    return resp
```

### Roles

Most flows are role-gated. The four roles are `owner`, `admin`, `billing_admin`,
and `member`.

| Action                 | Required role of the caller                       |
| ---------------------- | ------------------------------------------------- |
| Create an organization | `billing_admin` (platform-level)                  |
| Create invitations     | `owner`, `admin`, or `billing_admin` in org       |
| List org / invitations | any role in the org                               |
| Set / delete quota     | `billing_admin` of that org                       |
| Read billing reports   | any role (child-org reports need `billing_admin`) |

A `403` almost always means your API key's roles don't satisfy the row above,
not that the org is missing (that returns `404`).

## Quick start: onboard a customer end-to-end

This routine chains the common onboarding path into one function: create the org,
invite the initial admins and members, set the quota, and verify the result. It
calls helpers defined in the flows below (`create_organization_full` in
[Flow 1](#flow-1-create-an-organization), `create_invitations` in
[Flow 2](#flow-2-add-users-to-an-organization), and `set_quota` /
`get_organization` in [Flow 4](#flow-4-set-quotas-for-an-organization)), so copy
those in alongside the [shared client](#setup-shared-client). Each flow below
explains one piece in depth.

```python theme={null}
def onboard_customer(
    display_name: str,
    name: str,
    admins: list[str],
    members: list[str],
    quota_conversations: int,
    billing_org: str | None = None,
) -> dict:
    """Create an org, invite its people, set its quota, and return a summary."""

    # 1. Create the org. The caller becomes billing_admin automatically. The
    #    admins listed here are invited with the admin role; like all members
    #    they must accept their invitation before they join.
    org = create_organization_full(
        display_name=display_name,
        name=name,
        members=[{"email": e, "roles": ["admin"]} for e in admins],
        billing_org=billing_org,
        quota={"amount": quota_conversations, "unit": "conversations"},
    )
    org_id = org["id"]
    print(f"[1/4] Created org {org_id} ({display_name})")

    # 2. Invite the member-tier users.
    if members:
        invites = create_invitations(
            org_id, [{"email": e, "roles": ["member"]} for e in members]
        )
        print(f"[2/4] Sent {len(invites)} member invitation(s)")
    else:
        print("[2/4] No member invitations to send")

    # 3. Quota was set at creation; confirm / re-assert it here so the routine
    #    is safe to re-run against an org whose quota drifted.
    quota = set_quota(org_id, amount=quota_conversations)
    print(f"[3/4] Quota set to {quota['amount']} {quota['unit']}")

    # 4. Verify final state.
    final = get_organization(org_id)
    summary = {
        "org_id": org_id,
        "members": [m["email"] for m in final["members"]],
        "pending_invitations": [i["invitee"] for i in final["pendingInvitations"]],
        "quota": final.get("quota"),
    }
    print(f"[4/4] Verified: {len(summary['members'])} member(s), "
          f"{len(summary['pending_invitations'])} pending, "
          f"quota={summary['quota']}")
    return summary


summary = onboard_customer(
    display_name="Globex Corp",
    name="globex",
    admins=["ceo@globex.example", "ops@globex.example"],
    members=["eng1@globex.example", "eng2@globex.example"],
    quota_conversations=75_000,
)
print(summary)
```

Notes:

* The caller is assigned `billing_admin` on the new org automatically, and is
  the only account added directly. Everyone else, whether passed in `members` at
  create-time or through the invitations endpoint, and regardless of role, must
  accept an invitation before they join. So the admins and members here all show
  up under `pending_invitations` until they accept.
* The caller must be a `billing_admin` for step 1 (create) and step 3 (quota).
  The same API key is fine for the invitations in step 2, since `billing_admin`
  satisfies that gate too.
* To onboard a customer under a billing parent, pass `billing_org=parent_id`
  (see [Flow 5](#flow-5-set-up-a-billing-hierarchy-parent-and-child-orgs)).

## Flow 1: Create an organization

**Endpoint:** `POST /management/organizations`
**Who:** the authenticated user must be a `billing_admin`.

### Minimal create

Only `display_name` is required.

```python theme={null}
def create_organization(display_name: str, name: str | None = None) -> dict:
    body = {"display_name": display_name}
    if name is not None:
        body["name"] = name
    return request("POST", "/management/organizations", json=body).json()


org = create_organization(display_name="Acme Robotics", name="acme-robotics")
print("Created org:", org["id"], "-", org["display_name"])
```

The `201` response is the full `Organization` object. The fields you'll most
often key off of afterwards:

```python theme={null}
org_id  = org["id"]                 # uuid; you pass this into every other flow
members = org["members"]            # list of {id, name, email, roles[]}
pending = org["pendingInvitations"] # list of compact invitations (empty on a fresh org)
quota   = org.get("quota")          # None unless you seeded one (see below)
usage   = org.get("usage")          # None until consumption is recorded
```

### Create with members, a billing parent, and an initial quota

You can seed people in the same call that creates the org. `members` here is a
list of `OrganizationMemberInput`, the same shape used to invite people in
Flow 2 (each needs `roles`, plus `id` or `email`).

The caller (the `billing_admin` making this request) is the only account added
to the org directly. Everyone listed in `members` receives an invitation and
must accept it before they appear in `org["members"]`, regardless of the role
they're assigned. In other words, passing someone in `members` is a shortcut for
inviting them at create-time, not a way to add them without acceptance.

```python theme={null}
def create_organization_full(
    display_name: str,
    name: str | None = None,
    members: list[dict] | None = None,
    billing_org: str | None = None,
    quota: dict | None = None,
) -> dict:
    body: dict = {"display_name": display_name}
    if name is not None:
        body["name"] = name
    if members is not None:
        body["members"] = members
    if billing_org is not None:
        body["billingOrg"] = billing_org
    if quota is not None:
        body["quota"] = quota
    return request("POST", "/management/organizations", json=body).json()


org = create_organization_full(
    display_name="Acme Robotics EU",
    name="acme-robotics-eu",
    members=[
        {"email": "ops-lead@acme.example", "roles": ["admin"]},
        {"email": "finance@acme.example",  "roles": ["billing_admin"]},
    ],
    billing_org="11111111-1111-1111-1111-111111111111",  # existing parent org id
    quota={"amount": 50_000, "unit": "conversations"},
)
print("Created:", org["id"], "| quota:", org["quota"])
```

`billingOrg` rules:

* The referenced org must already exist.
* It must not itself have a `billingOrg`. Nested billing hierarchies are
  rejected. A parent can have many children; a child cannot be a parent.
* Violating either rule returns `400`.

### Confirm it exists

```python theme={null}
def get_organization(org_id: str) -> dict:
    return request("GET", f"/management/organizations/{org_id}").json()


fetched = get_organization(org["id"])
assert fetched["id"] == org["id"]
```

`GET` on an org you have no role in returns `403`; a missing id returns `404`.

## Flow 2: Add users to an organization

**Endpoint:** `POST /management/organizations/{orgId}/invitations`
**Who:** the caller needs `owner`, `admin`, or `billing_admin` in that org.

### How adding a user works

There is no add-member-directly endpoint. You invite users, and they become
`members` once they accept. The lifecycle:

```text theme={null}
POST invitations   ->  invitee appears in pendingInvitations
                       (invitee follows invitation_url and accepts, out of band)
                   ->  member appears in org["members"], drops out of pendingInvitations
```

### Batch-invite

The request body is an array of `OrganizationMemberInput`. Each entry needs
`roles` and at least one of `email` or `id`. When only `id` is given, the server resolves the email via
the auth provider at send-time.

```python theme={null}
def create_invitations(org_id: str, invites: list[dict]) -> list[dict]:
    return request(
        "POST",
        f"/management/organizations/{org_id}/invitations",
        json=invites,
    ).json()


invitations = create_invitations(org_id, [
    {"email": "engineer@acme.example",  "roles": ["member"]},
    {"email": "manager@acme.example",   "roles": ["admin"]},
    {"id": "auth0|abc123",              "roles": ["member"]},  # resolved to email server-side
])

for inv in invitations:
    print(f"{inv['invitee']:30} roles={inv['roles']} expires={inv['expires_at']}")
```

The response is a list of full `OrganizationInvitation` objects, including the
`invitation_url` each invitee must follow to accept.

Common `400` causes: an entry missing `roles`, or an entry with neither `id` nor
`email`.

### List invitations to track who hasn't accepted

This endpoint returns only unaccepted invitations, so everything it returns is
still pending; no client-side filtering is needed. An invitation drops out of the
list once the invitee accepts.

```python theme={null}
def list_invitations(org_id: str) -> list[dict]:
    return request(
        "GET", f"/management/organizations/{org_id}/invitations"
    ).json()


outstanding = list_invitations(org_id)
print(f"{len(outstanding)} invitation(s) still pending acceptance")
for inv in outstanding:
    print(f"  - {inv['invitee']:30} roles={inv['roles']} expires={inv['expires_at']}")
```

### Verify the user landed as a member

After the invitee accepts, they move from `pendingInvitations` into `members`:

```python theme={null}
def find_member(org_id: str, email: str) -> dict | None:
    org = get_organization(org_id)
    return next((m for m in org["members"] if m["email"] == email), None)


member = find_member(org_id, "engineer@acme.example")
print("Joined." if member else "Still pending; invitation not yet accepted.")
```

## Flow 3: Remove users from an organization

Membership changes run through the invitation lifecycle, so the operator task
here is to audit who currently has access, both active members and outstanding
invitations, and confirm the resulting state.

```python theme={null}
def access_report(org_id: str) -> None:
    org = get_organization(org_id)

    print(f"Organization: {org['display_name']} ({org['id']})\n")

    print("Active members:")
    for m in org["members"]:
        print(f"  - {m['email']:30} roles={m['roles']}")

    print("\nPending invitations (not yet accepted):")
    for inv in org["pendingInvitations"]:
        print(f"  - {inv['invitee']:30} roles={inv['roles']} expires={inv['expires_at']}")


access_report(org_id)
```

You can filter that report to find a specific person before acting on them:

```python theme={null}
def find_access(org_id: str, email: str) -> dict:
    org = get_organization(org_id)
    return {
        "member": next((m for m in org["members"] if m["email"] == email), None),
        "pending_invitation": next(
            (inv for inv in org["pendingInvitations"] if inv["invitee"] == email),
            None,
        ),
    }


status = find_access(org_id, "engineer@acme.example")
print("Active member." if status["member"] else "Not an active member.")
print("Has pending invite." if status["pending_invitation"] else "No pending invite.")
```

## Flow 4: Set quotas for an organization

**Endpoints:**
`PUT /management/organizations/{orgId}/quota` (set/replace),
`DELETE /management/organizations/{orgId}/quota` (clear).
**Who:** the caller must be the `billing_admin` of that specific org.

### What a quota is

A quota is a single `DimensionedValue`:

```json theme={null}
{ "amount": 100000, "unit": "conversations" }
```

* `amount`: an integer cap that applies per billing period. Billing periods are
  monthly in practice, so the quota is effectively a monthly cap; `usage` resets
  each period.
* `unit`: a string. In practice it is always `"conversations"`, and the server
  defaults it to `"conversations"` when omitted, so you don't have to send it.
  Sending it explicitly makes the code more readable, and the field is left open
  for forward compatibility.

Quota is a replace, not an increment: each `PUT` overwrites the previous value
wholesale. There is no separate "add 10k" call at this layer.

### The kinds of quota-related values

The API models three distinct numeric concepts. Only the first is settable here;
the other two are read-only. Keep them separate so you don't confuse them with
the quota.

| Concept        | Where it lives                                 | Settable?       | Unit(s)                  | Meaning                                                                  |
| -------------- | ---------------------------------------------- | --------------- | ------------------------ | ------------------------------------------------------------------------ |
| **Quota**      | `org.quota`, `PUT/DELETE .../quota`            | Yes (this flow) | `conversations`          | The cap you set. `null` means no limit configured.                       |
| **Usage**      | `org.usage`, and per-period in billing reports | No              | `conversations`          | Consumption in the current billing period.                               |
| **Adjustment** | `billingReport.adjustments[]`                  | No              | `conversations` or `USD` | Manual credits/debits applied to a period's bill (with a `description`). |

When someone says "set the quota," they mean the first row: a `conversations` cap
via `PUT`. Usage and adjustments are surfaced for visibility (see
[reading them back](#read-quota-usage-and-billing-back) below) but are not
writable through this API.

### Set or replace a quota

```python theme={null}
def set_quota(org_id: str, amount: int, unit: str = "conversations") -> dict:
    return request(
        "PUT",
        f"/management/organizations/{org_id}/quota",
        json={"amount": amount, "unit": unit},
    ).json()


new_quota = set_quota(org_id, amount=100_000)
print("Quota is now:", new_quota)  # -> {'amount': 100000, 'unit': 'conversations'}
```

The `200` response echoes back the stored `DimensionedValue`. Raising or lowering
the cap later is the same call with a new `amount`.

### Remove a quota (back to unlimited/unconfigured)

```python theme={null}
def delete_quota(org_id: str) -> None:
    resp = request("DELETE", f"/management/organizations/{org_id}/quota")
    assert resp.status_code == 204  # No Content


delete_quota(org_id)
assert get_organization(org_id).get("quota") is None
print("Quota cleared.")
```

`DELETE` returns `204 No Content`, so there is no body to parse.

### Read quota, usage, and billing back

Quota and usage ride directly on the `Organization` object. Historical billing
(usage, adjustments, and final billed amount per period) comes from the
billing-reports endpoint.

```python theme={null}
def quota_status(org_id: str) -> None:
    org   = get_organization(org_id)
    quota = org.get("quota")
    usage = org.get("usage")

    if quota is None:
        print("No quota configured (unlimited).")
    else:
        used = usage["amount"] if usage else 0
        pct  = (used / quota["amount"] * 100) if quota["amount"] else 0
        print(f"Usage: {used}/{quota['amount']} {quota['unit']} ({pct:.1f}%)")


def billing_reports(org_id: str, start: str | None = None, end: str | None = None) -> list[dict]:
    # start / end are ISO-8601 date-times, both optional.
    params = {}
    if start:
        params["startDate"] = start
    if end:
        params["endDate"] = end
    return request(
        "GET",
        f"/management/organizations/{org_id}/billing-reports",
        params=params,
    ).json()


quota_status(org_id)

for report in billing_reports(org_id, start="2026-01-01T00:00:00Z"):
    print(f"\n{report['billingPeriod']}: "
          f"{report['usage']['amount']} {report['usage']['unit']} used "
          f"-> billed {report['billedAmount']['amount']} {report['billedAmount']['unit']}")
    for adj in report["adjustments"]:
        sign = "+" if adj["amount"] >= 0 else ""
        print(f"    adjustment {sign}{adj['amount']} {adj['unit']}: "
              f"{adj.get('description', '')}")
```

If the caller is a `billing_admin`, the billing-reports endpoint also returns
reports for any child orgs billed under this one (those whose `billingOrg` points
here). A non-billing-admin sees only the org's own reports.

## Flow 5: Set up a billing hierarchy (parent and child orgs)

Snowglobe supports a one-level billing hierarchy: a parent org that carries the
billing relationship, and any number of child orgs billed under it. This is the
reseller / multi-tenant pattern. Each customer team gets its own org, with its
own members and quota, but the bills roll up to a single parent.

### The nesting rule

`billingOrg` may point only at an org that does not itself have a `billingOrg`:

```text theme={null}
parent (billingOrg = null)
  |- child A (billingOrg = parent.id)   ok
  |- child B (billingOrg = parent.id)   ok

child A (billingOrg = parent.id)
  |- grandchild (billingOrg = childA.id)   rejected, 400: childA already has a billingOrg
```

A parent can have many children; a child can never be a parent. Attempting to
nest returns `400`.

### Build the hierarchy

```python theme={null}
# 1. Create the parent. It has no billingOrg of its own.
parent = create_organization_full(
    display_name="Acme Robotics Global",
    name="acme-global",
    members=[{"email": "finance@acme.example", "roles": ["billing_admin"]}],
)
parent_id = parent["id"]
print("Parent:", parent_id)

# 2. Create children pointing at the parent. Each can carry its own members
#    and its own quota independent of the parent.
children_spec = [
    {"display_name": "Acme EU",   "name": "acme-eu",   "quota": 50_000},
    {"display_name": "Acme US",   "name": "acme-us",   "quota": 120_000},
    {"display_name": "Acme APAC", "name": "acme-apac", "quota": 30_000},
]

children = []
for spec in children_spec:
    child = create_organization_full(
        display_name=spec["display_name"],
        name=spec["name"],
        billing_org=parent_id,
        quota={"amount": spec["quota"], "unit": "conversations"},
    )
    children.append(child)
    print(f"  child: {child['display_name']:20} {child['id']}  quota={child['quota']}")
```

`create_organization_full` is defined in [Flow 1](#create-with-members-a-billing-parent-and-an-initial-quota).

### Guard against the nesting error

If you're building the hierarchy from data you don't fully control, check the
prospective parent before you point at it. An org that already has a `billingOrg`
cannot serve as a parent:

```python theme={null}
def assert_can_be_billing_parent(org_id: str) -> None:
    org = get_organization(org_id)
    if org.get("billingOrg"):
        raise ValueError(
            f"{org['display_name']} ({org_id}) already bills under "
            f"{org['billingOrg']}, so it cannot be a billing parent."
        )


assert_can_be_billing_parent(parent_id)  # passes; parent has no billingOrg
```

### Read the whole hierarchy's billing at once

When the caller is a `billing_admin`, the parent's billing-reports endpoint
returns reports for the parent and every child billed under it, so a single call
gives you the consolidated view:

```python theme={null}
reports = billing_reports(parent_id, start="2026-01-01T00:00:00Z")

by_org: dict[str, int] = {}
for r in reports:
    by_org[r["orgId"]] = by_org.get(r["orgId"], 0) + r["billedAmount"]["amount"]

print("Consolidated billed amount by org:")
for oid, total in by_org.items():
    print(f"  {oid}: {total}")
```

`billing_reports` is defined in [Flow 4](#read-quota-usage-and-billing-back).
Setting or clearing a quota is always per-org (there is no inherited quota), so
use [Flow 4](#flow-4-set-quotas-for-an-organization) against each child id to
adjust its cap.

## Appendix: status codes

| Code  | Meaning in this API                                                               |
| ----- | --------------------------------------------------------------------------------- |
| `200` | Read succeeded, or quota `PUT` succeeded.                                         |
| `201` | Organization created.                                                             |
| `204` | Quota deleted (no body).                                                          |
| `400` | Bad payload, e.g. bad `billingOrg`, or invite entry missing `roles`/`id`+`email`. |
| `403` | Your API key's roles don't satisfy the action (see the roles table up top).       |
| `404` | Organization not found.                                                           |

The `request()` helper at the top raises `RuntimeError` with the server's body on
any 4xx/5xx code, so failures surface the actual reason rather than a bare status
number.
