Directory API & webhooks
Sign-in tells your app about the person in front of it. The Directory API and webhooks tell it about everyone in an organization, so your product can show staff lists, assign shifts or keep its own user table in sync, without waiting for each person to sign in.
How access works
Both features use the app's own identity, not a user's:
- A platform admin turns on Directory API for the app (Platform → Applications → your app).
- Your server exchanges its client ID and secret for a short-lived app token using the
standard OAuth
client_credentialsgrant, with themembers:readscope. - It calls the Directory API with that token. Webhooks are sent to endpoints registered on the same page.
An app with Directory API access can read the members of every organization. Only enable it for Webrizen's own products. Only server apps (with a client secret) can use it.
List members
Get an app token
curl -u CLIENT_ID:CLIENT_SECRET https://accounts.webrizen.com/api/auth/oauth2/token \
-d grant_type=client_credentials -d scope=members:read{ "access_token": "…", "token_type": "Bearer", "expires_in": 3600, "scope": "members:read" }Reuse the token until it's about to expire (1 hour).
Call the Directory API
curl -H "Authorization: Bearer ACCESS_TOKEN" \
"https://accounts.webrizen.com/api/auth/directory/members?organization_id=ORG_ID&limit=100&offset=0"Parameters
| Query parameter | Default | Notes |
|---|---|---|
organization_id | required | From the org.id claim, a webhook, or your own records |
limit | 100 | 1–200 |
offset | 0 | Members are ordered by join date, oldest first |
Response
{
"organization": { "id": "b665…", "name": "Spice Route Restaurants", "slug": "spice-route-restaurants", "logo": null },
"members": [
{
"id": "38b5…",
"user_id": "e8be…",
"email": "ravi@spiceroute.in",
"email_verified": true,
"name": "Ravi Kumar",
"image": null,
"roles": ["cashier"],
"teams": [{ "id": "7a1d…", "name": "Andheri branch" }],
"permissions": { "menu": ["read"], "order": ["read", "create", "update"] },
"joined_at": "2026-09-30T10:30:12.000Z"
}
],
"total": 42,
"limit": 100,
"offset": 0,
"has_more": false
}user_id is the same value as the sub claim your app receives at sign-in, so use it to match
directory records with signed-in users. permissions are already resolved from all of the
member's roles.
Errors
| Status | error | Meaning |
|---|---|---|
| 401 | invalid_token | Missing, expired or unknown token |
| 403 | insufficient_scope | Not an app token, or no members:read scope |
| 403 | unauthorized_client | The app is disabled |
| 404 | not_found | No organization with that ID |
| 429 | – | More than 120 requests per minute |
Invite people
Apps can invite someone to an organization on behalf of one of its members, for example an "Invite" button next to an employee in your product. Webrizen sends the same invitation email as the members page; the person accepts it, signs up or signs in, and joins the organization.
Get an app token with the members:invite scope (apps with Directory API access have it), then:
curl -X POST https://accounts.webrizen.com/api/auth/directory/invitations \
-H "Authorization: Bearer INVITE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"organization_id":"ORG_ID","email":"new@company.com","role":"member","inviter_user_id":"USER_ID"}'inviter_user_id is the sub of the signed-in person who asked for the invitation. They must be
a member of the organization with permission to invite; only owners and admins can invite admins,
and apps can't invite owners. A new invitation replaces any pending one for the same address and
expires after 48 hours.
{
"invitation": {
"id": "…",
"email": "new@company.com",
"role": "member",
"status": "pending",
"expires_at": "2026-10-11T09:30:00.000Z",
"url": "https://accounts.webrizen.com/invite/…"
}
}| Status | error | Meaning |
|---|---|---|
| 400 | invalid_role | The role doesn't exist in the organization, or it's owner |
| 403 | insufficient_scope | Not an app token, or no members:invite scope |
| 403 | not_a_member / not_allowed | The inviter can't invite people to this organization |
| 409 | already_member | That email already belongs to a member |
| 429 | – | More than 30 invitations per minute |
When they accept, your app receives the usual member.added webhook.
With the SDK
@webrizen/sso handles the token, caching and pagination for you:
import { createDirectoryClient } from "@webrizen/sso";
const directory = createDirectoryClient({
issuer: process.env.WEBRIZEN_SSO_ISSUER!,
clientId: process.env.WEBRIZEN_SSO_CLIENT_ID!,
clientSecret: process.env.WEBRIZEN_SSO_CLIENT_SECRET!,
});
const everyone = await directory.listAllMembers(orgId); // follows pagination
const firstPage = await directory.listMembers(orgId, { limit: 50 });Webhooks
Add an endpoint on the app's page in Platform → Applications, choose events, and copy the
signing secret (shown once). Use Test to send a webhook.test event.
Events
| Event | Sent when | data contains |
|---|---|---|
member.added | Someone accepts an invitation or is added | organization, full member |
member.updated | A member's roles or teams change | organization, full member, previous_roles (role changes) |
member.removed | A member is removed or leaves | organization, member (id, user_id, email, name) |
role.updated | A custom role's permissions or name change | organization, role (name, previous_name, permissions) |
role.deleted | A custom role is deleted | organization, role.name |
organization.deleted | An organization is deleted | organization |
The member object has the same shape as in the Directory API.
{
"id": "msg_2f7c…",
"type": "member.updated",
"created_at": "2026-09-30T16:59:12.000Z",
"data": {
"organization": { "id": "b665…", "name": "Spice Route Restaurants", "slug": "spice-route-restaurants", "logo": null },
"member": { "user_id": "c8d3…", "email": "ravi@spiceroute.in", "roles": ["cashier", "member"], "…": "…" },
"previous_roles": ["cashier"]
}
}role.updated is sent once, not once per member. Re-fetch (or recompute) permissions for
members who hold that role.
Verifying signatures
Webhooks follow the open Standard Webhooks format. Each
request has webhook-id, webhook-timestamp and webhook-signature headers. Always verify
before trusting the body.
import { verifyWebhook, WebhookVerificationError } from "@webrizen/sso";
export async function POST(req: Request) {
const body = await req.text(); // the raw body, exactly as received
try {
const event = await verifyWebhook({
secret: process.env.WEBRIZEN_WEBHOOK_SECRET!,
headers: req.headers,
body,
});
if (event.type === "member.removed") {
// revoke access for event.data.member.user_id in event.data.organization.id
}
return new Response(null, { status: 204 });
} catch (err) {
if (err instanceof WebhookVerificationError) return new Response("Invalid signature", { status: 401 });
throw err;
}
}Without the SDK, any Standard Webhooks library works (available for Node, Python, PHP, Go,
Java, Ruby and more). By hand: base64-decode the secret after whsec_, compute
HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{body}, base64-encode it, and compare it
with the value after v1, in the signature header. Reject timestamps older than 5 minutes.
Delivery and retries
- Respond with any 2xx within 8 seconds. Do slow work after responding.
- Failed deliveries (network errors, 5xx, 429) are retried 3 times with short backoff. Other 4xx responses are treated as a deliberate rejection and not retried.
- Every delivery, with its status and payload, is listed on the app's page, where failed ones can be resent.
- Deliveries can occasionally repeat. Use
webhook-idto ignore duplicates.
There's no long-running retry queue: if your endpoint is down for a while, events after the
third attempt are only kept in the delivery log. For guaranteed consistency, run a periodic
full sync with listAllMembers and use webhooks for instant updates in between.