Webrizendocs
Integrate your app

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:

  1. A platform admin turns on Directory API for the app (Platform → Applications → your app).
  2. Your server exchanges its client ID and secret for a short-lived app token using the standard OAuth client_credentials grant, with the members:read scope.
  3. It calls the Directory API with that token. Webhooks are sent to endpoints registered on the same page.
Organization-wide access

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 parameterDefaultNotes
organization_idrequiredFrom the org.id claim, a webhook, or your own records
limit1001–200
offset0Members 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

StatuserrorMeaning
401invalid_tokenMissing, expired or unknown token
403insufficient_scopeNot an app token, or no members:read scope
403unauthorized_clientThe app is disabled
404not_foundNo 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/…"
  }
}
StatuserrorMeaning
400invalid_roleThe role doesn't exist in the organization, or it's owner
403insufficient_scopeNot an app token, or no members:invite scope
403not_a_member / not_allowedThe inviter can't invite people to this organization
409already_memberThat 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

EventSent whendata contains
member.addedSomeone accepts an invitation or is addedorganization, full member
member.updatedA member's roles or teams changeorganization, full member, previous_roles (role changes)
member.removedA member is removed or leavesorganization, member (id, user_id, email, name)
role.updatedA custom role's permissions or name changeorganization, role (name, previous_name, permissions)
role.deletedA custom role is deletedorganization, role.name
organization.deletedAn organization is deletedorganization

The member object has the same shape as in the Directory API.

Example: member.updated
{
  "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 edits affect many members

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.

app/api/webhooks/webrizen/route.ts
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-id to ignore duplicates.
Webhooks complement the API

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.