# API reference

The JSON API for external extensions. Everything here is read with a bearer token issued by the
Zen Social OAuth provider — see [Building an extension](/docs/extensions/guide) for how to get one.

## Base URL and authentication

```
https://zensocial.app/api/extensions/v1
```

Every request carries a bearer token in the `Authorization` header — the only place we read it; a token in the query string is ignored and the call answers `401`:

```
Authorization: Bearer YOUR_ACCESS_TOKEN
```

There are two kinds of token, and some endpoints only accept one of them.

| Token | Grant | Acts as | Notes |
|---|---|---|---|
| User token | `authorization_code` + PKCE | The member who consented | Carries their opaque id; scoped by what they may see. |
| App token | `client_credentials` | Your extension itself | Carries no member, so `openid` and `profile` buy it nothing. |

Requests and responses are JSON (`Content-Type: application/json` on anything with a body). The
token's OAuth client decides which extension is calling: an installation of any other extension is
simply `not_found`.

## Discovery

We are a standard OpenID Connect provider. Read the metadata rather than hard-coding endpoints:

| What | URL |
|---|---|
| Provider metadata | `https://zensocial.app/.well-known/openid-configuration` |
| JWKS (ID token signing keys) | `https://zensocial.app/oauth/discovery/keys` |
| Authorization | `https://zensocial.app/oauth/authorize` |
| Token | `https://zensocial.app/oauth/token` |
| UserInfo | `https://zensocial.app/oauth/userinfo` |
| Revocation | `https://zensocial.app/oauth/revoke` |

ID tokens are signed with `RS256`. PKCE with `code_challenge_method=S256` is required on every
authorization request, confidential clients included. Access tokens last 2 hours; refresh tokens
rotate on use.

