# 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": "#A54166",
  "icon_url": "https://community.example/rails/active_storage/representations/redirect/abc123/def456/logo.png"
}
```

| 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 | The background of the extension's door on the community's home in light mode, as `#RRGGBB` (on a dark page the home may show that colour's darker pair instead). It comes from the community's own colours, and the community admin may change it, so read it each time rather than storing it. |
| `icon_url` | string or null | Absolute URL of the logo uploaded with your listing, which the door shows; `null` if your listing has no logo. |

## 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": "#A54166", "icon_url": "https://community.example/rails/active_storage/representations/redirect/abc123/def456/logo.png" },
    "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": "#A54166", "icon_url": "https://community.example/rails/active_storage/representations/redirect/abc123/def456/logo.png" },
  "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
```
