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": "#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.
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": "#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. |
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": "#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.
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