The ID token and UserInfo carry `sub` (the member's opaque id, a 12-character string), `name`,
`picture`, `locale` and `updated_at`. There is no e-mail claim — an address in Zen Social is a
notification setting, not an identity.

## Scopes

| Scope | Grants | Needed for |
|---|---|---|
| `openid` | Sign-in itself. Implied by the authorization code flow. | Getting an ID token. |
| `profile` | The member's name, avatar, language and last profile change. | `GET /me`, the claims. |
| `members` | The community's member directory. | `GET /installations/:installation_id/members` |
| `notify` | Sending notifications on your extension's behalf. | `POST /installations/:installation_id/notifications` |

Ask for the least you need. Widening the scope set asks the member to consent again.

## Objects

### Person

```json
{
  "id": "2h54Gfr675Dd",
  "name": "Ada Holm",
  "avatar_url": "https://zensocial.app/rails/active_storage/…/ada.jpg",
  "locale": "da"
}
```

| Field | Type | Notes |
|---|---|---|
| `id` | string | The member's opaque Zen Social id: 12 letters and digits, the same value as the `sub` claim, stable for the life of the account. Never a number — store it as a string. |
| `name` | string | Display name. |
| `avatar_url` | string or null | Absolute URL. `null` when they have no picture — draw initials. |
| `locale` | string | `da`, `en`, … Render anything they will read in it. |

### Organization

```json
{
  "id": 7,
  "name": "Acme Rowing Club",
  "slug": "acme",
  "display_color": "#2F6F62",
  "initials": "AR"
}
```

| Field | Type | Notes |
|---|---|---|
| `id` | integer | The community's id. |
| `name` | string | Its name. |
| `slug` | string | Its URL slug — the `community` parameter on your launch URL. |
| `display_color` | string | Hex accent colour, for making your pages feel at home. |
| `initials` | string | At most five characters — the community's own, else derived from its name. For a mark when there is no logo. |

### Installation

```json
{
  "id": 42,
  "settings": {},
  "position": 3,
  "color": null,
  "icon_url": null
}
```

| Field | Type | Notes |
|---|---|---|
| `id` | integer | This community's copy of your extension. The id in every path below. |
| `settings` | object | What the community's admin configured. `{}` for an external extension today. |
| `position` | integer | The door's order among the community's installed extensions, set when it was installed. |
| `color` | string or null | `null` today — an external installation has no colour override yet. When it gets one, this is a `#RRGGBB` string. |
| `icon_url` | string or null | Absolute URL of the door's icon. `null` today, for the same reason as `color`; draw your own mark. |

## GET /me

The member the token belongs to. Needs the `profile` scope and a **user token**.

```bash
curl -H "Authorization: Bearer $ACCESS_TOKEN" \
     https://zensocial.app/api/extensions/v1/me
```

`200 OK` — a [Person](#person).

```json
{ "id": "2h54Gfr675Dd", "name": "Ada Holm", "avatar_url": null, "locale": "da" }
```

Errors: `401` `invalid_token` · `403` `insufficient_scope` without `profile` · `403`
`user_token_required` with an app token.

## GET /installations

Every community that has your extension installed *and* that the member belongs to, ordered as the
doors are. Needs a **user token**; any scope will do, `openid` alone included.

```bash
curl -H "Authorization: Bearer $ACCESS_TOKEN" \
     https://zensocial.app/api/extensions/v1/installations
```

`200 OK` — an array of entries.

```json
[
  {
    "installation": { "id": 42, "settings": {}, "position": 3, "color": null, "icon_url": null },
    "organization": { "id": 7, "name": "Acme Rowing Club", "slug": "acme", "display_color": "#2F6F62", "initials": "AR" },
    "role": "owner"
  }
]
```

| Field | Type | Notes |
|---|---|---|
| `installation` | object | An [Installation](#installation). |
| `organization` | object | An [Organization](#organization). |
| `role` | string or null | The member's role in the community: `owner`, `admin`, `editor`, `contributor`, `commenter`, `viewer` — or `null` when they belong through a sub-group only. |

At most 200 entries. This is the endpoint that turns the `installation` parameter on your launch URL
into something you may trust: if the id is not in this list, the member is not entitled to it.

Errors: `401` `invalid_token` · `403` `user_token_required` with an app token.

## GET /installations/:id

One installation and its community. Works with a **user token** (the member must belong to the
community) or an **app token**. Any scope will do.

```bash
curl -H "Authorization: Bearer $TOKEN" \
     https://zensocial.app/api/extensions/v1/installations/42
```

`200 OK`

```json
{
  "installation": { "id": 42, "settings": {}, "position": 3, "color": null, "icon_url": null },
  "organization": { "id": 7, "name": "Acme Rowing Club", "slug": "acme", "display_color": "#2F6F62", "initials": "AR" }
}
```

Errors: `401` `invalid_token` · `403` `not_a_member` when a member's token names a community they are
not in · `404` `not_found` for an unknown, suspended or foreign installation.

## GET /installations/:installation_id/members

The community's member directory, in the order its own members page lists them. Needs the `members`
scope. Works with either kind of token; a user token must belong to a member of that community.

```bash
curl -H "Authorization: Bearer $TOKEN" \
     https://zensocial.app/api/extensions/v1/installations/42/members
```

`200 OK` — an array of [Person](#person).

```json
[
  { "id": "2h54Gfr675Dd", "name": "Ada Holm", "avatar_url": "https://zensocial.app/…/ada.jpg", "locale": "da" },
  { "id": "Kq7bZ1mXn4Pt", "name": "Bo Lund", "avatar_url": null, "locale": "en" }
]
```

Errors: `401` `invalid_token` · `403` `insufficient_scope` without `members` · `403` `not_a_member` ·
`404` `not_found`.

## POST /installations/:installation_id/notifications

Notify members of that community. Needs the `notify` scope. An **app token** may always do it; a
**user token** only when that member is an `owner` or `admin` of the community, and the notification
is then authored by them.

```bash
curl -X POST https://zensocial.app/api/extensions/v1/installations/42/notifications \
  -H "Authorization: Bearer $APP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "title": "Your shift starts tomorrow",
        "text": "Saturday 09:00-13:00 at the clubhouse.",
        "url": "https://your-site.example/shifts/17",
        "recipient_ids": ["2h54Gfr675Dd", "Kq7bZ1mXn4Pt"]
      }'
```

| Field | Type | Rules |
|---|---|---|
| `title` | string | Required, at most 80 characters. |
| `text` | string | Required, at most 280 characters. |
| `url` | string | Required. `https`, and on the host of your registered launch URL. |
| `recipient_ids` | array of strings | Required, 1 to 500 member ids — the opaque `id` of a [Person](#person), the same value as their `sub`. Duplicates are collapsed; ids that are unknown, or that are not members of the community, are dropped silently. Anything that is not a 12-character member id is `422`. |

`201 Created`

```json
{ "id": 9001 }
```

Delivery is asynchronous and respects each member's own notification settings. `title` and `text`
are shown exactly as sent — translate them per recipient `locale` and send one call per language.

The endpoint is not idempotent: a retried POST (after a timeout, say) creates a second notification
and notifies its recipients again. An `Idempotency-Key` header is planned.

Errors: `401` `invalid_token` · `403` `insufficient_scope` without `notify` · `403` `admin_required`
for a non-admin member's token · `403` `not_a_member` · `404` `not_found` · `422` with per-field
errors · `429` after 20 calls in an hour for one installation.

```json
{ "errors": { "url": ["must be an https URL on your-site.example"] } }
```

## Errors

| Status | Body | Meaning |
|---|---|---|
| `401` | `{"error":"invalid_token","error_description":"…"}` | Missing, malformed, expired or revoked token, or one belonging to a disabled extension. Also sent with a `WWW-Authenticate: Bearer` header. |
| `403` | `{"error":"insufficient_scope","error_description":"…"}` | The token lacks the scope the endpoint needs. |
| `403` | `{"error":"not_a_member"}` | A member's token aimed at a community they do not belong to. |
| `403` | `{"error":"admin_required"}` | A member's token notifying without being an `owner` or `admin`. |
| `403` | `{"error":"user_token_required"}` | An app token on an endpoint that is about a person. |
| `404` | `{"error":"not_found"}` | Unknown, suspended, or another extension's installation. We never distinguish the three. |
| `422` | `{"errors":{"title":["is too long (maximum is 80 characters)"]}}` | Invalid body, errors keyed by field. |
| `429` | `{"error":"Too many requests. Please try again later."}` | Throttled; a `Retry-After: 60` header comes with it. |

The `error` values above are stable and safe to branch on. The human-readable `error_description`
and the `422` messages are English prose and may change — key on the field name and the status, not
on the sentence.

## Rate limits

| What | Limit | Keyed on |
|---|---|---|
| Any API call | 60 per minute | The access token |
| Any API call | 120 per minute | The calling IP address |
| Notifications | 20 per hour | The installation and the token together |
| `POST /oauth/token` | 30 per minute | The calling IP address |
| `POST /oauth/token` | 60 per minute | The client id |
| `/oauth/authorize` | 30 per minute | The calling IP address |

Over a limit you get `429` and `Retry-After: 60`. Batch recipients into one notification call rather
than looping, and cache what does not change per request.

## curl walkthrough

A complete server-side round trip, with no member present.

```bash
export CLIENT_ID=your-client-id
export CLIENT_SECRET=your-client-secret
export BASE=https://zensocial.app/api/extensions/v1

# 1. An app token for your own server.
TOKEN=$(curl -s -X POST https://zensocial.app/oauth/token \
  -d grant_type=client_credentials \
  -d client_id="$CLIENT_ID" \
  -d client_secret="$CLIENT_SECRET" \
  -d scope="members notify" | jq -r .access_token)

# 2. The installation you were handed, and its community.
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/installations/42" | jq

# 3. Who is in that community.
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/installations/42/members" | jq

# 4. Tell two of them something.
curl -s -X POST "$BASE/installations/42/notifications" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title":"Your shift starts tomorrow",
       "text":"Saturday 09:00-13:00 at the clubhouse.",
       "url":"https://your-site.example/shifts/17",
       "recipient_ids":["2h54Gfr675Dd","Kq7bZ1mXn4Pt"]}' | jq
```
