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 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_id of the app the identity is a member of.

  • Name
    role
    Type
    string
    Description

    The app-scoped role or label, for example member, admin, or super_admin. Defaults to member.

  • Name
    status
    Type
    string
    Description

    ACTIVE or REVOKED.

  • Name
    grantedBy
    Type
    string | null
    Description

    The user UUID that granted the membership, if recorded.

  • Name
    grantedByClient
    Type
    string
    Description

    The client_id that 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"
}

GET/v1/memberships

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 returns 403 FORBIDDEN.

Request

GET
/v1/memberships
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"
  }
]
GET/v1/membershipsTry it

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.

No token yet. Sign in above, or paste one below.
Query parameters

POST/v1/memberships

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 userId is 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

POST
/v1/memberships
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"
}

DELETE/v1/memberships/{id}

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

DELETE
/v1/memberships/{id}
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/login and use the returned accessToken. A client-credentials token is accepted too, but it records no human against the grant.

  • Name
    Permission
    Type
    admin:users
    Description

    The token's scopes claim must contain admin:users. That comes from the signed-in user's roles, not from the OAuth client's scopes column, so an operator with the admin role holds it.

  • Name
    clientId
    Type
    required, any app
    Description

    clientId names 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.


GET/v1/admin/memberships

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 example zb-banking-backoffice. A missing value returns 400 BAD_REQUEST.

  • Name
    page
    Type
    integer
    Description

    Optional, zero-based. Defaults to 0.

  • Name
    size
    Type
    integer
    Description

    Optional. Defaults to 25 and is capped at 100.

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

GET
/v1/admin/memberships
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
}
GET/v1/admin/membershipsTry it

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.

No token yet. Sign in above, or paste one below.
Query parameters

POST/v1/admin/memberships

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_id of 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 userId is 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: clientId absent entirely gives Invalid request body; clientId present but blank gives clientId: clientId is required; neither userId nor email supplied gives Either a userId or an email is required; and a clientId naming an app that is not registered gives Unknown 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 userId or email supplied. An empty body: only a missing subject answers 404, which is what distinguishes it from an unregistered app.

Request

POST
/v1/admin/memberships
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"
}

DELETE/v1/admin/memberships/{id}

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

DELETE
/v1/admin/memberships/{id}
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"
}

Was this page helpful?