# meibo API
*Version 1.0.0*

Reads and changes an owner's meibo school and club directories, through an
agent the owner hands a key to.

**A key is the owner's, for every directory they have, and is shown
once.** The owner makes it on their account page and chooses its scopes
there. Only a hash of it is stored, so a lost key is revoked and replaced,
never recovered. A key acts as the owner and follows the owner's rules.

**Name the directory with `?directory=`.** Every operation takes the
directory's slug, the last part of its address, as `?directory=<slug>`.
Left out, a key reaches the owner's one directory; when the owner has more
than one, it gets `400` with `reason: directory_required` and their slugs
in `directories`. A directory the key does not reach, another owner's or
one that is not there, gets `404` with `reason: directory_not_reached`.
A key reaches a directory only while its owner owns it: handed to someone
else, it is out of the previous owner's keys' reach. A key made before keys
reached every directory reaches only the one it was made for, needs no
`?directory=`, and gets `401` once that directory changes hands.

**`entries:read` is off unless the owner grants it.** The dashboard shows
this beside it:

> Reads every entry in full: parents' names, emails, phones and home addresses, and children's names and teachers. What it reads goes to the assistant's AI provider, under your control. Photos are never shared.

A key without it can still add, edit, publish and delete entries. What
those writes answer is an entry's `id` and `isPublished`, and nothing of
what is in it.

**Photos are never served, and a key cannot send one.** An entry carries
`hasPhoto` wherever the dashboard shows a photo: on the entry, and on each
parent and child. A body that carries `photoKey` anywhere, even as `null`,
is refused `400` with `reason: photos`. Editing an entry keeps the photos it
already has.

**Every call is logged.** Each call a key is authenticated for is recorded
against the directory it reached, with its method, route and status, and
the owner can read that log for 90 days. That includes refusals. A refusal
before any directory is reached is not logged: a `401`, and the `400` and
`404` about `?directory=`.

**A suspended owner's key keeps reading and withdrawing.** Reads,
unpublishing an entry and deleting one are served. Every other write gets
`403` with `reason: suspended`.

**So does the key of a frozen directory.** An account may own as many
directories as its plan allows. When it owns more, after a downgrade or a
lapsed subscription, its oldest directories are served in full and the
rest are frozen until the owner upgrades or deletes one. A frozen
directory's key is served reads, unpublishes and deletes, and every other
write gets `403` with `reason: frozen`, the owner's `plan` and its
directory `limit`.

**Rate limits.** The edge allows 300 requests per 5 minutes from one IP
address, and 600 requests per 5 minutes with one key, counted across
everything on this host. Past either limit, a request gets `429` with a
`WafBlock` body and never reaches meibo. Invites have their own cap: at
most 20 addresses per request, and 200 invites per directory in any 24
hours, shared with the invites the owner sends from the dashboard.

**Answers.** A success is `{ "success": true, "data": … }`. A refusal is a
bare `Refusal`. Its `error` is a sentence to show a person, and its
`reason`, when it has one, is a word to branch on.

**If something here did not work, tell us.** A call refused that this
reference says should succeed, a response that does not match its schema,
or a step that is missing or wrong: email support@fennec.studio with the
method and path, the status and the `error` and `reason` you got, and what
you expected. Leave out the key and anything an entry holds: names,
emails, phones and addresses stay in the directory. An agent that cannot
send email should give the owner the address.

## Servers

- `https://api.meibo.io` — Production

## Authentication

### `directoryKey`

- **Type:** http (bearer)

The key, sent as `Authorization: Bearer <key>`. The `Bearer ` prefix may
be left off. A key that is missing, revoked or expired gets `401`, and
so does a key made for one directory, before keys reached every
directory, once that directory has changed hands.

A key holds some of these scopes, chosen by the owner when it is made.
Each operation's `security` names the scopes it needs, and an operation
that names none is open to any key. A key without a scope the operation
needs is refused `403` with `reason: scope`, and `scope` names it.

- `directory`: change the directory's name, preset, groups and logo.
- `entries:write`: add entries and edit them.
- `moderation`: read the moderation queue, publish and unpublish
  entries, dismiss reports, and delete entries.
- `invites`: email families a directory's address, within its invite
  cap. It lets nobody in.
- `entries:read`: read entries in full, contacts and children's names
  included. Off unless the owner grants it.

## Endpoints

