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