# The API: how S12 is built

What S12 built (plan CORE §3.10; handbook §12, new in the family; TuteCampus's tokens page made to work, 24 October
2026) and how a module publishes its own endpoints. The rules: **the API is the app's operations, adapted** (an action
or a query, a Form Request, a policy keyed by permission, a `JsonResource`), and **a token is a membership's**: its
workspace the membership's (handbook 1.2), never more than its maker may do.

## The pieces

| Piece | What it does |
|---|---|
| `routes/api.php` | `/v1` (`apiPrefix`). The `api` group: `auth:sanctum`, `ResolveTokenTenant`, `SetLocale`, `throttle:api`, then bindings inside the workspace |
| `personal_access_token` / `PersonalAccessToken` | Sanctum's, singular: `tokenable` (the holder: the account that knows its secret, always an account, a check says so; not a copy of the membership's account, so it stays, plan SN D4), `membership_id` (its scope; `ResolveTokenTenant` refuses a holder who no longer reaches it), `tenant_id` (tied to the membership's, plan SN slice C), `abilities` (permission keys, as given when minted), `expires_at`, a `public_id` for pages. Platform data: Sanctum finds it by its hash before a workspace is open |
| `MintToken` | `make` (abilities: workspace keys the membership holds now, never `*`; expires after `api.token_days`; a 48-character secret with no id in it, kept as its SHA-256; audited `token.created`, sign-in log `token_created`), `revoke`, `held` |
| `ResolveTokenTenant` | The call's workspace is the token's membership's, which must be the account's, switched on, its workspace not paused; else 401 |
| `AccessServiceProvider` | Through a token, a permission needs the token's ability too (`Gate::before`); `viewApiDocs` for the document |
| `api` limiter | `Limiters::ALL['api']` ⇒ `api.per_minute`, counted per token |
| `Idempotent` (`idempotent`) | With an `Idempotency-Key`, the key is reserved before the request runs (a request that dies frees it after `RUNNING_MINUTES`); a successful answer kept 24 hours under the token and the key and replayed (`Idempotent-Replayed: true`); the same request while the first runs ⇒ 409 with `Retry-After`; the same key for another request ⇒ 422. `idempotency_key` (`IdempotencyKey`, scoped); stale ones go with the night's purge (`StaleIdempotencyKeys`) |
| `MeResource`, `PersonResource` | The outward shapes: public ids only, times ISO 8601 UTC (`…Z`) |
| `Problem` (S0) | Every API error as problem+json |
| Scramble | `/docs/api` (the UI), `/docs/api.json` (OpenAPI 3.1), `/v1` on the API host only, bearer auth, for `viewApiDocs`: whoever may make tokens, and the Service |

## Published

| Endpoint | Permission | Answers |
|---|---|---|
| `GET /v1/me` | a token | the account, the workspace, the membership's public id, the token's name, abilities it may use now, expiry |
| `GET /v1/person` | `person.view` | the workspace's people (`PersonResource`), newest first, `q`, `page`, `per_page` (≤100), Laravel's paginator |

## Pages

| Page | Route | Permission |
|---|---|---|
| Your API tokens | `/me/token`, `POST` (step-up), `DELETE /me/token/{token}` (step-up) | `token.manage` (Admin) |
| The API's document | `/docs/api` | `viewApiDocs` |

## Settings

| Key | Levels | Bounds | Shipped |
|---|---|---|---|
| `api.per_minute` | platform | 10–6,000 calls a minute | 120 |
| `api.token_days` | platform | 1–365 days | 90 |

## Publishing an endpoint

1. The operation exists as an action or a query (the page uses it too); its input a Form Request (`authorize` by
   permission); its shape a `JsonResource` (public ids, `…Z` times, no secret) added to `ResourceGuardTest`'s renderers.
2. The route in `routes/api.php`; a consequential POST takes `->middleware('idempotent')`.
3. A request test in `tests/Feature/Api` naming its path (`ApiGuardTest` fails without one); the document picks it up.

## The guards

| Test | Proves |
|---|---|
| `Guards/ApiGuardTest` | the done-when: every published endpoint named in a request test; the document lists exactly them, with bearer auth, no bigint id; the document's gate |
| `Guards/ResourceGuardTest` | every `JsonResource` rendered: no `id`, no `…_id` number, `…_at` as ISO 8601 UTC, no secret; every resource has a renderer |
| `Tenancy/TenantSweepTest` (API) | the done-when: a token of one workspace reads its people only, whatever the account's current workspace or the query says |
| `Api/ApiTest` | the two endpoints; ability and permission both; wrong, expired, turned-off tokens; no session; the limiter per token; the logs |
| `Api/TokenPageTest` | making (step-up, once, hashed, no id, within one's permissions, never `*`), revoking one's own only, the page for `token.manage` only |
| `Api/IdempotencyTest` | once, replayed, refused for another request, forgotten after 24 hours |
| `Api/ProblemJsonTest` (S0) | the error shape |

## Outbound webhooks (plan AF slice C3; handbook 12.9)

The mail engine's event catalogue is the only list of events. An event offers itself by what `webhook()` returns: public IDs and plain states, never a
person's details or words, or a one-time secret. `EventCatalogue::webhooks()` lists those that do; today that is `tenant.status_changed`, `entitlement.granted`, `entitlement.changed`, `links.turned_off`, `vouchers.batch_ready`, `vouchers.changed`, `message.changed`, `notify.batch_finished`, `notify.credit_bought`, `notify.credit_low`, `notify.credit_out`, `notify.email_finished`, `notify.sender_changed`, `membership.ended`, `account.signed_out`, `account.merged`, `account.changed` and `domain.changed`. A receipt such as `message.changed` or `notify.batch_finished` names one server (`webhookServer()`), and only that server hears it (`notify.md`); so does `account.signed_out`, raised once for each server whose product the session signed into (`accounts.md`).

| Piece | What it does |
|---|---|
| `Webhooks::add` | A new address (`webhook_endpoint`, platform data), https only in production, with the events it hears (`webhook_subscription`). Its secret is encrypted, and returned this once |
| `Webhooks::raise` | Called by `Notify::event`, apart from the mail: one `webhook_delivery` row and one `SendWebhook` job per address hearing the event, with the data frozen |
| `SendWebhook` | The `default` pool. Signed, no redirects followed, 15 seconds to answer. Any 2xx is delivered. Otherwise 8 tries over about a day (1, 5, 15 minutes; 1, 3, 6, 12 hours), then it shows among the failed jobs |
| `Signature` | `Webhook-Signature: t=<unix time>,v1=<hex HMAC-SHA256 of "<t>.<body>">`, refused by `verify` if wrong or over 5 minutes old |
| `OldWebhookDeliveries` | Finished delivery records go after `WebhookDelivery::KEEP_DAYS` (90), with the night's purge |

What a receiver gets, as `application/json`, with `Webhook-Event` and `Webhook-Delivery` headers:

```json
{"id":"01jc…","event":"tenant.status_changed","occurred_at":"2026-11-06T12:00:00Z","organisation":"01jb…","data":{"status":"suspended"}}
```

A receiver can see the same `id` twice, when it took the delivery but answered too late, so it treats a repeat as already done. No page lists addresses yet;
each belongs to a server (slice A4), which hears only of the organisations holding its product, and an event about no organisation. A6's console issues them.

## A server's call (plan AF slice A4, AF-1, AF-2)

Atrium's main callers are other apps. A product's server calls `/v1` on the API host, through Legatus, the client library (`legatus.md`):

```bash
curl https://api.tutelora.com/v1/ping -H "Authorization: Bearer atr_…" -H "X-Organisation: org_…"
```

`AuthenticateServer` (`server:<module>`) checks, failing closed at each: the key (live, its server on; else 401); the module is in its scopes, and the address in its allowed ones (else 403); and that the
organisation exists, is active and has a live `instance` of this server's product through an active `entitlement` (else 403, the same answer for each, so nobody can probe for who's a client). Only then does it set `CurrentTenant`, so
the fail-closed scope and job carrying work as they do for people. A missing header is a 422.

| Piece | What it does |
|---|---|
| `server` (`srv_…`) | The software that calls. It belongs to a product, or has none for an outside client; one with no product is refused any organisation until that has its own rule (plan TE) |
| `server_key` (`key_…`), `ServerKeys` | `issue`, `rotate`, `revoke`. The secret (`atr_…`) is returned once and kept as its SHA-256. At most two live per server, so a key rotates with no gap |
| `Modules`, `config/modules.php` | The module IDs are code; which are on is per install (`MODULES_ENABLED`). A module's `app/Modules/<Id>/routes.php` goes under `/v1` |
| `server` limiter | `api.per_minute`, counted per server, whichever key it used |
| `Idempotent` | Held by the server where a server called, so a retry after a rotation still gets the first answer |

## Usage and server status (plan AF slice A5)

| Piece | What it does |
|---|---|
| `Usage` | `record(module, metric, quantity, reference)`, by the server and organisation of the call. `AuthenticateServer` counts each call as `call`, and a good key's refused call as `refused` with no organisation. A reference means a retry can't count twice. A fault in counting never fails the call |
| `usage_event` | Append-only, purged after `log.usage_event_days` |
| `usage_limit` | A month's limit per organisation, module and metric. Stored and shown; Nuntius is first to enforce one |
| `POST /v1/report` | What a server says about itself: CPU, memory and disk in percent (each may be left out where its host hides it, plan LG, LG-11; the console shows a dash), uptime, queue, release, and up to six figures from its own dashboard. `server:any,itself`: any good key, naming no organisation, and not usage. Kept a week. Legatus sends it every five minutes (`legatus.md`) |
| `servers:check` | Every minute: asks each server's `https://<host>/up`, and notes whether it answered, how fast, and since when it hasn't |
| `ServerStatus` | Red when its address isn't answering; grey when it hasn't reported in 10 minutes, or ever; amber when its disk is 90% full; otherwise live |
| `GET /v1/job/{job}` | Long work's progress (`BackgroundJob`, `job_…`), for the server that started it, in its organisation. Finished ones go after 30 days |

## Accounts for products (plan CL, slice CL8)

The `accounts` module, for a server holding its scope, about the organisation it names (`X-Organisation`):

| Endpoint | What it does |
|---|---|
| `POST /v1/account/person` | Adds someone to the organisation, naming the admin who acted (`acting`: an `acc_…` of one of its members; CL-9). Invited by their phone or e-mail; or, with `username` and `password`, an account the organisation sets up for an adult with neither (CL-4; no children's accounts, CL-13). Answers the membership, the account only when it set one up with a username, and `invited` true whenever a phone or e-mail was given, the same whether it already belongs to an account or not (plan SEC, SEC4 F2). An existing account it matches stays closed to the product (its details, recheck and changes) until the person takes the place. The product keeps their role (CL-16). Idempotent |
| `GET /v1/account/{account}` | The account's own name and the phones and e-mails it shares (CL-17), and its membership here. Only an account belonging to this organisation is answered |
| `GET /v1/account/{account}/check?sid=&since=` | The 15-minute recheck (CL-8): `belongs`, `active`, and whether that session ended (`signed_out`: the `sid` signed out, or signed out everywhere since `since`). Corrected 9 November 2026 (plan LG): this page said `ended_session` |

Webhooks tell products sooner: `account.signed_out` (by `sid`, or `everywhere`), `membership.ended`, `account.merged`
(old and new IDs), `account.changed` (read the details again) and `domain.changed`. A product's own Service account signs
in at the product, never through `id` (CL-14).

## What an organisation holds (plan TB, slice TB1)

The `subscription` module, for a server holding its scope, about the organisation it names (`X-Organisation`), on the server's own product:

| Endpoint | What it does |
|---|---|
| `GET /v1/entitlement` | What the organisation holds on this product (with `as_of`, the moment Atrium read it, to the microsecond, so a product keeps the newer of two views; plan LG): the offering's code, its state, `ends_on`, whether it's `complimentary`, the day an organisation charged from today must choose a plan by (`choose_by`), while a period goes unpaid the day its plan is kept until (`grace_until`, TB3; keep serving the plan until told otherwise), its `limits` (code to amount; `null` is no limit) and `features` (codes), and the counts last reported (`used`). Complimentary means all of it: every feature and no limit (TB-10, TB-13) |
| `PUT /v1/entitlement/usage` | The counts the product keeps against its own limits, `{"used": {"members": 112}}`, so the organisation sees "112 of 150" at `id`. Whole numbers; a code that isn't one of the product's limits is refused; the last count wins. Answers the view, as above |

The same view goes by webhook as `entitlement.changed`, to the product's own servers only, when an offering's prices or allowances
change, an organisation's own billing changes (TB-16), or a term rolls on (TB-8); and `entitlement.granted` now carries it whole.
A product keeps the last view it had while Atrium is away, and enforces it itself (the failsafe).
