App Memberships
The membership registry records which ZB ID identities belong to a consuming app, and with what app-scoped role. Apps use it to gate their own access (for example, "is this ZB ID user an admin of my console?") without inventing a separate user store.
Overview
Membership endpoints are service-to-service. Call them with a client-credentials access token (see OAuth2 client credentials), not a user token. The acting app is taken from the token's client_id (the JWT subject), which is signed and cannot be forged.
Two scopes govern access:
- Name
app:memberships:read- Type
- scope
- Description
Required to list memberships. Granted to your OAuth client.
- Name
app:memberships:write- Type
- scope
- Description
Required to grant or revoke memberships. Granted to your OAuth client.
An app can only read and write memberships for its own client_id. A clientId in a request body is optional, and if present it must equal the calling client; the server always writes the membership for the caller, never for the value in the body. Attempting to act for another client returns 403 FORBIDDEN.
The routes under /v1/memberships all require a client-credentials token and are scoped to the calling app. Administrators acting across apps use the separate backoffice routes under /v1/admin/memberships.
The membership object
- Name
id- Type
- string
- Description
Unique membership identifier (UUID).
- Name
userId- Type
- string
- Description
The ZB ID identity (user UUID) this membership belongs to.
- Name
clientId- Type
- string
- Description
The
client_idof the app the identity is a member of.
- Name
role- Type
- string
- Description
The app-scoped role or label, for example
member,admin, orsuper_admin. Defaults tomember.
- Name
status- Type
- string
- Description
ACTIVEorREVOKED.
- Name
grantedBy- Type
- string | null
- Description
The user UUID that granted the membership, if recorded.
- Name
grantedByClient- Type
- string
- Description
The
client_idthat granted the membership.
- Name
createdAt- Type
- string
- Description
ISO 8601 timestamp when the membership was created.
- Name
updatedAt- Type
- string
- Description
ISO 8601 timestamp when the membership was last updated.
Membership object
{
"id": "9c1e5f2a-7b3d-4e8a-a1c2-3d4e5f6a7b8c",
"userId": "3f0a1b2c-4d5e-6f70-8192-a3b4c5d6e7f8",
"clientId": "zbid_client_a1b2c3d4e5f6",
"role": "admin",
"status": "ACTIVE",
"grantedBy": null,
"grantedByClient": "zbid_client_a1b2c3d4e5f6",
"createdAt": "2026-08-01T09:00:00Z",
"updatedAt": "2026-08-01T09:00:00Z"
}
List memberships
Returns the memberships owned by the calling app. Requires the app:memberships:read scope.
Query parameters
- Name
subject- Type
- string
- Description
Optional. A ZB ID user UUID. When present, returns only that identity's memberships for the calling app.
- Name
clientId- Type
- string
- Description
Optional. If present it must equal the calling client's own
client_id; otherwise the request returns403 FORBIDDEN.
Request
curl https://id.zb.co.zw/v1/memberships?subject=3f0a1b2c-4d5e-6f70-8192-a3b4c5d6e7f8 \
-H "Authorization: Bearer <client_credentials_token>"
Response
[
{
"id": "9c1e5f2a-7b3d-4e8a-a1c2-3d4e5f6a7b8c",
"userId": "3f0a1b2c-4d5e-6f70-8192-a3b4c5d6e7f8",
"clientId": "zbid_client_a1b2c3d4e5f6",
"role": "admin",
"status": "ACTIVE",
"grantedBy": null,
"grantedByClient": "zbid_client_a1b2c3d4e5f6",
"createdAt": "2026-08-01T09:00:00Z",
"updatedAt": "2026-08-01T09:00:00Z"
}
]
List memberships for the calling app
This read requires the app:memberships:read scope, so a plain user token from a Try it login returns 403. Paste a client-credentials token that holds the scope to see the list. Read-only either way.
Runs against the ZB ID STAGING sandbox (id-staging.zb.co.zw). Register a throwaway test account; never use real credentials.
Grant a membership
Grants an app membership to a ZB ID identity for the calling app. Requires the app:memberships:write scope. Identify the subject by userId or email (at least one is required). Returns 404 if no matching identity exists.
Request body
- Name
userId- Type
- string
- Description
The ZB ID user UUID to grant membership to. Provide this or
email.
- Name
email- Type
- string
- Description
The identity's email, used to resolve the user when
userIdis not supplied.
- Name
role- Type
- string
- Description
Optional app-scoped role. Defaults to
member.
- Name
grantedBy- Type
- string
- Description
Optional. The user UUID performing the grant, recorded for audit.
- Name
clientId- Type
- string
- Description
Optional. If present it must equal the calling client's own
client_id.
Request
curl -X POST https://id.zb.co.zw/v1/memberships \
-H "Authorization: Bearer <client_credentials_token>" \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"role": "admin"
}'
Response (201)
{
"id": "9c1e5f2a-7b3d-4e8a-a1c2-3d4e5f6a7b8c",
"userId": "3f0a1b2c-4d5e-6f70-8192-a3b4c5d6e7f8",
"clientId": "zbid_client_a1b2c3d4e5f6",
"role": "admin",
"status": "ACTIVE",
"grantedBy": null,
"grantedByClient": "zbid_client_a1b2c3d4e5f6",
"createdAt": "2026-08-01T09:00:00Z",
"updatedAt": "2026-08-01T09:00:00Z"
}
Revoke a membership
Revokes a membership owned by the calling app. Requires the app:memberships:write scope. Returns 204 No Content on success, or 404 Not Found if the membership does not exist or is not owned by the calling app.
Request
curl -X DELETE https://id.zb.co.zw/v1/memberships/9c1e5f2a-7b3d-4e8a-a1c2-3d4e5f6a7b8c \
-H "Authorization: Bearer <client_credentials_token>"
Response
204 No Content
Backoffice administration
The routes above are scoped to the calling app: a client-credentials token's subject is its client_id, so an app can only read and write its own roll. That leaves nobody able to seed the first member of a brand-new app, because until someone is a member there is no app to act for.
The routes under /v1/admin/memberships close that gap. They are the administrator's cross-app path: one call can put an identity into any registered app, or take them out of it, whichever app owns the row.
They differ from /v1/memberships in three ways:
- Name
Token- Type
- user access token
- Description
Sign in with
POST /auth/loginand use the returnedaccessToken. A client-credentials token is accepted too, but it records no human against the grant.
- Name
Permission- Type
- admin:users
- Description
The token's
scopesclaim must containadmin:users. That comes from the signed-in user's roles, not from the OAuth client'sscopescolumn, so an operator with theadminrole holds it.
- Name
clientId- Type
- required, any app
- Description
clientIdnames the app being written to and is required. Unlike/v1/memberships, it does not have to be the caller's own client.
Who is recorded as the grantor
Never the request body. grantedBy is the acting administrator's ZB ID user id, taken from the token's sub claim, and grantedByClient is the app the token was minted for, taken from its aud claim. A client-credentials caller has no human behind it, so grantedBy is left null.
Every call to these two writes is recorded in the auth audit log as ADMIN_MEMBERSHIP_GRANTED or ADMIN_MEMBERSHIP_REVOKED, on success and on failure. The audit row is written against the subject whose membership changed, so the change appears on that person's own trail; the acting administrator travels alongside it in the row's metadata.
To populate a platform picker, list the registered apps with GET /admin/clients (note: that path is not versioned). It requires the admin:clients permission, which the admin role also holds, and returns clientId, name and status for every client.
These two writes are live on staging (https://id-staging.zb.co.zw) as of 19 August 2026 and are awaiting promotion to production. The read at GET /v1/admin/memberships is on both.
List the members of a platform
Returns one page of the membership roll for a single app, newest first, with each row resolved back to the ZB ID identity behind it. Requires admin:users.
Query parameters
- Name
clientId- Type
- string
- Description
Required. The app's
client_id, for examplezb-banking-backoffice. A missing value returns400 BAD_REQUEST.
- Name
page- Type
- integer
- Description
Optional, zero-based. Defaults to
0.
- Name
size- Type
- integer
- Description
Optional. Defaults to
25and is capped at100.
Response
A paged envelope of items, page, size and total, where total is the count of all matching rows rather than the page size. Each item carries id, userId, email, fullName, role, status and createdAt. email and fullName are null when the membership points at an identity that no longer exists.
Request
curl "https://id.zb.co.zw/v1/admin/memberships?clientId=zb-banking-backoffice" \
-H "Authorization: Bearer <admin_access_token>"
Response
{
"items": [
{
"id": "9c1e5f2a-7b3d-4e8a-a1c2-3d4e5f6a7b8c",
"userId": "3f0a1b2c-4d5e-6f70-8192-a3b4c5d6e7f8",
"email": "[email protected]",
"fullName": "Ops Admin",
"role": "member",
"status": "ACTIVE",
"createdAt": "2026-08-19T09:00:00Z"
}
],
"page": 0,
"size": 25,
"total": 1
}
List the members of one platform
Sign in above with an operator holding the admin role, then set clientId to the app you want the roll for. Read-only.
Runs against the ZB ID STAGING sandbox (id-staging.zb.co.zw). Register a throwaway test account; never use real credentials.
Grant a membership on any app
Grants a membership to any identity on any registered app. Requires admin:users.
The call is idempotent per (user, app, role). Re-granting a membership that already exists re-activates a revoked row and re-stamps who granted it, rather than failing, and still answers 201.
Request body
- Name
clientId- Type
- string
- Description
Required. The
client_idof the app to grant membership on. It must exist in the OAuth client registry.
- Name
userId- Type
- string
- Description
The ZB ID user UUID to grant membership to. Provide this or
email.
- Name
email- Type
- string
- Description
The identity's email, used to resolve the user when
userIdis not supplied. Matching is case-insensitive.
- Name
role- Type
- string
- Description
Optional app-scoped role. Blank or absent defaults to
member.
There is deliberately no grantedBy field. Both provenance columns come from the verified token, so a caller cannot dictate who the audit trail says made the grant.
Status codes
- Name
201- Type
- Created
- Description
The membership exists and is
ACTIVE. Returned for a fresh grant and for an idempotent re-grant alike.
- Name
400- Type
- Bad Request
- Description
Four distinct cases, told apart by the
message:clientIdabsent entirely givesInvalid request body;clientIdpresent but blank givesclientId: clientId is required; neitheruserIdnoremailsupplied givesEither a userId or an email is required; and aclientIdnaming an app that is not registered givesUnknown clientId.
- Name
403- Type
- Forbidden
- Description
The token does not carry
admin:users.
- Name
404- Type
- Not Found
- Description
No ZB ID identity matches the
userIdoremailsupplied. An empty body: only a missing subject answers 404, which is what distinguishes it from an unregistered app.
Request
curl -X POST https://id.zb.co.zw/v1/admin/memberships \
-H "Authorization: Bearer <admin_access_token>" \
-H "Content-Type: application/json" \
-d '{
"clientId": "zb-banking-backoffice",
"email": "[email protected]",
"role": "member"
}'
Response (201)
{
"id": "9c1e5f2a-7b3d-4e8a-a1c2-3d4e5f6a7b8c",
"userId": "3f0a1b2c-4d5e-6f70-8192-a3b4c5d6e7f8",
"clientId": "zb-banking-backoffice",
"role": "member",
"status": "ACTIVE",
"grantedBy": "b61c83a4-7d52-4ca1-9bcf-c0030cb09dfa",
"grantedByClient": "zb-id-backoffice",
"createdAt": "2026-08-19T09:00:00Z",
"updatedAt": "2026-08-19T09:00:00Z"
}
Response (400)
{
"error": "BAD_REQUEST",
"message": "Unknown clientId",
"status": 400,
"timestamp": "2026-08-19T09:00:00Z"
}
There is no Try it panel on this endpoint or the one below. Both write to the staging registry, and a documentation page is the wrong place to grant somebody real access by accident.
Revoke any membership
Revokes a membership by id, whichever app owns it. Requires admin:users. The row is set to REVOKED rather than deleted, so the grant and revoke history survives.
This is the administrator's path and is separate from DELETE /v1/memberships/{id} on purpose. That one still refuses to touch a membership belonging to another client, so an app holding a client-credentials token cannot reach across into someone else's roll.
Status codes
- Name
204- Type
- No Content
- Description
The membership is now
REVOKED.
- Name
403- Type
- Forbidden
- Description
The token does not carry
admin:users.
- Name
404- Type
- Not Found
- Description
No membership with that id exists.
- Name
409- Type
- Conflict
- Description
Refused as a self-lockout: the membership is the caller's own, on the very app their token was minted for. Ask another administrator to make the change.
Request
curl -X DELETE https://id.zb.co.zw/v1/admin/memberships/9c1e5f2a-7b3d-4e8a-a1c2-3d4e5f6a7b8c \
-H "Authorization: Bearer <admin_access_token>"
Response
204 No Content
Response (409)
{
"error": "CONFLICT",
"message": "This is your own membership on the app you are currently signed in through. Revoking it could lock you out of this tool, so the change is refused. Ask another administrator to make it.",
"status": 409,
"timestamp": "2026-08-19T09:00:00Z"
}