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 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

{
  "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

{
  "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

{
  "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.

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

200 OK — a Person.

{ "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.

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

200 OK — an array of entries.

[
  {
    "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.
organization object An 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.

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

200 OK

{
  "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.

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

200 OK — an array of Person.

[
  { "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.

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, 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

{ "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.

{ "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.

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