Identity Provider (IDP) & MFA
Orchestrate user identities, implement multi-factor authentication (TOTP), and manage security audit trails.
Identity Provider (IDP) API
The Identity Provider (IDP) service centralizes user identity management and provides robust authentication layers. It allows organizations to link external user identifiers to a secure, managed identity capable of passwordless email magic links, Multi-Factor Authentication (MFA) via Time-based One-Time Passwords (TOTP), passkeys, and advanced risk assessment (Sentinel).
For the email account association and passwordless flow, see IDP Email Magic Links →.
For provisioning and replacing application-owned identity attributes, see IDP User Metadata →.
Identity Management
Provision External User
Provision a new managed identity within the IDP ecosystem. This creates a security context linked to your platform's internal external_id.
idp:writePath, query, and header parameters
No path, query, or additional header parameters.
Request Body
{
"user": {
"external_id": "ORG_USR_9921_AFG",
"metadata": {
"plan": "enterprise",
"region": "eu"
}
}
}user.external_idRequireduser.metadataResponses
User identity successfully provisioned.
{
"user": {
"id": "6908bffd...",
"external_id": "ORG_USR_9921_AFG",
"status": "active",
"metadata": {
"plan": "enterprise",
"region": "eu"
}
}
}Identity already exists for the specified external_id.
{
"error": "User with this external_id already exists for this company"
}Request examples
curl --request POST 'https://api.hub.donutwork.com/2026-02-01/idp/user.json' \
--header 'Authorization: Bearer YOUR_API_KEY' \
--header 'Content-Type: application/json' \
--data '{
"user": {
"external_id": "ORG_USR_9921_AFG",
"metadata": {
"plan": "enterprise",
"region": "eu"
}
}
}'const response = await fetch('https://api.hub.donutwork.com/2026-02-01/idp/user.json', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.DONUTWORK_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"user": {
"external_id": "ORG_USR_9921_AFG",
"metadata": {
"plan": "enterprise",
"region": "eu"
}
}
}),
});
const requestId = response.headers.get('x-request-id');
if (!response.ok) {
const error = await response.text();
throw new Error(`${response.status} ${error} [${requestId}]`);
}
const contentType = response.headers.get('content-type') ?? '';
const data = response.status === 204 ? null
: contentType.includes('application/json') ? await response.json()
: await response.text();$request = curl_init('https://api.hub.donutwork.com/2026-02-01/idp/user.json');
curl_setopt_array($request, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DONUTWORK_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => '{
"user": {
"external_id": "ORG_USR_9921_AFG",
"metadata": {
"plan": "enterprise",
"region": "eu"
}
}
}',
]);
$response = curl_exec($request);
if ($response === false) {
$error = curl_error($request);
curl_close($request);
throw new RuntimeException($error);
}
$status = curl_getinfo($request, CURLINFO_RESPONSE_CODE);
$contentType = (string) curl_getinfo($request, CURLINFO_CONTENT_TYPE);
curl_close($request);
if ($status >= 400) { throw new RuntimeException($response); }
$data = $status === 204 ? null
: (str_contains($contentType, 'application/json')
? json_decode($response, true, flags: JSON_THROW_ON_ERROR) : $response);Replace uppercase placeholders and sample values with your own data and company resource IDs. Keep the API key on a trusted server. Reuse an idempotency key only for the same logical request and payload.
Retrieve Identity Profile
Fetch the security profile for a specific user. This includes authentication capabilities (MFA status), operational status (locked/active), and audit metadata.
idp:readPath, query, and header parameters
externalUserIdRequiredResponses
Identity profile retrieved.
{
"id": "6908...",
"external_id": "ORG_USR_9921_AFG",
"status": "active",
"has_otp": true,
"has_passkeys": false,
"external_accounts": [
{
"handler": "email",
"identifier": "alexander@enterprise.com",
"status": "verified",
"verified_at": "2026-09-13T21:25:14+00:00"
}
],
"is_locked": false,
"failed_attempts": 0,
"saas_client_id": "67c9..."
}Request examples
curl --request GET 'https://api.hub.donutwork.com/2026-02-01/idp/EXTERNALUSERID.json' \
--header 'Authorization: Bearer YOUR_API_KEY'const response = await fetch('https://api.hub.donutwork.com/2026-02-01/idp/EXTERNALUSERID.json', {
method: 'GET',
headers: {
Authorization: 'Bearer ' + process.env.DONUTWORK_API_KEY,
},
});
const requestId = response.headers.get('x-request-id');
if (!response.ok) {
const error = await response.text();
throw new Error(`${response.status} ${error} [${requestId}]`);
}
const contentType = response.headers.get('content-type') ?? '';
const data = response.status === 204 ? null
: contentType.includes('application/json') ? await response.json()
: await response.text();$request = curl_init('https://api.hub.donutwork.com/2026-02-01/idp/EXTERNALUSERID.json');
curl_setopt_array($request, [
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DONUTWORK_API_KEY'),
],
]);
$response = curl_exec($request);
if ($response === false) {
$error = curl_error($request);
curl_close($request);
throw new RuntimeException($error);
}
$status = curl_getinfo($request, CURLINFO_RESPONSE_CODE);
$contentType = (string) curl_getinfo($request, CURLINFO_CONTENT_TYPE);
curl_close($request);
if ($status >= 400) { throw new RuntimeException($response); }
$data = $status === 204 ? null
: (str_contains($contentType, 'application/json')
? json_decode($response, true, flags: JSON_THROW_ON_ERROR) : $response);Replace uppercase placeholders and sample values with your own data and company resource IDs. Keep the API key on a trusted server. Reuse an idempotency key only for the same logical request and payload.
Replace User Metadata
Create or replace the complete application-owned metadata object for an existing identity. Sending an empty object clears all metadata. Keys cannot contain dots or start with $; values must be valid JSON values.
idp:writePath, query, and header parameters
externalUserIdRequiredRequest Body
{
"user": {
"metadata": {
"plan": "enterprise",
"region": "eu",
"features": [
"sso",
"audit"
]
}
}
}user.metadataRequiredResponses
Metadata replaced.
{
"user": {
"id": "6908...",
"external_id": "ORG_USR_9921_AFG",
"metadata": {
"plan": "enterprise",
"region": "eu",
"features": [
"sso",
"audit"
]
}
}
}The identity does not exist in the authenticated company.
The metadata is not a valid object or exceeds a structural limit.
Request examples
curl --request PUT 'https://api.hub.donutwork.com/2026-02-01/idp/EXTERNALUSERID/metadata.json' \
--header 'Authorization: Bearer YOUR_API_KEY' \
--header 'Content-Type: application/json' \
--data '{
"user": {
"metadata": {
"plan": "enterprise",
"region": "eu",
"features": [
"sso",
"audit"
]
}
}
}'const response = await fetch('https://api.hub.donutwork.com/2026-02-01/idp/EXTERNALUSERID/metadata.json', {
method: 'PUT',
headers: {
Authorization: 'Bearer ' + process.env.DONUTWORK_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"user": {
"metadata": {
"plan": "enterprise",
"region": "eu",
"features": [
"sso",
"audit"
]
}
}
}),
});
const requestId = response.headers.get('x-request-id');
if (!response.ok) {
const error = await response.text();
throw new Error(`${response.status} ${error} [${requestId}]`);
}
const contentType = response.headers.get('content-type') ?? '';
const data = response.status === 204 ? null
: contentType.includes('application/json') ? await response.json()
: await response.text();$request = curl_init('https://api.hub.donutwork.com/2026-02-01/idp/EXTERNALUSERID/metadata.json');
curl_setopt_array($request, [
CURLOPT_CUSTOMREQUEST => 'PUT',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DONUTWORK_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => '{
"user": {
"metadata": {
"plan": "enterprise",
"region": "eu",
"features": [
"sso",
"audit"
]
}
}
}',
]);
$response = curl_exec($request);
if ($response === false) {
$error = curl_error($request);
curl_close($request);
throw new RuntimeException($error);
}
$status = curl_getinfo($request, CURLINFO_RESPONSE_CODE);
$contentType = (string) curl_getinfo($request, CURLINFO_CONTENT_TYPE);
curl_close($request);
if ($status >= 400) { throw new RuntimeException($response); }
$data = $status === 204 ? null
: (str_contains($contentType, 'application/json')
? json_decode($response, true, flags: JSON_THROW_ON_ERROR) : $response);Replace uppercase placeholders and sample values with your own data and company resource IDs. Keep the API key on a trusted server. Reuse an idempotency key only for the same logical request and payload.
Multi-Factor Authentication (MFA)
Configuration of TOTP Secret
Generate a new TOTP secret and QR code payload to enable secondary authentication. Scanning this payload into an authenticator application (e.g., Microsoft/Google Authenticator) completes the setup.
idp:writePath, query, and header parameters
externalUserIdRequiredRequest Body
{
"otp": {
"issuer_name": "Acme Enterprise",
"account_name": "alexander@enterprise.com"
}
}otp.issuer_nameRequiredotp.account_nameRequiredResponses
New MFA secret successfully generated.
{
"user": {
"external_id": "ORG_USR_9921_AFG",
"new_otp_secret": "REDACTED_OTP_SECRET",
"qrcode_payload": "otpauth://totp/Acme+Enterprise:alexander%40enterprise.com?secret=REDACTED_OTP_SECRET&issuer=Acme+Enterprise",
"is_setup_complete": true
}
}Request examples
curl --request POST 'https://api.hub.donutwork.com/2026-02-01/idp/EXTERNALUSERID/otp.json' \
--header 'Authorization: Bearer YOUR_API_KEY' \
--header 'Content-Type: application/json' \
--data '{
"otp": {
"issuer_name": "Acme Enterprise",
"account_name": "alexander@enterprise.com"
}
}'const response = await fetch('https://api.hub.donutwork.com/2026-02-01/idp/EXTERNALUSERID/otp.json', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.DONUTWORK_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"otp": {
"issuer_name": "Acme Enterprise",
"account_name": "alexander@enterprise.com"
}
}),
});
const requestId = response.headers.get('x-request-id');
if (!response.ok) {
const error = await response.text();
throw new Error(`${response.status} ${error} [${requestId}]`);
}
const contentType = response.headers.get('content-type') ?? '';
const data = response.status === 204 ? null
: contentType.includes('application/json') ? await response.json()
: await response.text();$request = curl_init('https://api.hub.donutwork.com/2026-02-01/idp/EXTERNALUSERID/otp.json');
curl_setopt_array($request, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DONUTWORK_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => '{
"otp": {
"issuer_name": "Acme Enterprise",
"account_name": "alexander@enterprise.com"
}
}',
]);
$response = curl_exec($request);
if ($response === false) {
$error = curl_error($request);
curl_close($request);
throw new RuntimeException($error);
}
$status = curl_getinfo($request, CURLINFO_RESPONSE_CODE);
$contentType = (string) curl_getinfo($request, CURLINFO_CONTENT_TYPE);
curl_close($request);
if ($status >= 400) { throw new RuntimeException($response); }
$data = $status === 204 ? null
: (str_contains($contentType, 'application/json')
? json_decode($response, true, flags: JSON_THROW_ON_ERROR) : $response);Replace uppercase placeholders and sample values with your own data and company resource IDs. Keep the API key on a trusted server. Reuse an idempotency key only for the same logical request and payload.
Verify Authentication Code
Validate a 6-digit TOTP code (or a one-time backup code) to finalize the authentication process.
idp:writePath, query, and header parameters
No path, query, or additional header parameters.
Request Body
{
"otp": {
"id": "ORG_USR_9921_AFG",
"code": "552192"
}
}otp.idRequiredotp.codeRequiredResponses
Authentication successful.
{
"status": "success",
"id": "ORG_USR_9921_AFG",
"message": "OTP verified successfully."
}Request examples
curl --request POST 'https://api.hub.donutwork.com/2026-02-01/idp/otp/verify.json' \
--header 'Authorization: Bearer YOUR_API_KEY' \
--header 'Content-Type: application/json' \
--data '{
"otp": {
"id": "ORG_USR_9921_AFG",
"code": "552192"
}
}'const response = await fetch('https://api.hub.donutwork.com/2026-02-01/idp/otp/verify.json', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.DONUTWORK_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"otp": {
"id": "ORG_USR_9921_AFG",
"code": "552192"
}
}),
});
const requestId = response.headers.get('x-request-id');
if (!response.ok) {
const error = await response.text();
throw new Error(`${response.status} ${error} [${requestId}]`);
}
const contentType = response.headers.get('content-type') ?? '';
const data = response.status === 204 ? null
: contentType.includes('application/json') ? await response.json()
: await response.text();$request = curl_init('https://api.hub.donutwork.com/2026-02-01/idp/otp/verify.json');
curl_setopt_array($request, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DONUTWORK_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => '{
"otp": {
"id": "ORG_USR_9921_AFG",
"code": "552192"
}
}',
]);
$response = curl_exec($request);
if ($response === false) {
$error = curl_error($request);
curl_close($request);
throw new RuntimeException($error);
}
$status = curl_getinfo($request, CURLINFO_RESPONSE_CODE);
$contentType = (string) curl_getinfo($request, CURLINFO_CONTENT_TYPE);
curl_close($request);
if ($status >= 400) { throw new RuntimeException($response); }
$data = $status === 204 ? null
: (str_contains($contentType, 'application/json')
? json_decode($response, true, flags: JSON_THROW_ON_ERROR) : $response);Replace uppercase placeholders and sample values with your own data and company resource IDs. Keep the API key on a trusted server. Reuse an idempotency key only for the same logical request and payload.
Provision Backup Codes
Generate a collection of single-use backup codes. These allow users to regain access if the primary MFA device is unavailable.
idp:writePath, query, and header parameters
externalUserIdRequiredResponses
Set of 10 backup codes generated.
{
"user": {
"id": "ORG_USR_9921_AFG",
"backupCodes": [
"ABCD-1234",
"EFGH-5678",
"..."
]
}
}Request examples
curl --request POST 'https://api.hub.donutwork.com/2026-02-01/idp/EXTERNALUSERID/backupcodes.json' \
--header 'Authorization: Bearer YOUR_API_KEY'const response = await fetch('https://api.hub.donutwork.com/2026-02-01/idp/EXTERNALUSERID/backupcodes.json', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.DONUTWORK_API_KEY,
},
});
const requestId = response.headers.get('x-request-id');
if (!response.ok) {
const error = await response.text();
throw new Error(`${response.status} ${error} [${requestId}]`);
}
const contentType = response.headers.get('content-type') ?? '';
const data = response.status === 204 ? null
: contentType.includes('application/json') ? await response.json()
: await response.text();$request = curl_init('https://api.hub.donutwork.com/2026-02-01/idp/EXTERNALUSERID/backupcodes.json');
curl_setopt_array($request, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DONUTWORK_API_KEY'),
],
]);
$response = curl_exec($request);
if ($response === false) {
$error = curl_error($request);
curl_close($request);
throw new RuntimeException($error);
}
$status = curl_getinfo($request, CURLINFO_RESPONSE_CODE);
$contentType = (string) curl_getinfo($request, CURLINFO_CONTENT_TYPE);
curl_close($request);
if ($status >= 400) { throw new RuntimeException($response); }
$data = $status === 204 ? null
: (str_contains($contentType, 'application/json')
? json_decode($response, true, flags: JSON_THROW_ON_ERROR) : $response);Replace uppercase placeholders and sample values with your own data and company resource IDs. Keep the API key on a trusted server. Reuse an idempotency key only for the same logical request and payload.
Passkey (WebAuthn) Delegation
For architecture, benefits and production integration pattern (backend relay S2S), see:
Create Registration Options
Generate challenge and WebAuthn options used by the client to create a passkey credential.
idp:writePath, query, and header parameters
externalUserIdRequiredRequest Body
{
"passkey": {
"display_name": "Alexander",
"account_name": "alexander@enterprise.com"
}
}passkey.display_namepasskey.account_nameResponses
Registration options generated.
{
"status": "success",
"passkey": {
"challenge_id": "ch_01J...",
"options": {
"challenge": "BASE64_CHALLENGE",
"rp": {
"id": "example.com",
"name": "Donutwork"
}
}
}
}Request examples
curl --request POST 'https://api.hub.donutwork.com/2026-02-01/idp/EXTERNALUSERID/passkey/options.json' \
--header 'Authorization: Bearer YOUR_API_KEY' \
--header 'Content-Type: application/json' \
--data '{
"passkey": {
"display_name": "Alexander",
"account_name": "alexander@enterprise.com"
}
}'const response = await fetch('https://api.hub.donutwork.com/2026-02-01/idp/EXTERNALUSERID/passkey/options.json', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.DONUTWORK_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"passkey": {
"display_name": "Alexander",
"account_name": "alexander@enterprise.com"
}
}),
});
const requestId = response.headers.get('x-request-id');
if (!response.ok) {
const error = await response.text();
throw new Error(`${response.status} ${error} [${requestId}]`);
}
const contentType = response.headers.get('content-type') ?? '';
const data = response.status === 204 ? null
: contentType.includes('application/json') ? await response.json()
: await response.text();$request = curl_init('https://api.hub.donutwork.com/2026-02-01/idp/EXTERNALUSERID/passkey/options.json');
curl_setopt_array($request, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DONUTWORK_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => '{
"passkey": {
"display_name": "Alexander",
"account_name": "alexander@enterprise.com"
}
}',
]);
$response = curl_exec($request);
if ($response === false) {
$error = curl_error($request);
curl_close($request);
throw new RuntimeException($error);
}
$status = curl_getinfo($request, CURLINFO_RESPONSE_CODE);
$contentType = (string) curl_getinfo($request, CURLINFO_CONTENT_TYPE);
curl_close($request);
if ($status >= 400) { throw new RuntimeException($response); }
$data = $status === 204 ? null
: (str_contains($contentType, 'application/json')
? json_decode($response, true, flags: JSON_THROW_ON_ERROR) : $response);Replace uppercase placeholders and sample values with your own data and company resource IDs. Keep the API key on a trusted server. Reuse an idempotency key only for the same logical request and payload.
Confirm Registration
Confirm passkey registration by submitting the credential payload generated by the WebAuthn client.
idp:writePath, query, and header parameters
externalUserIdRequiredRequest Body
{
"passkey": {
"challenge_id": "ch_01J...",
"payload": "{\"id\":\"...\",\"rawId\":\"...\",\"response\":{...}}",
"device": "Chrome 125 / macOS"
}
}passkey.challenge_idRequiredpasskey.payloadRequiredpasskey.deviceResponses
Passkey registered successfully.
{
"status": "success",
"passkey": {
"id": "pk_01J...",
"external_id": "ORG_USR_9921_AFG",
"registered": true
}
}Request examples
curl --request POST 'https://api.hub.donutwork.com/2026-02-01/idp/EXTERNALUSERID/passkey/confirm.json' \
--header 'Authorization: Bearer YOUR_API_KEY' \
--header 'Content-Type: application/json' \
--data '{
"passkey": {
"challenge_id": "ch_01J...",
"payload": "{\"id\":\"...\",\"rawId\":\"...\",\"response\":{...}}",
"device": "Chrome 125 / macOS"
}
}'const response = await fetch('https://api.hub.donutwork.com/2026-02-01/idp/EXTERNALUSERID/passkey/confirm.json', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.DONUTWORK_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"passkey": {
"challenge_id": "ch_01J...",
"payload": "{\"id\":\"...\",\"rawId\":\"...\",\"response\":{...}}",
"device": "Chrome 125 / macOS"
}
}),
});
const requestId = response.headers.get('x-request-id');
if (!response.ok) {
const error = await response.text();
throw new Error(`${response.status} ${error} [${requestId}]`);
}
const contentType = response.headers.get('content-type') ?? '';
const data = response.status === 204 ? null
: contentType.includes('application/json') ? await response.json()
: await response.text();$request = curl_init('https://api.hub.donutwork.com/2026-02-01/idp/EXTERNALUSERID/passkey/confirm.json');
curl_setopt_array($request, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DONUTWORK_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => '{
"passkey": {
"challenge_id": "ch_01J...",
"payload": "{\\"id\\":\\"...\\",\\"rawId\\":\\"...\\",\\"response\\":{...}}",
"device": "Chrome 125 / macOS"
}
}',
]);
$response = curl_exec($request);
if ($response === false) {
$error = curl_error($request);
curl_close($request);
throw new RuntimeException($error);
}
$status = curl_getinfo($request, CURLINFO_RESPONSE_CODE);
$contentType = (string) curl_getinfo($request, CURLINFO_CONTENT_TYPE);
curl_close($request);
if ($status >= 400) { throw new RuntimeException($response); }
$data = $status === 204 ? null
: (str_contains($contentType, 'application/json')
? json_decode($response, true, flags: JSON_THROW_ON_ERROR) : $response);Replace uppercase placeholders and sample values with your own data and company resource IDs. Keep the API key on a trusted server. Reuse an idempotency key only for the same logical request and payload.
Create Authentication Challenge
Generate a passkey challenge for login verification.
idp:writePath, query, and header parameters
No path, query, or additional header parameters.
Request Body
{
"passkey": {
"id": "ORG_USR_9921_AFG"
}
}passkey.idRequiredResponses
Authentication challenge generated.
{
"status": "success",
"passkey": {
"challenge_id": "ch_01J...",
"options": {
"challenge": "BASE64_CHALLENGE",
"rpId": "example.com"
}
}
}Request examples
curl --request POST 'https://api.hub.donutwork.com/2026-02-01/idp/passkey/challenge.json' \
--header 'Authorization: Bearer YOUR_API_KEY' \
--header 'Content-Type: application/json' \
--data '{
"passkey": {
"id": "ORG_USR_9921_AFG"
}
}'const response = await fetch('https://api.hub.donutwork.com/2026-02-01/idp/passkey/challenge.json', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.DONUTWORK_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"passkey": {
"id": "ORG_USR_9921_AFG"
}
}),
});
const requestId = response.headers.get('x-request-id');
if (!response.ok) {
const error = await response.text();
throw new Error(`${response.status} ${error} [${requestId}]`);
}
const contentType = response.headers.get('content-type') ?? '';
const data = response.status === 204 ? null
: contentType.includes('application/json') ? await response.json()
: await response.text();$request = curl_init('https://api.hub.donutwork.com/2026-02-01/idp/passkey/challenge.json');
curl_setopt_array($request, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DONUTWORK_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => '{
"passkey": {
"id": "ORG_USR_9921_AFG"
}
}',
]);
$response = curl_exec($request);
if ($response === false) {
$error = curl_error($request);
curl_close($request);
throw new RuntimeException($error);
}
$status = curl_getinfo($request, CURLINFO_RESPONSE_CODE);
$contentType = (string) curl_getinfo($request, CURLINFO_CONTENT_TYPE);
curl_close($request);
if ($status >= 400) { throw new RuntimeException($response); }
$data = $status === 204 ? null
: (str_contains($contentType, 'application/json')
? json_decode($response, true, flags: JSON_THROW_ON_ERROR) : $response);Replace uppercase placeholders and sample values with your own data and company resource IDs. Keep the API key on a trusted server. Reuse an idempotency key only for the same logical request and payload.
Verify Passkey Authentication
Validate the assertion payload generated by the authenticator.
idp:writePath, query, and header parameters
No path, query, or additional header parameters.
Request Body
{
"passkey": {
"id": "ORG_USR_9921_AFG",
"challenge_id": "ch_01J...",
"payload": "{\"id\":\"...\",\"rawId\":\"...\",\"response\":{...}}"
}
}passkey.idRequiredpasskey.challenge_idRequiredpasskey.payloadRequiredResponses
Passkey authentication successful.
{
"status": "success",
"id": "ORG_USR_9921_AFG",
"message": "Passkey verified successfully."
}Request examples
curl --request POST 'https://api.hub.donutwork.com/2026-02-01/idp/passkey/verify.json' \
--header 'Authorization: Bearer YOUR_API_KEY' \
--header 'Content-Type: application/json' \
--data '{
"passkey": {
"id": "ORG_USR_9921_AFG",
"challenge_id": "ch_01J...",
"payload": "{\"id\":\"...\",\"rawId\":\"...\",\"response\":{...}}"
}
}'const response = await fetch('https://api.hub.donutwork.com/2026-02-01/idp/passkey/verify.json', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.DONUTWORK_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"passkey": {
"id": "ORG_USR_9921_AFG",
"challenge_id": "ch_01J...",
"payload": "{\"id\":\"...\",\"rawId\":\"...\",\"response\":{...}}"
}
}),
});
const requestId = response.headers.get('x-request-id');
if (!response.ok) {
const error = await response.text();
throw new Error(`${response.status} ${error} [${requestId}]`);
}
const contentType = response.headers.get('content-type') ?? '';
const data = response.status === 204 ? null
: contentType.includes('application/json') ? await response.json()
: await response.text();$request = curl_init('https://api.hub.donutwork.com/2026-02-01/idp/passkey/verify.json');
curl_setopt_array($request, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DONUTWORK_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => '{
"passkey": {
"id": "ORG_USR_9921_AFG",
"challenge_id": "ch_01J...",
"payload": "{\\"id\\":\\"...\\",\\"rawId\\":\\"...\\",\\"response\\":{...}}"
}
}',
]);
$response = curl_exec($request);
if ($response === false) {
$error = curl_error($request);
curl_close($request);
throw new RuntimeException($error);
}
$status = curl_getinfo($request, CURLINFO_RESPONSE_CODE);
$contentType = (string) curl_getinfo($request, CURLINFO_CONTENT_TYPE);
curl_close($request);
if ($status >= 400) { throw new RuntimeException($response); }
$data = $status === 204 ? null
: (str_contains($contentType, 'application/json')
? json_decode($response, true, flags: JSON_THROW_ON_ERROR) : $response);Replace uppercase placeholders and sample values with your own data and company resource IDs. Keep the API key on a trusted server. Reuse an idempotency key only for the same logical request and payload.
Revoke Passkey Credential
Delete one registered passkey credential for the user.
idp:writePath, query, and header parameters
externalUserIdRequiredcredentialIdRequiredResponses
Credential removed successfully.
{
"status": "success",
"id": "ORG_USR_9921_AFG",
"message": "Passkey removed successfully."
}Request examples
curl --request DELETE 'https://api.hub.donutwork.com/2026-02-01/idp/EXTERNALUSERID/passkey/CREDENTIALID.json' \
--header 'Authorization: Bearer YOUR_API_KEY'const response = await fetch('https://api.hub.donutwork.com/2026-02-01/idp/EXTERNALUSERID/passkey/CREDENTIALID.json', {
method: 'DELETE',
headers: {
Authorization: 'Bearer ' + process.env.DONUTWORK_API_KEY,
},
});
const requestId = response.headers.get('x-request-id');
if (!response.ok) {
const error = await response.text();
throw new Error(`${response.status} ${error} [${requestId}]`);
}
const contentType = response.headers.get('content-type') ?? '';
const data = response.status === 204 ? null
: contentType.includes('application/json') ? await response.json()
: await response.text();$request = curl_init('https://api.hub.donutwork.com/2026-02-01/idp/EXTERNALUSERID/passkey/CREDENTIALID.json');
curl_setopt_array($request, [
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DONUTWORK_API_KEY'),
],
]);
$response = curl_exec($request);
if ($response === false) {
$error = curl_error($request);
curl_close($request);
throw new RuntimeException($error);
}
$status = curl_getinfo($request, CURLINFO_RESPONSE_CODE);
$contentType = (string) curl_getinfo($request, CURLINFO_CONTENT_TYPE);
curl_close($request);
if ($status >= 400) { throw new RuntimeException($response); }
$data = $status === 204 ? null
: (str_contains($contentType, 'application/json')
? json_decode($response, true, flags: JSON_THROW_ON_ERROR) : $response);Replace uppercase placeholders and sample values with your own data and company resource IDs. Keep the API key on a trusted server. Reuse an idempotency key only for the same logical request and payload.
Sentinel User Lifecycle
These administrative endpoints operate on the tenant-owned identity identified by its external_id. They require the sentinel:write scope.
Suspend User
Suspend new IDP authentication attempts for a user. Repeating the request for an already suspended user is safe.
sentinel:writePath, query, and header parameters
externalUserIdRequiredResponses
User access suspended.
{
"status": "success",
"user": {
"external_id": "ORG_USR_9921_AFG",
"status": "suspended"
},
"message": "User access suspended."
}No matching user exists in the authenticated company.
{
"error": "User not found"
}Request examples
curl --request POST 'https://api.hub.donutwork.com/2026-02-01/sentinel/users/EXTERNALUSERID/suspend.json' \
--header 'Authorization: Bearer YOUR_API_KEY'const response = await fetch('https://api.hub.donutwork.com/2026-02-01/sentinel/users/EXTERNALUSERID/suspend.json', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.DONUTWORK_API_KEY,
},
});
const requestId = response.headers.get('x-request-id');
if (!response.ok) {
const error = await response.text();
throw new Error(`${response.status} ${error} [${requestId}]`);
}
const contentType = response.headers.get('content-type') ?? '';
const data = response.status === 204 ? null
: contentType.includes('application/json') ? await response.json()
: await response.text();$request = curl_init('https://api.hub.donutwork.com/2026-02-01/sentinel/users/EXTERNALUSERID/suspend.json');
curl_setopt_array($request, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DONUTWORK_API_KEY'),
],
]);
$response = curl_exec($request);
if ($response === false) {
$error = curl_error($request);
curl_close($request);
throw new RuntimeException($error);
}
$status = curl_getinfo($request, CURLINFO_RESPONSE_CODE);
$contentType = (string) curl_getinfo($request, CURLINFO_CONTENT_TYPE);
curl_close($request);
if ($status >= 400) { throw new RuntimeException($response); }
$data = $status === 204 ? null
: (str_contains($contentType, 'application/json')
? json_decode($response, true, flags: JSON_THROW_ON_ERROR) : $response);Replace uppercase placeholders and sample values with your own data and company resource IDs. Keep the API key on a trusted server. Reuse an idempotency key only for the same logical request and payload.
Reactivate User
Restore IDP authentication for a suspended user. Repeating the request for an active user is safe.
sentinel:writePath, query, and header parameters
externalUserIdRequiredResponses
User access restored.
{
"status": "success",
"user": {
"external_id": "ORG_USR_9921_AFG",
"status": "active"
},
"message": "User access restored."
}No matching user exists in the authenticated company.
{
"error": "User not found"
}Request examples
curl --request POST 'https://api.hub.donutwork.com/2026-02-01/sentinel/users/EXTERNALUSERID/reactivate.json' \
--header 'Authorization: Bearer YOUR_API_KEY'const response = await fetch('https://api.hub.donutwork.com/2026-02-01/sentinel/users/EXTERNALUSERID/reactivate.json', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.DONUTWORK_API_KEY,
},
});
const requestId = response.headers.get('x-request-id');
if (!response.ok) {
const error = await response.text();
throw new Error(`${response.status} ${error} [${requestId}]`);
}
const contentType = response.headers.get('content-type') ?? '';
const data = response.status === 204 ? null
: contentType.includes('application/json') ? await response.json()
: await response.text();$request = curl_init('https://api.hub.donutwork.com/2026-02-01/sentinel/users/EXTERNALUSERID/reactivate.json');
curl_setopt_array($request, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DONUTWORK_API_KEY'),
],
]);
$response = curl_exec($request);
if ($response === false) {
$error = curl_error($request);
curl_close($request);
throw new RuntimeException($error);
}
$status = curl_getinfo($request, CURLINFO_RESPONSE_CODE);
$contentType = (string) curl_getinfo($request, CURLINFO_CONTENT_TYPE);
curl_close($request);
if ($status >= 400) { throw new RuntimeException($response); }
$data = $status === 204 ? null
: (str_contains($contentType, 'application/json')
? json_decode($response, true, flags: JSON_THROW_ON_ERROR) : $response);Replace uppercase placeholders and sample values with your own data and company resource IDs. Keep the API key on a trusted server. Reuse an idempotency key only for the same logical request and payload.
Delete User
Permanently delete the identity and its stored MFA material. This operation cannot be undone.
sentinel:writePath, query, and header parameters
externalUserIdRequiredResponses
User permanently deleted.
{
"status": "success",
"user": {
"external_id": "ORG_USR_9921_AFG",
"deleted": true
},
"message": "User deleted successfully."
}No matching user exists in the authenticated company.
{
"error": "User not found"
}Request examples
curl --request DELETE 'https://api.hub.donutwork.com/2026-02-01/sentinel/users/EXTERNALUSERID.json' \
--header 'Authorization: Bearer YOUR_API_KEY'const response = await fetch('https://api.hub.donutwork.com/2026-02-01/sentinel/users/EXTERNALUSERID.json', {
method: 'DELETE',
headers: {
Authorization: 'Bearer ' + process.env.DONUTWORK_API_KEY,
},
});
const requestId = response.headers.get('x-request-id');
if (!response.ok) {
const error = await response.text();
throw new Error(`${response.status} ${error} [${requestId}]`);
}
const contentType = response.headers.get('content-type') ?? '';
const data = response.status === 204 ? null
: contentType.includes('application/json') ? await response.json()
: await response.text();$request = curl_init('https://api.hub.donutwork.com/2026-02-01/sentinel/users/EXTERNALUSERID.json');
curl_setopt_array($request, [
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DONUTWORK_API_KEY'),
],
]);
$response = curl_exec($request);
if ($response === false) {
$error = curl_error($request);
curl_close($request);
throw new RuntimeException($error);
}
$status = curl_getinfo($request, CURLINFO_RESPONSE_CODE);
$contentType = (string) curl_getinfo($request, CURLINFO_CONTENT_TYPE);
curl_close($request);
if ($status >= 400) { throw new RuntimeException($response); }
$data = $status === 204 ? null
: (str_contains($contentType, 'application/json')
? json_decode($response, true, flags: JSON_THROW_ON_ERROR) : $response);Replace uppercase placeholders and sample values with your own data and company resource IDs. Keep the API key on a trusted server. Reuse an idempotency key only for the same logical request and payload.
Sentinel Risk Assessment
Sentinel provides a sophisticated analytical layer for evaluating authentication risk in real-time.
Analyze Session Integrity (Discover)
Submit session metadata to Sentinel to perform a risk assessment based on behavioral patterns, network reputation, and environmental context.
sentinel:writePath, query, and header parameters
No path, query, or additional header parameters.
Request Body
{
"sentinel": {
"id": "ORG_USR_9921_AFG",
"context": {
"ip": "1.1.1.1",
"ua": "EnterpriseAgent/1.0"
}
}
}sentinel.idRequiredsentinel.context.ipRequiredsentinel.context.uaRequiredsentinel.context.user_idResponses
Risk assessment completed.
{
"success": true,
"event_id": "67f4...",
"user_id": "ORG_USR_9921_AFG",
"ip": "1.1.1.1",
"geo_country": "US",
"risk_score": 0.62,
"risk_band": "high",
"decision_advice": "challenge_mfa",
"decision": "challenge_mfa",
"recommended_factor": "passkey_or_otp",
"reasons": [
"High source IP velocity detected."
],
"verification_cost_ms": 18
}Missing input fields.
{
"success": false,
"message": "Missing required parameters: user_id, ip, or user_agent."
}Request examples
curl --request POST 'https://api.hub.donutwork.com/2026-02-01/sentinel/verify.json' \
--header 'Authorization: Bearer YOUR_API_KEY' \
--header 'Content-Type: application/json' \
--data '{
"sentinel": {
"id": "ORG_USR_9921_AFG",
"context": {
"ip": "1.1.1.1",
"ua": "EnterpriseAgent/1.0"
}
}
}'const response = await fetch('https://api.hub.donutwork.com/2026-02-01/sentinel/verify.json', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.DONUTWORK_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"sentinel": {
"id": "ORG_USR_9921_AFG",
"context": {
"ip": "1.1.1.1",
"ua": "EnterpriseAgent/1.0"
}
}
}),
});
const requestId = response.headers.get('x-request-id');
if (!response.ok) {
const error = await response.text();
throw new Error(`${response.status} ${error} [${requestId}]`);
}
const contentType = response.headers.get('content-type') ?? '';
const data = response.status === 204 ? null
: contentType.includes('application/json') ? await response.json()
: await response.text();$request = curl_init('https://api.hub.donutwork.com/2026-02-01/sentinel/verify.json');
curl_setopt_array($request, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DONUTWORK_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => '{
"sentinel": {
"id": "ORG_USR_9921_AFG",
"context": {
"ip": "1.1.1.1",
"ua": "EnterpriseAgent/1.0"
}
}
}',
]);
$response = curl_exec($request);
if ($response === false) {
$error = curl_error($request);
curl_close($request);
throw new RuntimeException($error);
}
$status = curl_getinfo($request, CURLINFO_RESPONSE_CODE);
$contentType = (string) curl_getinfo($request, CURLINFO_CONTENT_TYPE);
curl_close($request);
if ($status >= 400) { throw new RuntimeException($response); }
$data = $status === 204 ? null
: (str_contains($contentType, 'application/json')
? json_decode($response, true, flags: JSON_THROW_ON_ERROR) : $response);Replace uppercase placeholders and sample values with your own data and company resource IDs. Keep the API key on a trusted server. Reuse an idempotency key only for the same logical request and payload.
Sentinel also accepts the legacy envelope:
{
"event": {
"user_id": "ORG_USR_9921_AFG",
"ip": "1.1.1.1",
"user_agent": "EnterpriseAgent/1.0",
"sentinel_session": "sess_123"
}
}