- [`GET /api/v1/directory`](#get-api-v1-directory) — Read the directory's settings
- [`PUT /api/v1/directory`](#put-api-v1-directory) — Change the directory's name, preset, groups or logo
- [`GET /api/v1/directory/entries`](#get-api-v1-directory-entries) — List or search the entries
- [`POST /api/v1/directory/entries`](#post-api-v1-directory-entries) — Add an entry
- [`GET /api/v1/directory/entries/{id}`](#get-api-v1-directory-entries-id) — Read one entry
- [`PUT /api/v1/directory/entries/{id}`](#put-api-v1-directory-entries-id) — Edit, publish, unpublish, or dismiss reports
- [`DELETE /api/v1/directory/entries/{id}`](#delete-api-v1-directory-entries-id) — Delete an entry
- [`POST /api/v1/directory/invites`](#post-api-v1-directory-invites) — Invite families by email
- [`GET /api/v1/directory/moderation`](#get-api-v1-directory-moderation) — List the moderation queue

### `GET /api/v1/directory`

**Read the directory's settings**

Open to any key, whatever its scopes, and served while the owner is suspended.

**Operation ID:** `readSettings`
**Security:** `directoryKey`

**Responses:**

- **200** — The settings.

  *settings:* `{"success":true,"data":{"name":"Maple Elementary","slug":"maple","preset":"school","groups":[{"id":"66f0c1a2b3c4d5e6f7a8b9c0","name":"Ms. Rivera","aliases":["Room 4"],"leaders":[12]},{"id":"66f0c1a2b3c4d5e6f7a8b9c1","name":"Mr. Chen","aliases":[],"leaders":[]}],"logoUrl":"https://public.fennec.studio/storefronts/media/directory-logo-1.png","scopes":["directory","entries:write","moderation"]}}`
- **400** → [`DirectoryRequired`](#directoryrequired)
- **401** → [`Unauthorized`](#unauthorized)
- **404** — The key does not reach the directory `?directory=` names (`reason: directory_not_reached`), or its owner no longer owns it. → [`Refusal`](#refusal)

  *notReached:* `undefined`

  *noDirectory:* `{"error":"This account does not own a directory."}`
- **429** → [`WafRateLimited`](#wafratelimited)

---

### `PUT /api/v1/directory`

**Change the directory's name, preset, groups or logo**

Changes the fields the body sends, and answers the settings as they are now. The slug, both access rules, pre-screening and the email list stay dashboard-only.

**Operation ID:** `updateSettings`
**Security:** `directoryKey`

**Request body (required):**

```yaml
$ref: "#/components/schemas/SettingsUpdate"
```

*Example — rename:*

```json
{
  "name": "Maple Elementary School",
  "groups": [
    {
      "id": "66f0c1a2b3c4d5e6f7a8b9c0",
      "name": "Ms. Rivera",
      "aliases": [
        "Room 4"
      ],
      "leaders": [
        12
      ]
    },
    {
      "name": "Mr. Chen"
    }
  ]
}
```

**Responses:**

- **200** — The settings, as saved.

  *settings:* `{"success":true,"data":{"name":"Maple Elementary School","slug":"maple","preset":"school","groups":[{"id":"66f0c1a2b3c4d5e6f7a8b9c0","name":"Ms. Rivera","aliases":["Room 4"],"leaders":[12]},{"id":"66f0c1a2b3c4d5e6f7a8b9c1","name":"Mr. Chen","aliases":[],"leaders":[]}],"logoUrl":null,"scopes":["directory"]}}`
- **400** — A field a key may not change (`reason: dashboard-only`, with `field`), a body that is not a JSON object, or a field that fails its check (with `field`). Or the key reaches more than one directory and the call named none (`reason: directory_required`). → [`Refusal`](#refusal)

  *directoryRequired:* `undefined`

  *dashboardOnly:* `{"error":"An API key can change only name, preset, groups and logo.","reason":"dashboard-only","field":"slug"}`

  *badField:* `{"error":"Enter a name.","field":"name"}`
- **401** → [`Unauthorized`](#unauthorized)
- **403** — The key lacks `directory` (`reason: scope`), the owner is suspended (`reason: suspended`), or the directory is frozen (`reason: frozen`). → [`Refusal`](#refusal)

  *scope:* `{"error":"This API key does not have the directory scope.","reason":"scope","scope":"directory"}`

  *suspended:* `undefined`

  *frozen:* `undefined`
- **404** — The key does not reach the directory `?directory=` names (`reason: directory_not_reached`), or its owner no longer owns it. → [`Refusal`](#refusal)

  *notReached:* `undefined`

  *noDirectory:* `{"error":"This account does not own a directory."}`
- **409** — A `preset` whose people are in one group each (`school`, `team`, `other`), sent while some people are in more than one group. `field` is `preset`, and `error` says how many. Nothing is saved. → [`Refusal`](#refusal)

  *severalGroups:* `{"error":"2 people are in more than one production. Leave each of them in one before switching.","field":"preset"}`
- **413** — The logo, or the whole body, is too large. `field` is `logo` when it is the logo. → [`Refusal`](#refusal)

  *logo:* `{"error":"Logos must be 512 KB or smaller.","field":"logo"}`
- **429** → [`WafRateLimited`](#wafratelimited)

---

### `GET /api/v1/directory/entries`

**List or search the entries**

One page of entries by name, 48 to a page, unpublished entries included. Served while the owner is suspended.

**Operation ID:** `listEntries`
**Security:** `directoryKey`

**Parameters:**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `q` | query | string | no | Words to search for, ignoring case. An entry matches when every word appears in its name, a parent's name or email, a child's name, teacher, or group's name or other names, a staff member's title, or an individual's title, or group's name or other names. A group renamed since an entry was last saved is found by its old name until the entry is saved again. Blank lists every entry. Only the first 8 words and first 100 characters are searched. |
| `undefined` | undefined |  | no |  |

**Responses:**

- **200** — One page of entries.

  *page:* `{"success":true,"data":{"entries":[{"id":11,"type":"family","name":"The Okafors","isPublished":true,"hasPhoto":true,"parents":[{"id":"p1","name":"Ada","email":"ada@example.com","phoneNumber":"555-0100","address":null,"hasPhoto":true}],"children":[{"id":"c1","name":"Tobi","teacher":"Ms. Rivera","groups":["66f0c1a2b3c4d5e6f7a8b9c0"],"hasPhoto":false}]},{"id":12,"type":"staff","name":"Ms. Rivera","isPublished":false,"hasPhoto":false,"title":"Year 2 teacher","email":null,"phoneNumber":null,"description":null,"links":[{"name":"Class page","url":"https://school.example/year-2"}]}],"page":1,"totalPages":1,"totalEntries":2}}`
- **400** → [`DirectoryRequired`](#directoryrequired)
- **401** → [`Unauthorized`](#unauthorized)
- **403** — The key lacks `entries:read` (`reason: scope`). → [`Refusal`](#refusal)

  *scope:* `{"error":"This API key does not have the entries:read scope.","reason":"scope","scope":"entries:read"}`
- **404** → [`DirectoryNotReached`](#directorynotreached)
- **429** → [`WafRateLimited`](#wafratelimited)

---

### `POST /api/v1/directory/entries`

**Add an entry**

Adds a family, a staff member or an individual. It is published at once, unless the directory approves entries first, or already publishes as many as its plan allows. Either way it is saved, and `isPublished` says which.

**Operation ID:** `createEntry`
**Security:** `directoryKey`

**Request body (required):**

```yaml
$ref: "#/components/schemas/EntryInput"
```

*Example — family:*

```json
{
  "type": "family",
  "name": "The Parks",
  "parents": [
    {
      "name": "Min-jun Park",
      "email": "minjun@example.com",
      "phoneNumber": "555-0142"
    }
  ],
  "children": [
    {
      "name": "Seo-yeon",
      "teacher": "Mr. Chen"
    }
  ]
}
```

**Responses:**

- **201** — Saved.

  *created:* `{"success":true,"data":{"id":91,"isPublished":false}}`
- **400** — The body carries `photoKey` (`reason: photos`), or fails a check. Or the key reaches more than one directory and the call named none (`reason: directory_required`). → [`Refusal`](#refusal)

  *directoryRequired:* `undefined`

  *photos:* `undefined`

  *badBody:* `{"error":"Choose Family or Staff."}`
- **401** → [`Unauthorized`](#unauthorized)
- **403** — The key lacks `entries:write` (`reason: scope`), the owner is suspended (`reason: suspended`), or the directory is frozen (`reason: frozen`). → [`Refusal`](#refusal)

  *scope:* `{"error":"This API key does not have the entries:write scope.","reason":"scope","scope":"entries:write"}`

  *suspended:* `undefined`

  *frozen:* `undefined`
- **404** → [`DirectoryNotReached`](#directorynotreached)
- **429** → [`WafRateLimited`](#wafratelimited)

---

### `GET /api/v1/directory/entries/{id}`

**Read one entry**

Served while the owner is suspended.

**Operation ID:** `getEntry`
**Security:** `directoryKey`

**Responses:**

- **200** — The entry.

  *entry:* `{"success":true,"data":{"entry":{"id":11,"type":"family","name":"The Okafors","isPublished":true,"hasPhoto":true,"parents":[{"id":"p1","name":"Ada","email":"ada@example.com","phoneNumber":null,"address":null,"hasPhoto":true}],"children":[{"id":"c1","name":"Tobi","teacher":"Ms. Rivera","groups":["66f0c1a2b3c4d5e6f7a8b9c0"],"hasPhoto":false}]},"canEdit":true,"canDelete":true,"canModerate":true}}`
- **400** → [`DirectoryRequired`](#directoryrequired)
- **401** → [`Unauthorized`](#unauthorized)
- **403** — The key lacks `entries:read` (`reason: scope`). → [`Refusal`](#refusal)

  *scope:* `{"error":"This API key does not have the entries:read scope.","reason":"scope","scope":"entries:read"}`
- **404** → [`EntryNotFound`](#entrynotfound)
- **429** → [`WafRateLimited`](#wafratelimited)

---

### `PUT /api/v1/directory/entries/{id}`

**Edit, publish, unpublish, or dismiss reports**

The scopes needed depend on the body (see `EntryUpdate`): `entries:write` for an edit, `moderation` for `isPublished` or `dismissReports`, and both for both. A body with no fields, or one that is not an object, is treated as an edit. While the owner is suspended, only an unpublish is served: `isPublished: false`, with no edit and no `dismissReports: true`.

**Operation ID:** `updateEntry`
**Security:** `directoryKey, directoryKey, directoryKey`

**Request body (required):**

```yaml
$ref: "#/components/schemas/EntryUpdate"
```

*Example — publish:*

```json
{
  "isPublished": true,
  "dismissReports": true
}
```

*Example — edit:*

```json
{
  "type": "family",
  "name": "The Okafors",
  "parents": [
    {
      "id": "p1",
      "name": "Ada",
      "email": "ada@example.com",
      "phoneNumber": "555-0100"
    }
  ]
}
```

**Responses:**

- **200** — Saved.

  *saved:* `{"success":true,"data":{"id":11,"isPublished":true}}`
- **400** — The body carries `photoKey` (`reason: photos`), `isPublished` or `dismissReports` is not a boolean, or an edit fails a check. Or the key reaches more than one directory and the call named none (`reason: directory_required`). → [`Refusal`](#refusal)

  *directoryRequired:* `undefined`

  *photos:* `undefined`

  *notBoolean:* `{"error":"isPublished must be true or false."}`
- **401** → [`Unauthorized`](#unauthorized)
- **403** — The key lacks a scope the body needs (`reason: scope`), the owner is suspended (`reason: suspended`), or the directory is frozen (`reason: frozen`). → [`Refusal`](#refusal)

  *scope:* `{"error":"This API key does not have the entries:write scope.","reason":"scope","scope":"entries:write"}`

  *suspended:* `undefined`

  *frozen:* `undefined`
- **404** → [`EntryNotFound`](#entrynotfound)
- **409** — Publishing would take the directory past its plan's entry limit (`reason: entry_limit`, with `limit`). An edit sent with it has been saved, and the message says so. The reports are left as they were. → [`Refusal`](#refusal)

  *entryLimit:* `{"error":"This directory already publishes 25 entries, the most its plan allows. Unpublish another entry or upgrade the plan to publish this one.","reason":"entry_limit","limit":25}`
- **429** → [`WafRateLimited`](#wafratelimited)

---

### `DELETE /api/v1/directory/entries/{id}`

**Delete an entry**

Served while the owner is suspended.

**Operation ID:** `deleteEntry`
**Security:** `directoryKey`

**Responses:**

- **200** — Deleted.

  *deleted:* `{"success":true,"data":{"id":11}}`
- **400** → [`DirectoryRequired`](#directoryrequired)
- **401** → [`Unauthorized`](#unauthorized)
- **403** — The key lacks `moderation` (`reason: scope`). → [`Refusal`](#refusal)

  *scope:* `{"error":"This API key does not have the moderation scope.","reason":"scope","scope":"moderation"}`
- **404** → [`EntryNotFound`](#entrynotfound)
- **429** → [`WafRateLimited`](#wafratelimited)

---

### `POST /api/v1/directory/invites`

**Invite families by email**

Emails each address an invite to the directory, from the owner's storefront. It lets nobody in: whoever signs in with that address still has to get in another way, by a link, the email list or the directory being open, and an access request stays waiting for the owner. At most 20 addresses per request. The directory may send 200 invites in any 24 hours, and the invites the owner sends from the dashboard count too. Each invite stops counting 24 hours after it was sent. Every refusal but a `500` naming `sent` means nothing was emailed.

**Operation ID:** `sendInvites`
**Security:** `directoryKey`

**Request body (required):**

```yaml
$ref: "#/components/schemas/InviteRequest"
```

*Example — two:*

```json
{
  "emails": "ana@example.com, ben@example.com"
}
```

**Responses:**

- **200** — Every address was emailed.

  *sent:* `{"success":true,"data":{"sent":["ana@example.com","ben@example.com"]}}`
- **400** — `emails` is missing or not a string, names no address or too many, or names something that is not an email address (listed in `invalid`). Or the key reaches more than one directory and the call named none (`reason: directory_required`). → [`Refusal`](#refusal)

  *directoryRequired:* `undefined`

  *invalid:* `{"error":"Some of these are not email addresses.","invalid":["ana"]}`
- **401** → [`Unauthorized`](#unauthorized)
- **403** — The key lacks `invites` (`reason: scope`), the owner is suspended (`reason: suspended`), or the directory is frozen (`reason: frozen`). → [`Refusal`](#refusal)

  *scope:* `{"error":"This API key does not have the invites scope.","reason":"scope","scope":"invites"}`

  *suspended:* `undefined`

  *frozen:* `undefined`
- **404** — The key does not reach the directory `?directory=` names (`reason: directory_not_reached`), or the directory, or the storefront its invites are sent from, is gone. → [`Refusal`](#refusal)

  *notReached:* `undefined`

  *gone:* `{"error":"Directory not found"}`
- **409** — Some addresses belong to people the owner removed or declined, listed in `removed`. Take them out to invite the others. → [`Refusal`](#refusal)

  *removed:* `{"error":"You removed or declined these people, so they cannot be invited: ben@example.com. Take them out of the list to invite the others. Nothing was emailed.","removed":["ben@example.com"]}`
- **429** — Two conditions share this status. The directory's invite cap, a `Refusal` whose `remaining` says how many more it may send now. Or the edge's rate limit, a `WafBlock`, which never reached meibo.

  *cap:* `{"error":"This directory can send 3 more invites now, of at most 200 in any 24 hours. Nothing was emailed. Send 3 or fewer addresses.","remaining":3}`

  *waf:* `undefined`
- **500** — Nothing could be sent, or some addresses were emailed and others were not. In the second case `sent` and `failed` say which. → [`Refusal`](#refusal)

  *partial:* `{"error":"Some invites could not be sent.","sent":["ana@example.com"],"failed":["ben@example.com"]}`

---

### `GET /api/v1/directory/moderation`

**List the moderation queue**

One page of the entries waiting for approval, the entries with reports, and the entries published after pre-screening in the last 7 days, by name, 48 to a page. An entry published after pre-screening is published already, and is listed so the owner can see it and unpublish it. What each carries depends on the key: the whole entry with `entries:read`, and a `ModerationSummary` without it. Served while the owner is suspended.

**Operation ID:** `listModeration`
**Security:** `directoryKey`

**Parameters:**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `undefined` | undefined |  | no |  |

**Responses:**

- **200** — One page of the queue.

  *A key that also holds entries:read:* `{"success":true,"data":{"entries":[{"id":12,"type":"staff","name":"Ms. Rivera","isPublished":false,"hasPhoto":true,"title":"Year 2 teacher","email":null,"phoneNumber":null,"description":null,"links":[],"flags":[],"aiReview":{"action":"hold","category":"off-topic","confidence":0.4,"model":"gemini-2.5-flash","reviewedAt":"2026-09-25T02:00:00.000Z"}}],"page":1,"totalPages":1,"totalEntries":1}}`

  *A key without entries:read:* `{"success":true,"data":{"entries":[{"id":11,"name":"The Okafors","isPublished":true,"flags":[{"reason":"Not a family at this school","reportedAt":"2026-09-25T01:00:00.000Z"}],"aiReview":null}],"page":1,"totalPages":1,"totalEntries":1}}`
- **400** → [`DirectoryRequired`](#directoryrequired)
- **401** → [`Unauthorized`](#unauthorized)
- **403** — The key lacks `moderation` (`reason: scope`). → [`Refusal`](#refusal)

  *scope:* `{"error":"This API key does not have the moderation scope.","reason":"scope","scope":"moderation"}`
- **404** → [`DirectoryNotReached`](#directorynotreached)
- **429** → [`WafRateLimited`](#wafratelimited)

---

## Common responses

### `Unauthorized`

No key, or a key that is revoked, expired or no longer its directory owner's. Not logged.

### `WafRateLimited`

The edge's rate limit, per IP address or per key. The request never reached meibo. Back off and retry.

### `EntryNotFound`

No entry with that id in this directory. An id that is not a positive whole number is answered the same way. Or the key does not reach the directory `?directory=` names (`reason: directory_not_reached`).

### `DirectoryRequired`

The key reaches more than one directory and the call named none. `directories` lists their slugs: send one as `?directory=`.

### `DirectoryNotReached`

The key does not reach the directory `?directory=` names: another owner's, or one that is not there.

## Schemas

### `SuccessEnvelope`

```yaml
type: object
required:
  - success
  - data
properties:
  success:
    type: boolean
    const: true
  data: {}
description: Every success, `201` included. `data` is the operation's answer.
```

### `Refusal`

```yaml
type: object
required:
  - error
properties:
  error:
    type: string
    description: A sentence to show a person. Its wording can change; branch on `reason`.
  reason:
    type: string
    description: |
      A word to branch on, where the refusal has one. This is not an enum.
      Layers this API shares with the dashboard pass their own reasons
      through, so expect values that are not listed here. Known values:

      - `scope`: the key lacks the scope named in `scope`.
      - `dashboard-only`: a settings body names a field only the dashboard
        may change. `field` names it.
      - `photos`: the body carries `photoKey`.
      - `suspended`: the owner's account is suspended, and this is a write
        other than an unpublish or a delete.
      - `frozen`: the directory is past the number its owner's plan
        allows, and this is a write other than an unpublish or a delete.
        `plan` and `limit` say which plan and how many.
      - `entry_limit`: publishing would take the directory past its plan's
        entry limit. `limit` is that limit.
      - `directory_required`: the key reaches more than one directory
        and the call named none. `directories` lists their slugs.
      - `directory_not_reached`: the key does not reach the directory
        `?directory=` names.
  scope:
    $ref: "#/components/schemas/Scope"
    description: With `reason` `scope`, the scope the key lacks.
  field:
    type: string
    description: >
      A settings body's field. With `reason` `dashboard-only`, the first field a
      key may not change. Otherwise, the field that failed its check: `name`,
      `preset`, `groups` or `logo`.
  limit:
    type: integer
    description: >
      With `reason` `entry_limit`, how many published entries the plan allows.
      With `reason` `frozen`, how many directories it allows.
  plan:
    type: string
    description: >
      With `reason` `frozen`, the owner's plan: `free`, `small` or `large`.
      Custom allows any number, so it never freezes one.
  directories:
    type: array
    items:
      type: string
    description: >
      With `reason` `directory_required`, the slugs of the directories the key
      reaches, oldest first. Send one as `?directory=`.
  invalid:
    type: array
    items:
      type: string
    description: Invites only. The addresses that are not email addresses.
  removed:
    type: array
    items:
      type: string
    description: Invites only. The addresses of people the owner removed or declined.
  remaining:
    type: integer
    minimum: 0
    description: Invites only. How many more invites the directory may send now.
  sent:
    type: array
    items:
      type: string
    description: Invites only. The addresses that were emailed before others failed.
  failed:
    type: array
    items:
      type: string
    description: Invites only. The addresses that could not be emailed.
description: >
  Every refusal meibo itself answers. `error` is always there. Everything else
  depends on the refusal, and each operation's responses say which fields its
  refusals carry.
```

### `WafBlock`

```yaml
type: object
required:
  - error
  - message
properties:
  error:
    type: string
    const: Too Many Requests
  message:
    type: string
    description: A retry hint to show a person.
description: >
  What the edge answers when it rate-limits a request. The request never reached
  meibo, so this is not a `Refusal`: `error` is a fixed status label and the
  sentence is in `message`. Back off and retry.
```

### `Scope`

```yaml
type: string
enum:
  - directory
  - entries:write
  - moderation
  - invites
  - entries:read
description: A scope a key can hold. See the `directoryKey` scheme for what each allows.
```

### `Settings`

```yaml
type: object
required:
  - name
  - slug
  - preset
  - groups
  - logoUrl
  - scopes
properties:
  name:
    type: string
  slug:
    type: string
    description: The directory's slug. Only its manage page, `/manage/<slug>/`, changes it.
  preset:
    $ref: "#/components/schemas/Preset"
  groups:
    type: array
    items:
      $ref: "#/components/schemas/Group"
    description: >
      The directory's groups: its classes, teams or productions, as its preset
      names them. A child joins one by its `id`.
  logoUrl:
    type:
      - string
      - "null"
    format: uri
    description: The logo's public URL, or null when there is none.
  scopes:
    type: array
    items:
      $ref: "#/components/schemas/Scope"
    description: The scopes of the key that asked.
description: >
  The directory's settings, as a key sees them. The two access rules, the email
  list and billing are the dashboard's, and are not here.
```

### `Preset`

```yaml
type: string
enum:
  - school
  - team
  - adultTeam
  - theater
  - study
  - club
  - other
description: >
  What the directory is for. It sets the words its pages use, whether an entry
  is a family or one person, and how many groups each person can be in: one
  under `school`, `team`, `adultTeam` and `other`, any number under `theater`,
  `study` and `club`.
```

### `Group`

```yaml
type: object
required:
  - id
  - name
  - aliases
  - leaders
properties:
  id:
    type: string
    description: Send it back in an update to keep this group, and the children in it.
  name:
    type:
      - string
      - "null"
    description: The group's name. Null for a group known by its leader.
  aliases:
    type: array
    items:
      type: string
    description: Other names for the group, such as a room number.
  leaders:
    type: array
    items:
      type: integer
    description: >
      Entry ids of the staff entries that lead the group. Being a leader grants
      nothing.
```

### `SettingsUpdate`

```yaml
type: object
additionalProperties: false
properties:
  name:
    type: string
    minLength: 1
    maxLength: 100
    description: The directory's name, trimmed.
  preset:
    $ref: "#/components/schemas/Preset"
  groups:
    type: array
    maxItems: 100
    items:
      $ref: "#/components/schemas/GroupInput"
    description: >
      Replaces the whole list. A group sent with an `id` keeps that group and
      the children in it, and the `id` must be one of this directory's; a group
      sent without one is new. Each group needs a name or a leader, no two may
      share a name (ignoring case), and each leader must be a staff entry of
      this directory. Text is trimmed, blank aliases are dropped, and a row with
      nothing in it is dropped.
  logo:
    type: string
    description: >
      A new logo: the image's bytes in base64, optionally as a `data:` URL. PNG,
      JPEG or WebP, whatever the declared type says, 512 KB or smaller and at
      most 2048 pixels on each side. SVG is refused. There is no way to remove a
      logo here, and `null` is refused.
description: >
  Only `name`, `preset`, `groups` and `logo`. Any other field is refused `400`
  with `reason: dashboard-only` before anything is saved. A field left out keeps
  its value, so `{}` changes nothing and answers the settings.
```

### `GroupInput`

```yaml
type: object
properties:
  id:
    type: string
    minLength: 1
  name:
    type:
      - string
      - "null"
    maxLength: 80
  aliases:
    type: array
    maxItems: 10
    items:
      type: string
      maxLength: 80
  leaders:
    type: array
    items:
      type: integer
      minimum: 1
```

### `Entry`

```yaml
description: >
  A family, a staff member or an individual, as the owner sees it. Photos are
  never sent: `hasPhoto` says whether there is one.
oneOf:
  - $ref: "#/components/schemas/FamilyEntry"
  - $ref: "#/components/schemas/StaffEntry"
  - $ref: "#/components/schemas/IndividualEntry"
discriminator:
  propertyName: type
  mapping:
    family: "#/components/schemas/FamilyEntry"
    staff: "#/components/schemas/StaffEntry"
    individual: "#/components/schemas/IndividualEntry"
```

### `FamilyEntry`

```yaml
type: object
required:
  - id
  - type
  - name
  - isPublished
  - hasPhoto
  - parents
  - children
properties:
  id:
    type: integer
  type:
    type: string
    const: family
  name:
    type: string
  isPublished:
    type: boolean
    description: False while the entry waits for approval, or after it is unpublished.
  hasPhoto:
    type: boolean
  parents:
    type: array
    items:
      $ref: "#/components/schemas/Parent"
  children:
    type: array
    items:
      $ref: "#/components/schemas/Child"
```

### `Parent`

```yaml
type: object
required:
  - id
  - name
  - email
  - phoneNumber
  - address
  - hasPhoto
properties:
  id:
    type:
      - string
      - "null"
    description: Send it back in an edit to keep this row, and its photo.
  name:
    type: string
  email:
    type:
      - string
      - "null"
  phoneNumber:
    type:
      - string
      - "null"
  address:
    type:
      - string
      - "null"
  hasPhoto:
    type: boolean
```

### `Child`

```yaml
type: object
required:
  - id
  - name
  - teacher
  - groups
  - hasPhoto
properties:
  id:
    type:
      - string
      - "null"
    description: Send it back in an edit to keep this row, and its photo.
  name:
    type: string
  teacher:
    type:
      - string
      - "null"
    description: >
      A teacher none of the settings' `groups` names, as it was sent; else the
      name of the child's first group, or its first leader's name when it has
      none.
  groups:
    type: array
    items:
      type: string
    description: Ids of the settings' `groups` this child is in.
  hasPhoto:
    type: boolean
```

### `StaffEntry`

```yaml
type: object
required:
  - id
  - type
  - name
  - isPublished
  - hasPhoto
  - title
  - email
  - phoneNumber
  - description
  - links
properties:
  id:
    type: integer
  type:
    type: string
    const: staff
  name:
    type: string
  isPublished:
    type: boolean
    description: False while the entry waits for approval, or after it is unpublished.
  hasPhoto:
    type: boolean
  title:
    type:
      - string
      - "null"
  email:
    type:
      - string
      - "null"
  phoneNumber:
    type:
      - string
      - "null"
  description:
    type:
      - string
      - "null"
  links:
    type: array
    items:
      $ref: "#/components/schemas/Link"
```

### `IndividualEntry`

```yaml
type: object
description: >
  One person, as the `adultTeam` preset lists its players and the `theater`,
  `study`, `club` and `other` presets their members: a staff member's fields,
  and the groups they are in.
required:
  - id
  - type
  - name
  - isPublished
  - hasPhoto
  - title
  - email
  - phoneNumber
  - description
  - links
  - groups
properties:
  id:
    type: integer
  type:
    type: string
    const: individual
  name:
    type: string
  isPublished:
    type: boolean
    description: False while the entry waits for approval, or after it is unpublished.
  hasPhoto:
    type: boolean
  title:
    type:
      - string
      - "null"
  email:
    type:
      - string
      - "null"
  phoneNumber:
    type:
      - string
      - "null"
  description:
    type:
      - string
      - "null"
  links:
    type: array
    items:
      $ref: "#/components/schemas/Link"
  groups:
    type: array
    items:
      type: string
    description: Ids of the settings' `groups` this person is in.
```

### `Link`

```yaml
type: object
required:
  - name
  - url
properties:
  name:
    type:
      - string
      - "null"
  url:
    type:
      - string
      - "null"
```

### `EntryList`

```yaml
type: object
required:
  - entries
  - page
  - totalPages
  - totalEntries
properties:
  entries:
    type: array
    items:
      $ref: "#/components/schemas/Entry"
    description: By name, 48 to a page, unpublished entries included.
  page:
    type: integer
    minimum: 1
  totalPages:
    type: integer
    minimum: 0
  totalEntries:
    type: integer
    minimum: 0
```

### `EntryDetail`

```yaml
type: object
required:
  - entry
  - canEdit
  - canDelete
  - canModerate
properties:
  entry:
    $ref: "#/components/schemas/Entry"
  canEdit:
    type: boolean
  canDelete:
    type: boolean
  canModerate:
    type: boolean
    description: A key acts as the owner, so all three are true. The scopes decide
      what the key may do.
```

### `EntryWritten`

```yaml
type: object
required:
  - id
  - isPublished
properties:
  id:
    type: integer
  isPublished:
    type: boolean
description: >
  What a write answers, and all it answers, so a key that may write but not read
  learns nothing of an entry's contents.
```

### `EntryDeleted`

```yaml
type: object
required:
  - id
properties:
  id:
    type: integer
```

### `EntryInput`

```yaml
description: >
  A new entry, or the whole of an edit. `type` and `name` are always required,
  even to change one field. Fields that belong to the other type are ignored. On
  an edit, a field left out keeps its value. A list that is sent is the whole
  list: send each row back with its `id` to keep it (and its photo), and a row
  without one is new. `photoKey` is refused anywhere in the body, with `reason:
  photos`. Every type is taken under every preset.
oneOf:
  - $ref: "#/components/schemas/FamilyEntryInput"
  - $ref: "#/components/schemas/StaffEntryInput"
  - $ref: "#/components/schemas/IndividualEntryInput"
discriminator:
  propertyName: type
  mapping:
    family: "#/components/schemas/FamilyEntryInput"
    staff: "#/components/schemas/StaffEntryInput"
    individual: "#/components/schemas/IndividualEntryInput"
```

### `FamilyEntryInput`

```yaml
type: object
required:
  - type
  - name
properties:
  type:
    type: string
    const: family
  name:
    type: string
    minLength: 1
  parents:
    type:
      - array
      - "null"
    items:
      $ref: "#/components/schemas/ParentInput"
  children:
    type:
      - array
      - "null"
    items:
      $ref: "#/components/schemas/ChildInput"
```

### `ParentInput`

```yaml
type: object
required:
  - name
properties:
  id:
    type:
      - string
      - "null"
    minLength: 1
  name:
    type: string
    minLength: 1
  email:
    type:
      - string
      - "null"
    description: Blank is stored as null.
  phoneNumber:
    type:
      - string
      - "null"
    description: Blank is stored as null.
  address:
    type:
      - string
      - "null"
    description: Blank is stored as null.
```

### `ChildInput`

```yaml
type: object
required:
  - name
properties:
  id:
    type:
      - string
      - "null"
    minLength: 1
  name:
    type: string
    minLength: 1
  teacher:
    type:
      - string
      - "null"
    description: >
      Blank is stored as null. A teacher naming one of the settings' `groups`,
      by its name or an alias and ignoring case, puts the child in that group
      instead of being stored. Sent without `groups`, the child is in that group
      alone, or in none.
  groups:
    type:
      - array
      - "null"
    items:
      type: string
    description: >
      Ids of the settings' `groups` this child is in. At most one under a preset
      of `school`, `team` or `other`. Left out, with `teacher` left out too, an
      edit keeps the child's groups.
```

### `StaffEntryInput`

```yaml
type: object
required:
  - type
  - name
properties:
  type:
    type: string
    const: staff
  name:
    type: string
    minLength: 1
  title:
    type:
      - string
      - "null"
  email:
    type:
      - string
      - "null"
  phoneNumber:
    type:
      - string
      - "null"
  description:
    type:
      - string
      - "null"
  links:
    type:
      - array
      - "null"
    items:
      $ref: "#/components/schemas/LinkInput"
```

### `IndividualEntryInput`

```yaml
type: object
required:
  - type
  - name
properties:
  type:
    type: string
    const: individual
  name:
    type: string
    minLength: 1
  title:
    type:
      - string
      - "null"
  email:
    type:
      - string
      - "null"
  phoneNumber:
    type:
      - string
      - "null"
  description:
    type:
      - string
      - "null"
  links:
    type:
      - array
      - "null"
    items:
      $ref: "#/components/schemas/LinkInput"
  groups:
    type:
      - array
      - "null"
    items:
      type: string
    description: >
      Ids of the settings' `groups` this person is in. At most one under a
      preset of `school`, `team` or `other`. An id the settings do not list is
      refused. Left out, an edit keeps the person's groups.
```

### `LinkInput`

```yaml
type: object
required:
  - url
properties:
  name:
    type:
      - string
      - "null"
  url:
    type: string
    format: uri
    description: An `http://` or `https://` address.
```

### `EntryModeration`

```yaml
type: object
minProperties: 1
additionalProperties: false
properties:
  isPublished:
    type: boolean
    description: Publish or unpublish. A suspended owner's key may still unpublish.
  dismissReports:
    type: boolean
    description: "`true` clears every report on the entry. `false` does nothing."
```

### `EntryUpdate`

```yaml
description: >
  One of three bodies. `isPublished` and `dismissReports` alone
  (`EntryModeration`) change nothing else and need `moderation`. An edit
  (`EntryInput`) needs `entries:write`. An edit with either field beside it
  needs both, and runs in order: the edit, then the publish, then the reports.
anyOf:
  - $ref: "#/components/schemas/EntryModeration"
  - allOf:
      - $ref: "#/components/schemas/EntryInput"
      - type: object
        properties:
          isPublished:
            type: boolean
          dismissReports:
            type: boolean
```

### `ModerationList`

```yaml
type: object
required:
  - entries
  - page
  - totalPages
  - totalEntries
properties:
  entries:
    description: >
      By name, 48 to a page. Entries waiting for approval, entries with reports,
      and entries published after pre-screening in the last 7 days. With
      `entries:read` each is a `WithheldModerationEntry`. Without it, each is a
      `ModerationSummary`.
    anyOf:
      - type: array
        items:
          $ref: "#/components/schemas/WithheldModerationEntry"
      - type: array
        items:
          $ref: "#/components/schemas/ModerationSummary"
  page:
    type: integer
    minimum: 1
  totalPages:
    type: integer
    minimum: 0
  totalEntries:
    type: integer
    minimum: 0
```

### `ModerationSummary`

```yaml
type: object
required:
  - id
  - name
  - isPublished
  - flags
  - aiReview
properties:
  id:
    type: integer
  name:
    type: string
  isPublished:
    type: boolean
  flags:
    type: array
    items:
      $ref: "#/components/schemas/ModerationFlag"
  aiReview:
    $ref: "#/components/schemas/NullableAiReview"
description: A queued entry for a key without `entries:read`. Contacts and
  children's names stay out.
```

### `WithheldModerationEntry`

```yaml
description: A queued entry for a key with `entries:read`. The whole entry, with
  its reports and its pre-screening result.
allOf:
  - $ref: "#/components/schemas/Entry"
  - $ref: "#/components/schemas/ModerationFields"
```

### `ModerationFields`

```yaml
type: object
required:
  - flags
  - aiReview
properties:
  flags:
    type: array
    items:
      $ref: "#/components/schemas/ModerationFlag"
  aiReview:
    $ref: "#/components/schemas/NullableAiReview"
```

### `ModerationFlag`

```yaml
type: object
required:
  - reason
  - reportedAt
properties:
  reason:
    type:
      - string
      - "null"
    description: What the reporter wrote.
  reportedAt:
    type:
      - string
      - "null"
    format: date-time
description: One report. Who made it is not sent.
```

### `NullableAiReview`

```yaml
description: The pre-screening result, or null when there is none.
anyOf:
  - $ref: "#/components/schemas/AiReview"
  - type: "null"
```

### `AiReview`

```yaml
type: object
required:
  - action
  - category
  - confidence
  - model
  - reviewedAt
properties:
  action:
    type: string
    enum:
      - approve
      - hold
  category:
    type:
      - string
      - "null"
    enum:
      - ok
      - spam
      - duplicate
      - impersonation
      - off-topic
      - unsuitable-photo
      - failed
      - null
    description: Why. `failed` means pre-screening errored or timed out, which holds
      the entry.
  confidence:
    type:
      - number
      - "null"
    minimum: 0
    maximum: 1
  model:
    type:
      - string
      - "null"
  reviewedAt:
    type:
      - string
      - "null"
    format: date-time
```

### `InviteRequest`

```yaml
type: object
required:
  - emails
properties:
  emails:
    type: string
    description: >
      One string of addresses, separated by commas, semicolons or new lines. A
      list is refused. Blanks are dropped, and so are repeats, ignoring case. At
      most 20 addresses after that.
```

### `InvitesSent`

```yaml
type: object
required:
  - sent
properties:
  sent:
    type: array
    items:
      type: string
    description: Every address emailed, as parsed from `emails`.
```
