Manage an organization's members from your product
How a registered product lets an organization's administrator list members, invite people, revoke invitations and remove members from inside the product, with the administrator's own token and permissions.
On this page
Status: In development
The member API is built and tested but not released, and it is closed by default: TDACorp enables it for a registered product, one product at a time. Until it is enabled for your product, every route on this page is refused: with the same 403 as any other refusal once it is enabled for some product, and with 404 while it is enabled for none.
Some products have their own screen for an organization's people: a client portal, say, where the organization's administrator invites a colleague or removes someone who has left. This page describes the routes that let your product do that without holding any platform authority of its own. The product forwards the administrator's own access token, and identity decides everything from that person's permissions.
Who may use it#
All of these must hold:
- Your client is registered with identity and belongs to a product. See Get a client registered.
- TDACorp has enabled the member API for your client. It is off for every client until then.
- Your product has an active installation in the organization. See Installations for app developers.
- The person calling is a current member of that organization and holds the permission to manage its members (below).
- Your client is registered with the
tdaid.membersscope. A client registered by a person through their account, and a client identified by a client ID metadata document, cannot hold it. The organization namedinternalis never reachable through this API.
How your product calls it#
- Ask for the scope
tdaid.memberswhen the administrator signs in, with the others you need. On the consent screen it reads Manage members of your organization: "Lets the app list, invite and remove the members of an organization you administer, as you, using only the permissions you already have there. It can see their names and email addresses." See Scopes and consent. - Call the routes with that administrator's own user access token. A token for your client alone, from
client_credentials, does not work: there is no person in it. - Present the token with DPoP. The token must be bound to a key (a DPoP key) and sent with
Authorization: DPoP <token>and a proof that carriesath. A token with no key binding is refused, and so is a bound token sent asAuthorization: Bearer. See DPoP.
The token is also refused with 401 when it has an act claim (a delegated or exchanged token), when it is not a person's token, when it names no session, or when the session has ended or is restricted. If the scope is missing from the token, the answer is 403 with a WWW-Authenticate header that names tdaid.members.
Use the refresh token to keep the administrator working. A refresh keeps the key binding and the session. When the administrator signs out, or the session ends, the access token stops working.
What the administrator may do#
The routes act as the person, not as your product.
- Reads need the permission to manage members. Listing members or invitations discloses names and email addresses, so it is not open to every member.
- Inviting someone with a role also needs the permission to create role bindings. An invitation may carry a role only if it is a custom role of that organization, or a role of your own product. A role that holds a high-risk permission, a wildcard permission or any permission of identity itself is refused, as is a role of another product.
- Removing a member follows the same peer rule as the administrator console. The administrator must outrank the member. An administrator cannot remove another administrator, or themselves. The last administrator of an organization is never removed.
- Writes need a fresh second factor. See Step-up.
The routes#
Every route is under /v1/tenants/{tenantId}, where {tenantId} is the organization's identifier, which your product stored when the organization installed it. Bodies are JSON. The generated schemas are in the API reference.
| Method and path | What it does |
|---|---|
GET /members | Lists the organization's members. |
GET /invitations | Lists pending invitations. |
POST /invitations | Invites an email address, optionally with a role. |
DELETE /invitations/{invitationId} | Revokes a pending invitation. |
DELETE /members/{membershipId} | Removes a member. |
List members#
GET /v1/tenants/{tenantId}/members?limit=50&cursor=...
{ "members": [ { "membership_id": "0198a1b2-...", "principal_kind": "human", "status": "active", "kind": "member", "display_name": "Asha Rao", "email": "asha@example.com", "joined_at": "2026-09-01T09:30:00.000Z" } ], "next_cursor": "0198a1b2-..."}A member has a display name and a verified email address, and that is all your product learns about them. There is no account identifier (sub) in the answer, so you cannot join it to your own sign-ins by this call. email is null when the person has no verified primary address, and both email and display_name are null for an agent or a service account. Removed members are not listed; suspended ones are, with their status.
List invitations#
GET /v1/tenants/{tenantId}/invitations?limit=50&cursor=... returns { "invitations": [...], "next_cursor": ... }. Each invitation has id, email, role_id (or null), created_at and expires_at. Only pending invitations are listed. The invitation secret is never returned by any route.
Paging#
Both lists page by cursor. limit is a whole number of 1 or more; the server caps it at a maximum page size, 100 unless TDACorp has set another, and a missing or invalid limit uses the maximum. A page that comes back full carries a next_cursor: pass it as cursor to get the next one. A page that is not full has next_cursor: null, which ends the list. A cursor that is not one the server issued is refused with 400 and invalid_request. Every page you fetch is recorded, so read what you need rather than copying a whole list on a schedule.
Invite#
POST /v1/tenants/{tenantId}/invitations
{ "email": "new.colleague@example.com", "role_id": "0198a1b2-..." }email is required and at most 320 characters. role_id is optional. Unknown fields are refused with 400. The answer is 201:
{ "invitation_id": "0198c3d4-...", "email_dispatched": true, "expires_at": "2026-11-08T09:30:00.000Z" }Identity sends the invitation email. The secret in the link reaches the invitee by email alone: it is not in this response, in the list or in any other call. An organization has a daily limit on invitations; past it the answer is 429 with Retry-After.
Revoke an invitation#
DELETE /v1/tenants/{tenantId}/invitations/{invitationId} answers 200 with { "invitation_id": "...", "revoked": true }. An unknown invitation, or one that belongs to another organization, is 404. An invitation that is no longer pending (accepted, expired or already revoked) is 409.
Remove a member#
DELETE /v1/tenants/{tenantId}/members/{membershipId}
The body is optional: { "suspend_owned": true }. A member who owns agents or service accounts cannot be removed until those are dealt with: the call answers 409 unless you send suspend_owned: true, which suspends them together with the membership. The answer is 200:
{ "membership_id": "0198a1b2-...", "status": "removed", "removed_at": "2026-10-25T10:00:00.000Z" }The person's sessions in that organization end, and the removal is announced to the organization's webhooks like any other removal. An unknown or already removed member is 404.
Errors#
| Status | When | What to do |
|---|---|---|
400 | invalid_request: a bad body, a field that is not allowed, a role_id that is not an identifier, or a bad cursor. | Fix the request. |
401 | No token, a bad token, a token without a key binding, an act token, an ended session. Or insufficient_user_authentication: the sign-in is not fresh or strong enough. | For the second, see Step-up. Otherwise sign the administrator in again. |
403 | Every other refusal, with the same body each time. | Show a neutral message. |
404 | not_found: no such invitation or member in this organization. Or the whole route family, while it is not enabled for any product. | |
409 | conflict: a member who owns agents or service accounts (send suspend_owned), or an invitation that is no longer pending. | |
429 | rate_limited: too many calls, or the daily invitation limit. Retry-After says when. | Wait, then retry. |
The 403 is the same body for an organization that does not exist, one your product is not installed in, a caller who is not a member, a caller without the permission, a client TDACorp has not enabled, a role that may not be invited, and a member the caller may not remove:
{ "error": "forbidden", "error_description": "this tenant is not available to you through this application" }It is identical on purpose. Your product cannot tell the causes apart, and it cannot use these routes to discover which organizations exist or who belongs to them. Do not try to explain which cause it was.
The rate limit is kept for each administrator and each client together, so another product's calls for the same person do not spend your allowance. Honour Retry-After; see Rate limits and retries.
Step-up for writes#
A write (invite, revoke, remove) needs a second factor used recently. A read does not. When the administrator's session is not recent enough, and only after the caller has been accepted for everything else, the answer is 401 with the challenge from RFC 9470:
HTTP/1.1 401WWW-Authenticate: DPoP error="insufficient_user_authentication", error_description="this action needs a second factor used recently", acr_values="aal2", max_age="1800"{ "error": "insufficient_user_authentication", "error_description": "this action needs a second factor used recently", "acr_values": "aal2", "max_age": 1800 }Send the administrator through a new authorization that asks for acr_values and max_age from the challenge, for the same organization and with the same tdaid.members scope. Then retry the call with the new access token. With the SDK, rp.stepUpUrl(challenge, tenant) builds that request. See Step-up and SSO enforcement; a person's view of it is in Step-up. Do not retry the same token: it will get the same answer.
An organization that requires single sign-on can send the same challenge, with the acr_values and max_age it needs, on a read as well as a write. Answer it the same way.
An example call#
The administrator's access token is bound to your key. You need a proof for each request, with the method, the full address without a query, the hash of the token (ath) and the latest DPoP-Nonce from identity.
curl -s https://identity.tdacorp.in/v1/tenants/$TENANT_ID/invitations \ -X POST \ -H "Authorization: DPoP $ACCESS_TOKEN" \ -H "DPoP: $PROOF" \ -H "Content-Type: application/json" \ -d '{"email":"new.colleague@example.com"}'A 201 answers with the invitation, and the next call needs a proof with the nonce from the DPoP-Nonce response header. How to build the proof and answer a use_dpop_nonce challenge is in DPoP.
What the organization sees#
Every call that reads a list or changes a member is recorded in the organization's audit log, with the administrator who acted and which product acted for them. A removal and an invitation also appear as they do when an administrator acts in the console. The organization's administrators can see what your product did on their behalf.
Related#
- DPoP for the proof and the nonce.
- Step-up and SSO enforcement for the challenge.
- Scopes and consent for asking for
tdaid.members. - Installations for app developers for the entry rule.
- Get a client registered for registering your client.
- API reference and Scopes.