DonutworkHelp Center
Donutwork documentation

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.

POST
/2026-02-01/idp/user.json
Required permissionidp:write

Path, query, and header parameters

No path, query, or additional header parameters.

Request Body

JSON
{
  "user": {
    "external_id": "ORG_USR_9921_AFG",
    "metadata": {
      "plan": "enterprise",
      "region": "eu"
    }
  }
}
user.external_idstringRequired
Your system's unique identifier for the user (e.g., UUID or DB ID).
user.metadataobject
Application-owned JSON metadata: maximum 32 KB, 6 nested levels, and 500 entries.

Responses

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.

GET
/2026-02-01/idp/{externalUserId}.json
Required permissionidp:read

Path, query, and header parameters

externalUserIdstring· pathRequired
The identifier provided during provisioning.

Responses

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.

PUT
/2026-02-01/idp/{externalUserId}/metadata.json
Required permissionidp:write

Path, query, and header parameters

externalUserIdstring· pathRequired
The identifier provided during provisioning.

Request Body

JSON
{
  "user": {
    "metadata": {
      "plan": "enterprise",
      "region": "eu",
      "features": [
        "sso",
        "audit"
      ]
    }
  }
}
user.metadataobjectRequired
Replacement JSON object, up to 32 KB, 6 nested levels, and 500 entries.

Responses

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.

POST
/2026-02-01/idp/{externalUserId}/otp.json
Required permissionidp:write

Path, query, and header parameters

externalUserIdstring· pathRequired
The user identifier.

Request Body

JSON
{
  "otp": {
    "issuer_name": "Acme Enterprise",
    "account_name": "alexander@enterprise.com"
  }
}
otp.issuer_namestringRequired
The organization name displayed in the authenticator app.
otp.account_namestringRequired
The specific account identifier displayed in the app.

Responses

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.

POST
/2026-02-01/idp/otp/verify.json
Required permissionidp:write

Path, query, and header parameters

No path, query, or additional header parameters.

Request Body

JSON
{
  "otp": {
    "id": "ORG_USR_9921_AFG",
    "code": "552192"
  }
}
otp.idstringRequired
The unique external user identifier.
otp.codestringRequired
The 6-digit code or numeric backup code.

Responses

Authentication successful.

{
  "status": "success",
  "id": "ORG_USR_9921_AFG",
  "message": "OTP verified successfully."
}

Invalid credentials or code expired.

{
  "error": "Invalid credentials or access denied"
}

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.

POST
/2026-02-01/idp/{externalUserId}/backupcodes.json
Required permissionidp:write

Path, query, and header parameters

externalUserIdstring· pathRequired
The user identifier.

Responses

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.

POST
/2026-02-01/idp/{externalUserId}/passkey/options.json
Required permissionidp:write

Path, query, and header parameters

externalUserIdstring· pathRequired
The user identifier.

Request Body

JSON
{
  "passkey": {
    "display_name": "Alexander",
    "account_name": "alexander@enterprise.com"
  }
}
passkey.display_namestring
Display name shown during registration.
passkey.account_namestring
Account name bound to the credential.

Responses

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.

POST
/2026-02-01/idp/{externalUserId}/passkey/confirm.json
Required permissionidp:write

Path, query, and header parameters

externalUserIdstring· pathRequired
The user identifier.

Request Body

JSON
{
  "passkey": {
    "challenge_id": "ch_01J...",
    "payload": "{\"id\":\"...\",\"rawId\":\"...\",\"response\":{...}}",
    "device": "Chrome 125 / macOS"
  }
}
passkey.challenge_idstringRequired
Challenge reference from options endpoint.
passkey.payloadstring|objectRequired
Serialized PublicKeyCredential payload.
passkey.devicestring
Client device fingerprint for audit.

Responses

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.

POST
/2026-02-01/idp/passkey/challenge.json
Required permissionidp:write

Path, query, and header parameters

No path, query, or additional header parameters.

Request Body

JSON
{
  "passkey": {
    "id": "ORG_USR_9921_AFG"
  }
}
passkey.idstringRequired
External user identifier.

Responses

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.

POST
/2026-02-01/idp/passkey/verify.json
Required permissionidp:write

Path, query, and header parameters

No path, query, or additional header parameters.

Request Body

JSON
{
  "passkey": {
    "id": "ORG_USR_9921_AFG",
    "challenge_id": "ch_01J...",
    "payload": "{\"id\":\"...\",\"rawId\":\"...\",\"response\":{...}}"
  }
}
passkey.idstringRequired
External user identifier.
passkey.challenge_idstringRequired
Challenge reference from challenge endpoint.
passkey.payloadstring|objectRequired
Serialized PublicKeyCredential assertion payload.

Responses

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.

DELETE
/2026-02-01/idp/{externalUserId}/passkey/{credentialId}.json
Required permissionidp:write

Path, query, and header parameters

externalUserIdstring· pathRequired
The user identifier.
credentialIdstring· pathRequired
Passkey credential identifier.

Responses

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.

POST
/2026-02-01/sentinel/users/{externalUserId}/suspend.json
Required permissionsentinel:write

Path, query, and header parameters

externalUserIdstring· pathRequired
The external identifier provided when the user was provisioned.

Responses

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.

POST
/2026-02-01/sentinel/users/{externalUserId}/reactivate.json
Required permissionsentinel:write

Path, query, and header parameters

externalUserIdstring· pathRequired
The external identifier provided when the user was provisioned.

Responses

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.

DELETE
/2026-02-01/sentinel/users/{externalUserId}.json
Required permissionsentinel:write

Path, query, and header parameters

externalUserIdstring· pathRequired
The external identifier provided when the user was provisioned.

Responses

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.

POST
/2026-02-01/sentinel/verify.json
Required permissionsentinel:write

Path, query, and header parameters

No path, query, or additional header parameters.

Request Body

JSON
{
  "sentinel": {
    "id": "ORG_USR_9921_AFG",
    "context": {
      "ip": "1.1.1.1",
      "ua": "EnterpriseAgent/1.0"
    }
  }
}
sentinel.idstringRequired
External user identifier for the risk evaluation.
sentinel.context.ipstringRequired
Source IP address of the session.
sentinel.context.uastringRequired
User-Agent value for the session.
sentinel.context.user_idstring
Optional explicit user identifier override.

Responses

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"
  }
}

On this page