Building an extension
This guide walks through an extension end to end: registration, sign-in, finding the community, reading its members, notifying them, and going live. The API reference has the exact shapes; this page explains the order and the reasoning.
How it fits together
There are three parties and two tokens.
| Party | What it is |
|---|---|
| The member | A person in a Zen Social community, using their browser or the Zen Social app. |
| Zen Social | The community platform: the door, the identity provider, the API. |
| Your extension | Your site, on your servers, under your domain. |
| Token | How you get it | What it is for |
|---|---|---|
| A user token | Authorization code + PKCE, after the member consents | Acting as the member: who they are, which communities they can see. |
| An app token | client_credentials, from your server, with no member involved |
Acting as yourself: background work, sending notifications. |
The flow, in order:
- A community admin installs your extension. Zen Social creates an installation — that community's copy of your extension, with its own id.
- Every member of that community now sees your door. Tapping it opens
https://your-site.example/start?installation=42&community=acmein a new tab. - Your site sends the member to Zen Social to sign in (OpenID Connect).
- They come back with a code; you exchange it for an ID token and an access token.
- You call the API with the access token to confirm which installation they are entitled to, and to read the community.
- Your server calls the API with its own app token to send notifications.
Registration
Registration is done by Zen Social staff, once, in the backoffice. Send us:
| Field | Notes |
|---|---|
| Name | What the community sees on the door and on the consent screen. |
| Description | One or two sentences for the catalogue card. |
| Developer | The person or organisation behind it. Shown on the consent screen. |
| Launch URL | https://…. Must be https. The door opens it; notification links must live on its host. |
| Redirect URIs | Your OAuth callbacks, one per line. Matched exactly — no wildcards, no trailing-slash forgiveness. |
| Scopes | The most you will ever ask for: openid, profile, members, notify. |
You get back a client id (public) and a client secret (shown once, at registration — we store only a hash of it, so we cannot show it to you again; ask for a rotation if you lose it).
Extensions are unlisted: a community only sees yours in its catalogue after staff grant that community access. Tell us which communities you are working with.
Sign-in with OpenID Connect
We are a standard OpenID Connect provider. If your framework has an OIDC client, point it at the discovery document and skip to the claims:
https://zensocial.app/.well-known/openid-configuration
By hand, it is the authorization code flow with PKCE.
1. Make a PKCE pair. A random code_verifier (43–128 characters), and its challenge:
code_challenge = BASE64URL(SHA256(code_verifier)) # no padding
S256 is the only method we accept, and PKCE is mandatory for every client — including
confidential ones that also send a client secret.
2. Send the member to the authorization endpoint.
https://zensocial.app/oauth/authorize
?client_id=YOUR_CLIENT_ID
&redirect_uri=https://your-site.example/callback
&response_type=code
&scope=openid%20profile
&state=RANDOM_STATE
&nonce=RANDOM_NONCE
&code_challenge=CHALLENGE
&code_challenge_method=S256
state and nonce are yours to generate and to check on the way back. Ask only for the scopes you
need now; you can ask for more later, and the member is asked again when you do.
3. The member signs in and consents. If they are not signed in to Zen Social, we sign them in first and then bring them back. The consent screen names your extension, its developer, and each scope in plain language. Consent is remembered per scope set: a second sign-in with the same scopes goes straight through, and a sign-in that asks for more stops and asks again.
If they say no, we send them back to your redirect_uri with error=access_denied.
4. Exchange the code, from your server. A code is single-use and lives 10 minutes.
curl -X POST https://zensocial.app/oauth/token \
-d grant_type=authorization_code \
-d code="$CODE" \
-d redirect_uri=https://your-site.example/callback \
-d client_id="$CLIENT_ID" \
-d client_secret="$CLIENT_SECRET" \
-d code_verifier="$CODE_VERIFIER"
{
"access_token": "…",
"token_type": "Bearer",
"expires_in": 7200,
"refresh_token": "…",
"scope": "openid profile",
"id_token": "eyJ…"
}
5. Verify the ID token. RS256, against the keys at
https://zensocial.app/oauth/discovery/keys. Check iss is https://zensocial.app, aud is your
client id, exp is in the future and nonce is the one you sent.
| Claim | Type | Notes |
|---|---|---|
sub |
string | The member's stable Zen Social id: an opaque 12-character string of letters and digits, never an integer. Your primary key for them — store it as a string, and never parse it. |
name |
string | Their display name. |
picture |
string | Absolute URL of their avatar. Absent when they have not set one. |
locale |
string | Their language, e.g. da or en. Render your UI and your notifications in it. |
updated_at |
integer | When their profile last changed, in seconds since the epoch. |
nonce |
string | Echoed from your request. |
name, picture, locale and updated_at need the profile scope, and are also available from
https://zensocial.app/oauth/userinfo with the access token.
There is no e-mail claim, and there never will be. In Zen Social an address is a notification
setting, not an identity — many members have none at all, and the ones who do did not give it to
you. Identify members by sub. If your extension needs to write to someone, ask them for an
address yourself, or send them a Zen Social notification instead.
If Support merges two accounts, the surviving account keeps one sub; the other one stops resolving.
Tokens and refresh. An access token lasts 2 hours. Refresh with the refresh_token grant; both
tokens rotate, so store what you get back and throw the old pair away:
curl -X POST https://zensocial.app/oauth/token \
-d grant_type=refresh_token \
-d refresh_token="$REFRESH_TOKEN" \
-d client_id="$CLIENT_ID" \
-d client_secret="$CLIENT_SECRET"
You can hand a token back with POST https://zensocial.app/oauth/revoke when a member signs out of
your site for good.
Knowing the community
The door's installation and community parameters are hints. They arrive in a URL, so a member
could change them, and your extension may well be installed in several communities at once. The
API is the truth.
After sign-in, ask what this member can see:
curl -H "Authorization: Bearer $ACCESS_TOKEN" \
https://zensocial.app/api/extensions/v1/installations
You get one entry per community that has your extension installed and that the member belongs to. Each entry carries the installation, the community, and the member's role:
[
{
"installation": { "id": 42, "settings": {}, "position": 3, "color": null, "icon_url": null },
"organization": { "id": 7, "name": "Acme", "slug": "acme", "display_color": "#2F6F62", "initials": "AC" },
"role": "owner"
}
]
If the id from the door is not in that list, the member is not entitled to it: show them the list you did get, or an empty state. Never trust the parameter on its own.
role is the member's role in the community itself, as a string: owner, admin, editor,
contributor, commenter or viewer. It is null when they belong to the community only through
one of its sub-groups — that is a member in good standing with no role of their own, not an error.
owner and admin are the two that can manage the community.
The organization object is there so your pages can look like they belong: name for the heading,
display_color for an accent, initials for a mark when there is no logo.
Reading members
With the members scope you can read a community's directory through its installation:
curl -H "Authorization: Bearer $TOKEN" \
https://zensocial.app/api/extensions/v1/installations/42/members
[
{ "id": "2h54Gfr675Dd", "name": "Ada Holm", "avatar_url": "https://zensocial.app/…/ada.jpg", "locale": "da" },
{ "id": "Kq7bZ1mXn4Pt", "name": "Bo Lund", "avatar_url": null, "locale": "en" }
]
These are the same people the community's own members page lists, in the same order. avatar_url
is absolute, or null for a member with no picture — draw their initials. locale is what to
render their name card, and any notification you send them, in.
Both an app token and a member's user token may read members. A user token only works for a member
of that community; anyone else gets 403 not_a_member.
Store ids, not copies. A member who leaves the community disappears from this list, and that is the only signal you get — re-read it rather than keeping a directory of your own.
Sending notifications
A notification from your extension shows up in Zen Social's own notification list, reaches the member's devices, and respects the notification settings they have already chosen.
Use an app token from your server. It needs no member and no member's consent:
curl -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"
Then post to the installation:
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"]
}'
Rules worth knowing before you write the code:
titleis at most 80 characters,textat most 280. Both are shown exactly as you send them, so translate them yourself — use each recipient'slocaleand send one call per language.urlmust behttpsand on the host of your registered launch URL. A notification that leads off your own site is rejected with422.recipient_idsis 1–500 Zen Social member ids — the opaqueidof a person, which is the same string as theirsub. Ids that are unknown, or that are not members of that community, are dropped silently, so a stale id costs you nothing. Anything that is not a 12-character member id is rejected with422.- A user token may only do this for a community
owneroradmin; any other member gets403 admin_required. Otherwise every member could broadcast to the whole community through you. - 20 notification calls per hour per installation. Batch recipients into one call rather than looping.
The door
The door is static and it is ours to draw. It shows exactly what you registered — your extension's
name and the host of your launch URL — painted in the community's own palette colour, and it opens
the launch URL with the installation and community parameters appended. There is no badge, no
unread count, no live content from your side: an extension cannot draw on the community's home
screen.
A community admin can add the door and remove it, and nothing else. For an external extension there
is no rename, no recolour and no reorder, which is why installation.color and icon_url come back
null. Members can do none of it: they see the door, or, if the extension is not installed, nothing
at all.
Design your landing page for a cold start. The member may never have seen your site, may not be
signed in to it, and may be arriving on a phone in a browser tab with no navigation of its own.
Send them to sign-in immediately and land them on something useful for the community named in the
community parameter.
Going live checklist
- The launch URL is https and serves a real page for a signed-out visitor.
- Every redirect URI you use is registered, character for character.
- PKCE with
S256on every authorization request. stateandnoncegenerated per request, and verified on the way back.- The ID token's signature,
iss,audandexpare verified against the JWKS. - Members are keyed on
sub, and nothing in your data model needs an e-mail address. - The installation id is confirmed against
GET /installations, never trusted from the URL. - Refresh handled, with both rotated tokens stored.
401retried once by re-authenticating, not in a loop.429respected — back off for theRetry-Afteryou are given.- Notification titles and texts are translated per recipient
locale. - Your client secret is on your server only, never in a browser or a mobile binary.
Limits and errors
Everything is throttled per minute, and notifications per hour as well. All limits answer with
429 and Retry-After: 60.
| What | Limit |
|---|---|
| Any call to the API, per access token | 60 per minute |
| Any call to the API, per IP address | 120 per minute |
| Notifications, per installation per token | 20 per hour |
| The token endpoint, per IP address | 30 per minute |
| The token endpoint, per client id | 60 per minute |
| The authorization endpoint, per IP address | 30 per minute |
| Status | Body | What happened |
|---|---|---|
401 |
{"error":"invalid_token"} |
Missing, expired, revoked, or belonging to a disabled extension. Get a new token. |
403 |
{"error":"insufficient_scope"} |
The token does not hold the scope this 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 trying to notify without being an admin. |
403 |
{"error":"user_token_required"} |
An app token on an endpoint that is about a person. |
404 |
{"error":"not_found"} |
No such installation — or one that is not yours, or suspended. |
422 |
{"errors":{"title":["is too long (maximum is 80 characters)"]}} |
The notification body is invalid. Errors are per field. |
429 |
{"error":"Too many requests. Please try again later."} |
Throttled. Wait for Retry-After. |
404 is deliberately the answer for an installation that belongs to another extension. We never
confirm that an id exists but is not yours.
Disconnecting
Three things can end the relationship, and you should handle all of them as "the token stopped working":
- A member disconnects you from their Zen Social profile, under Connected extensions. Every token and grant they hold for you is revoked at once.
- An admin removes the extension from their community. The installation stops existing, the
door disappears, and calls about it answer
404. - Zen Social disables the extension. Every token and grant of your client, for every member and
for your server, is revoked; every door disappears; and sign-in itself is refused —
/oauth/authorizeand/oauth/tokenanswerunauthorized_clientwhile it lasts. This is the kill switch; we would talk to you first unless something is on fire.
When a call answers 401, send the member through sign-in again. When one answers 404, drop the
installation from your side. Do not retry either in a loop.