User Deprovisioning
Learn how to manage deprovisioning users within your application.
Wristband Managed Users OnlyThis page covers deprovisioning users who are managed by Wristband. If your application owns and stores its own users instead (meaning Wristband only brokers SSO authentication), refer to SSO-Only User Deprovisioning instead.
User deprovisioning is the process of removing or deactivating a user in Wristband.
Your customers may need to deprovision users from their tenant for several reasons, including:
- Employee offboarding: Removing access when an employee leaves the organization.
- Access revocation: Revoking a user's access to your application.
- Seat management: Freeing up user seats to stay within subscription limits or reduce licensing costs.
This guide covers several approaches to deprovisioning users in Wristband.
Ways to Deprovision a User
There are several ways to handle user deprovisioning in Wristband, depending on your customers' requirements and the level of automation you want to provide.
Option 1: Rely on Enterprise IdP Access Controls
For customers who use an enterprise IdP to authenticate with your application, you may not need to implement explicit user deprovisioning in Wristband. With this approach, the user's record remains in Wristband, but they can no longer authenticate through the enterprise IdP once their account is deactivated in the upstream identity provider (e.g., Okta or Microsoft Entra ID).
This approach may be sufficient if your enterprise customers are comfortable relying on their IdP to control access. However, it does not remove or deactivate the user's record in Wristband.
Option 2: Handle Deprovisioning Manually Through Support
You can manage deprovisioning through a manual administrative workflow. When an enterprise customer offboards an employee, they submit a support ticket requesting access removal. Your operations team then deactivates or deletes the user in the Wristband Dashboard.
This approach avoids the need for additional development but introduces operational overhead.
Option 3: Build a Customer Portal for User Management
You can use Wristband's APIs to build a customer portal that allows your customers to manage users within their tenants, including deactivating or deleting users as needed.
This approach reduces the operational burden on your support team by allowing customers to manage deprovisioning themselves. However, it still requires customers to manually initiate deprovisioning through the portal, which can be inefficient when managing large numbers of users.
Option 4: Automate Deprovisioning With a Wrapper API
You can build a dedicated API layer, referred to throughout this guide as a wrapper API, that allows your customers to trigger user deprovisioning programmatically. This eliminates the need for manual support tickets and enables a more scalable, automated workflow.
The following sections outline the architectural steps for implementing the wrapper API approach.
How the Wrapper API Works
When a customer initiates an offboarding event, their system sends a request to your custom deprovisioning endpoint. Because the customer's directory tools integrate with your application rather than directly with Wristband, the offboarding request won't include a Wristband userId. Instead, your endpoint should accept a user identifier from the customer's directory payload, typically an email or externalId, and use it to identify the corresponding Wristband user.
Your endpoint should:
- Authenticate the incoming customer request.
- Validate the target user's
externalIdoremailfrom the request. - Authenticate itself to Wristband to obtain an access token for making API calls.
- Look up the corresponding Wristband user by tenant and
externalIdoremailto get their WristbanduserId. - Use that
userIdto deactivate or delete the user.
An end-to-end user deprovisioning request follows this operational flow:
%%{init: {'themeVariables': {'fontSize': '16px'}}}%%
sequenceDiagram
participant Customer as Customer's backend
participant Wrapper as Your Application APIs
participant Token as Wristband Token Endpoint
participant API as Wristband User APIs
Customer->>Wrapper: Token request (client creds)
Wrapper->>Token: Proxy request
Token-->>Wrapper: Access token
Wrapper-->>Customer: Access token
Customer->>Wrapper: Deprovision request (Bearer token)
Wrapper->>Wrapper: Validate token
Wrapper->>Token: Request own token
Token-->>Wrapper: Access token
Wrapper->>API: Look up + deprovision user
API-->>Wrapper: 200 or 204
Wrapper-->>Customer: 200 or 204
1) Create a Tenant-Level M2M Client for Your Customer
Already Have Your Own API Key System?Steps 1 and 2 are only required if you're using Wristband to generate API keys for your application. If you already have your own API key system, you can continue using it to secure your endpoints and skip directly to Step 3.
To secure requests to your wrapper API, create a dedicated tenant-level Machine-to-Machine (M2M) client for each customer. Each customer can use their clientId and clientSecret to obtain a Wristband access token and include it as a bearer token in requests to your API. Your wrapper API must validate the access token before processing any deprovisioning requests.
For instructions on creating tenant-level M2M clients, see the Tenant-Level Clients guide. Because these clients are used only to authenticate requests to your wrapper API, not to call Wristband APIs directly, they don't need any Wristband resource permissions.
Once you've created the client, share the following information with your customer through a secure channel:
| Field | Description |
|---|---|
| Client ID | The clientId of the tenant-level M2M client you created for this customer. |
| Client Secret | The clientSecret of the tenant-level M2M client you created for this customer. |
2) Create a Token Endpoint Wrapper API
Expose a token-exchange endpoint in your wrapper API to allow customers to obtain Wristband access tokens securely. Your endpoint must accept the customer's tenant-level M2M credentials, forward them to the Wristband Token Endpoint using the client_credentials grant type, and return the resulting access token to the caller.
Suggested Specification
The following section provides a suggested specification for your custom token-exchange endpoint. You can adapt the endpoint design and implementation to fit your application's requirements.
Request
POST /api/v1/auth/token HTTP/1.1
Host: yourapp.com
Content-Type: application/json
{
"clientId": "abc123",
"clientSecret": "def456"
}Headers
| Name | Required | Description |
|---|---|---|
Content-Type | Yes | Must be application/json. |
Body
| Field | Type | Required | Description |
|---|---|---|---|
clientId | string | Yes | The clientId of the tenant-level M2M client you issued to this customer. |
clientSecret | string | Yes | The clientSecret of the tenant-level M2M client you issued to this customer. |
Response
HTTP/1.1 200 OK
Content-Type: application/json
{
"accessToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"expiresIn": 1800
}HTTP Response Codes
| Status | Description |
|---|---|
200 OK | The credentials were valid, and an access token is included in the response body. |
400 Bad Request | The request body was missing clientId or clientSecret. |
401 Unauthorized | The clientId and clientSecret combination was invalid. |
Code Example
For example, a TypeScript function that proxies the request to Wristband's Token Endpoint:
async function handleTokenRequest(clientId: string, clientSecret: string) {
const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
const response = await fetch(`https://${process.env.APPLICATION_VANITY_DOMAIN}/api/v1/oauth2/token`, {
method: 'POST',
headers: {
Authorization: `Basic ${credentials}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'grant_type=client_credentials',
});
if (!response.ok) {
throw new Error(`Failed to get token: ${response.status}`);
}
const { access_token, expires_in } = await response.json();
return { accessToken: access_token, expiresIn: expires_in };
}3) Create a Deprovision Endpoint Wrapper API
Your custom deprovisioning endpoint acts as a bridge between your customer's identity system and Wristband. Wristband's user deactivation and deletion APIs require a Wristband userId, while your customers typically identify users by an upstream identifier, such as externalId or email. Your endpoint must accept these identifiers, resolve the corresponding Wristband userId using your internal database or the Wristband Query Tenant Users API, and then perform the requested deprovisioning operation.
Suggested Specification
The following is a recommended API specification for your custom deprovisioning endpoint. You can adapt it to fit your application's architecture and requirements.
Request
POST /api/v1/users/deprovision HTTP/1.1
Host: yourapp.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
{
"email": "[email protected]"
}Headers
| Name | Required | Description |
|---|---|---|
Authorization | Yes | A Bearer token authenticating your customer's call. See Validate Your Customer's Requests. |
Content-Type | Yes | Must be application/json. |
Body
| Field | Type | Required | Description |
|---|---|---|---|
externalId | string | Conditionally | The user's identifier in your customer's identity provider. Required if email isn't provided. |
email | string | Conditionally | The user's email address. Required if externalId isn't provided. |
User Identifier RequirementsProvide exactly one of
externalIdor
Response
HTTP/1.1 204 No ContentHTTP Response Codes
| Status | Description |
|---|---|
204 No Content | The user was successfully deprovisioned |
400 Bad Request | The request body was missing required fields, or included both externalId and email. |
401 Unauthorized | The Bearer token was missing, invalid, or failed tenant validation. |
404 Not Found | No user was found matching the provided externalId or email. |
Validate Customer Requests
Use the Wristband JWT SDK to validate incoming access tokens at your deprovisioning endpoint. Extract the tnt_id claim from the validated token to identify the tenant associated with the request, since the request payload doesn't include a tenant identifier.
Using Your Own API KeysIf you're using your own API key system, validate incoming requests using your existing authentication mechanism. You'll also need an alternative way to determine the tenant associated with each request, since your API keys won't necessarily contain a
tnt_idclaim.
Code Example
The following example demonstrates how to use the Wristband TypeScript JWT SDK to validate the incoming access token and extract the tnt_id claim:
import { createWristbandJwtValidator } from '@wristband/typescript-jwt';
// Create once and reuse across requests.
const wristbandJwtValidator = createWristbandJwtValidator({
wristbandApplicationVanityDomain: process.env.APPLICATION_VANITY_DOMAIN!,
});
async function validateCustomerRequest(authorizationHeader: string): Promise<string> {
// Pull the raw Bearer token out of the Authorization header.
const token = wristbandJwtValidator.extractBearerToken(authorizationHeader);
// Verify the token's signature, issuer, and expiration.
const result = await wristbandJwtValidator.validate(token);
if (!result.isValid) {
throw new Error(`Invalid token: ${result.errorMessage}`);
}
// A valid signature means this tnt_id can be trusted as the calling customer's tenant.
const tenantId = result.payload?.tnt_id;
if (!tenantId) {
throw new Error('Token is missing a tenant ID');
}
return tenantId;
}Authenticate Requests to Wristband
Use a dedicated application-level M2M client that you control to authenticate requests from your wrapper API to Wristband.
To set up your client, follow the Application-Level Clients guide.
Assign the client a role with the Application permission boundary to allow it to query and manage users across tenants. Then, grant the role the permissions listed in the table below based on your deprovisioning requirements.
| Permission | Used For |
|---|---|
user:read | Looking up a user by email or externalId |
user:update, user:manage-status | Deactivating a user (updating status to INACTIVE) |
user:delete | Deleting a user |
client:rotate-secret | Rotating your customer's tenant-level client's secret on their behalf. Only needed if you're exposing a secret rotation endpoint (covered later in this page). |
Code Example
To authenticate requests to Wristband APIs, your wrapper API needs an access token. You can retrieve tokens directly from the Token Endpoint or use the Wristband M2M SDK, which automatically retrieves and caches tokens in memory.
The following example shows how to initialize the TypeScript M2M SDK and use it to retrieve an access token.
import { createWristbandM2MClient } from '@wristband/typescript-m2m-auth';
const wristbandM2MClient = createWristbandM2MClient({
clientId: process.env.APPLICATION_CLIENT_ID!,
clientSecret: process.env.APPLICATION_CLIENT_SECRET!,
wristbandApplicationVanityDomain: process.env.APPLICATION_VANITY_DOMAIN!,
});
const accessToken = await wristbandM2MClient.getToken();Look Up the User in Wristband
Your deprovisioning endpoint needs to identify the correct Wristband user before deprovisioning them. Enterprise identity providers typically identify users by an externalId or email, while Wristband uses its own userId.
To find the corresponding Wristband user, call the Query Tenant Users API with a filter matching the user's externalId or email.
If the tenant has only one identity provider enabled, the query should return at most one matching user because externalId and email are unique within a tenant and identity provider. Extract the user's userId from the response and use it in subsequent deprovisioning operations.
Already Mapping Users to Wristband IDs?If your application already stores the Wristband
userIdwith each local user record, you can retrieve it directly from your database instead of calling the Query Tenant Users API.
Code Example
The following example demonstrates how to call the Wristband Query Tenant Users API using TypeScript to resolve the Wristband userId:
async function findUserIdByEmail(tenantId: string, email: string, accessToken: string): Promise<string> {
const query = encodeURIComponent(`email eq '${email}'`);
const response = await fetch(
`https://${process.env.APPLICATION_VANITY_DOMAIN}/api/v1/tenants/${tenantId}/users?query=${query}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);
if (!response.ok) {
throw new Error(`Failed to query user: ${response.status}`);
}
const { items } = await response.json();
if (items.length === 0) {
throw new Error('No matching user found');
}
return items[0].id;
}async function findUserIdByExternalId(tenantId: string, externalId: string, accessToken: string): Promise<string> {
const query = encodeURIComponent(`externalId eq '${externalId}'`);
const response = await fetch(
`https://${process.env.APPLICATION_VANITY_DOMAIN}/api/v1/tenants/${tenantId}/users?query=${query}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);
if (!response.ok) {
throw new Error(`Failed to query user: ${response.status}`);
}
const { items } = await response.json();
if (items.length === 0) {
throw new Error('No matching user found');
}
return items[0].id;
}Choose a Deprovisioning Method
Wristband supports two ways to deprovision a user, depending on whether you want to retain or permanently remove their user record.
- Deactivate a user: Use the Patch User API to set the user's status to
INACTIVE. This preserves the user record but prevents the user from authenticating. You can restore access later by setting the status back toACTIVE. - Delete a user: Use the Delete User API to permanently delete the user's record. This action cannot be undone.
Code Example
The following TypeScript example demonstrates how to deactivate a user using the Patch User API or permanently remove them using the Delete User API.
async function deactivateUser(userId: string, accessToken: string) {
const response = await fetch(`https://${process.env.APPLICATION_VANITY_DOMAIN}/api/v1/users/$USERID`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ status: 'INACTIVE' }),
});
if (!response.ok) {
throw new Error(`Failed to deactivate user: ${response.status}`);
}
}async function deleteUser(userId: string, accessToken: string) {
const response = await fetch(`https://${process.env.APPLICATION_VANITY_DOMAIN}/api/v1/users/$USERID`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error(`Failed to delete user: ${response.status}`);
}
}Putting It All Together
The following TypeScript example brings together the key components covered in this guide, including token proxying, access token validation, user lookup, and deprovisioning. The example uses framework-agnostic functions that you can adapt to your existing backend infrastructure.
Other Languages and FrameworksIf you're using a different language or framework, see Authentication SDKs for available M2M and JWT validation SDKs.
import { createWristbandM2MClient } from '@wristband/typescript-m2m-auth';
import { createWristbandJwtValidator } from '@wristband/typescript-jwt';
const VANITY_DOMAIN = process.env.APPLICATION_VANITY_DOMAIN!;
// M2M client used to authenticate the wrapper API's own calls to Wristband.
const wristbandM2MClient = createWristbandM2MClient({
clientId: process.env.APPLICATION_CLIENT_ID!,
clientSecret: process.env.APPLICATION_CLIENT_SECRET!,
wristbandApplicationVanityDomain: VANITY_DOMAIN,
});
// JWT validator used to validate the customer's incoming access tokens.
const wristbandJwtValidator = createWristbandJwtValidator({
wristbandApplicationVanityDomain: VANITY_DOMAIN,
});
// Validates a customer's incoming request and returns the tenant it belongs to.
async function validateCustomerRequest(authorizationHeader: string): Promise<string> {
const token = wristbandJwtValidator.extractBearerToken(authorizationHeader);
const result = await wristbandJwtValidator.validate(token);
if (!result.isValid) {
throw new Error(`Invalid token: ${result.errorMessage}`);
}
const tenantId = result.payload?.tnt_id;
if (!tenantId) {
throw new Error('Token is missing a tenant ID');
}
return tenantId;
}
// Resolves a Wristband userId from either an email or an externalId.
async function findUserId(
tenantId: string,
email: string | undefined,
externalId: string | undefined,
accessToken: string
): Promise<string> {
const filter = email ? `email eq '${email}'` : `externalId eq '${externalId}'`;
const query = encodeURIComponent(filter);
const response = await fetch(
`https://${VANITY_DOMAIN}/api/v1/tenants/${tenantId}/users?query=${query}`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
);
if (!response.ok) {
throw new Error(`Failed to query user: ${response.status}`);
}
const { items } = await response.json();
if (items.length === 0) {
throw new Error('No matching user found');
}
return items[0].id;
}
// Deactivates a user identified by email or externalId.
export async function deactivateUserByEmailOrExternalId(
authorizationHeader: string,
email: string | undefined,
externalId: string | undefined
) {
if ((!email && !externalId) || (email && externalId)) {
throw new Error('Provide exactly one of email or externalId');
}
const tenantId = await validateCustomerRequest(authorizationHeader);
const accessToken = await wristbandM2MClient.getToken();
const userId = await findUserId(tenantId, email, externalId, accessToken);
const response = await fetch(`https://${VANITY_DOMAIN}/api/v1/users/$USERID`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'INACTIVE' }),
});
if (!response.ok) {
throw new Error(`Failed to deactivate user: ${response.status}`);
}
}import { createWristbandM2MClient } from '@wristband/typescript-m2m-auth';
import { createWristbandJwtValidator } from '@wristband/typescript-jwt';
const VANITY_DOMAIN = process.env.APPLICATION_VANITY_DOMAIN!;
// M2M client used to authenticate the wrapper API's own calls to Wristband.
const wristbandM2MClient = createWristbandM2MClient({
clientId: process.env.APPLICATION_CLIENT_ID!,
clientSecret: process.env.APPLICATION_CLIENT_SECRET!,
wristbandApplicationVanityDomain: VANITY_DOMAIN,
});
// JWT validator used to validate the customer's incoming access tokens.
const wristbandJwtValidator = createWristbandJwtValidator({
wristbandApplicationVanityDomain: VANITY_DOMAIN,
});
// Validates a customer's incoming request and returns the tenant it belongs to.
async function validateCustomerRequest(authorizationHeader: string): Promise<string> {
const token = wristbandJwtValidator.extractBearerToken(authorizationHeader);
const result = await wristbandJwtValidator.validate(token);
if (!result.isValid) {
throw new Error(`Invalid token: ${result.errorMessage}`);
}
const tenantId = result.payload?.tnt_id;
if (!tenantId) {
throw new Error('Token is missing a tenant ID');
}
return tenantId;
}
// Resolves a Wristband userId from either an email or an externalId.
async function findUserId(
tenantId: string,
email: string | undefined,
externalId: string | undefined,
accessToken: string
): Promise<string> {
const filter = email ? `email eq '${email}'` : `externalId eq '${externalId}'`;
const query = encodeURIComponent(filter);
const response = await fetch(
`https://${VANITY_DOMAIN}/api/v1/tenants/${tenantId}/users?query=${query}`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
);
if (!response.ok) {
throw new Error(`Failed to query user: ${response.status}`);
}
const { items } = await response.json();
if (items.length === 0) {
throw new Error('No matching user found');
}
return items[0].id;
}
// Deletes a user identified by email or externalId.
export async function deleteUserByEmailOrExternalId(
authorizationHeader: string,
email: string | undefined,
externalId: string | undefined
) {
if ((!email && !externalId) || (email && externalId)) {
throw new Error('Provide exactly one of email or externalId');
}
const tenantId = await validateCustomerRequest(authorizationHeader);
const accessToken = await wristbandM2MClient.getToken();
const userId = await findUserId(tenantId, email, externalId, accessToken);
const response = await fetch(`https://${VANITY_DOMAIN}/api/v1/users/$USERID`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) {
throw new Error(`Failed to delete user: ${response.status}`);
}
}Client Secret Rotation (Optional)
You can allow customers to rotate their M2M client secrets programmatically by exposing a secret rotation endpoint.
When a customer requests a rotation, your API uses its application-level M2M client to call the Rotate Client Secret API. The application-level client must have the client:rotate-secret permission assigned to it.
Rotating a secret generates a new primary secret and moves the existing primary secret to the secondary slot, allowing customers to transition to the new credentials without downtime.
Suggested Specification
The following section provides a suggested specification for your custom secret rotation endpoint. You can adapt the endpoint design and request/response payloads to fit your application's requirements.
Request
POST /api/v1/auth/rotate-secret HTTP/1.1
Host: yourapp.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/jsonHeaders
| Name | Required | Description |
|---|---|---|
Authorization | Yes | A Bearer token authenticating your customer's call, the same as the deprovision endpoint. |
Content-Type | Yes | Must be application/json. |
Response
HTTP/1.1 200 OK
Content-Type: application/json
{
"clientSecret": "new-secret-value..."
}HTTP Response Codes
| Status | Description |
|---|---|
200 OK | The secret was rotated, and the new secret is included in the response body. |
401 Unauthorized | The Bearer token was missing, invalid, or failed tenant validation. |
Code Example
You can simplify secret rotation by exposing a single endpoint that handles the entire process. When a customer requests rotation, your backend rotates the client secret and immediately deletes the old secret from the secondary slot using the Delete Secondary Client Secret API. This approach simplifies the customer workflow, but requires them to deploy the new secret immediately because the old secret will no longer be valid.
Alternative: Use Two Separate EndpointsIf customers need time to deploy and verify the new secret before the old one is invalidated, expose two separate endpoints: one to rotate the secret and another to delete the secondary secret.
The customer workflow would be:
- Call your Rotate Secret endpoint to generate a new primary secret.
- Deploy the new secret across their systems and verify that it works.
- Call your Delete Secondary Secret endpoint to invalidate the old secret.
import { createWristbandM2MClient } from '@wristband/typescript-m2m-auth';
import { createWristbandJwtValidator } from '@wristband/typescript-jwt';
const VANITY_DOMAIN = process.env.APPLICATION_VANITY_DOMAIN!;
const wristbandM2MClient = createWristbandM2MClient({
clientId: process.env.APPLICATION_CLIENT_ID!,
clientSecret: process.env.APPLICATION_CLIENT_SECRET!,
wristbandApplicationVanityDomain: VANITY_DOMAIN,
});
const wristbandJwtValidator = createWristbandJwtValidator({
wristbandApplicationVanityDomain: VANITY_DOMAIN,
});
// NOTE: This is the part that could be split out into its own endpoint,
// called separately once your customer has deployed and confirmed the
// new secret. See the callout above.
async function deleteSecondarySecret(customerClientId: string, accessToken: string) {
const response = await fetch(`https://${VANITY_DOMAIN}/api/v1/clients/${customerClientId}/secondary-secret`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) {
throw new Error(`Failed to delete secondary secret: ${response.status}`);
}
}
export async function rotateCustomerClientSecret(authorizationHeader: string): Promise<string> {
const token = wristbandJwtValidator.extractBearerToken(authorizationHeader);
const result = await wristbandJwtValidator.validate(token);
if (!result.isValid) {
throw new Error(`Invalid token: ${result.errorMessage}`);
}
// The client_id claim identifies exactly which client this token was issued to.
const customerClientId = result.payload?.client_id;
if (!customerClientId) {
throw new Error('Token is missing a client ID');
}
const accessToken = await wristbandM2MClient.getToken();
const rotateResponse = await fetch(`https://${VANITY_DOMAIN}/api/v1/clients/${customerClientId}/rotate-secret`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!rotateResponse.ok) {
throw new Error(`Failed to rotate secret: ${rotateResponse.status}`);
}
const { clientSecret } = await rotateResponse.json();
// Delete the old secondary immediately, so the customer only calls one endpoint.
await deleteSecondarySecret(customerClientId, accessToken);
return clientSecret;
}Updated 12 days ago