IDP Email Magic Links
Associate email identities and implement a single-use, PKCE-bound passwordless sign-in flow.
IDP Email Magic Links
Email magic links let a third-party application authenticate a registered Sentinel identity without exposing its API key to the browser. The application requests delivery, receives the email callback, then verifies the proof server-to-server and creates its own session.
All endpoints require a bearer API key with idp:write and are scoped to the API key company.
Before you start
- Enable Email magic links in Sentinel → Settings.
- Register every exact HTTPS callback URL. Credentials and URL fragments are not accepted.
- Associate one email external account with the Sentinel user.
The callback allowlist may remain empty while the feature is disabled. At least one callback is mandatory before it can be enabled.
Associate an email account
idp:writePath, query, and header parameters
externalUserIdRequiredhandlerRequiredRequest Body
{
"external_account": {
"identifier": "alexander@enterprise.com"
}
}external_account.identifierRequiredResponses
Email identity associated. Its assurance is asserted until the first successful magic-link verification.
{
"external_account": {
"handler": "email",
"identifier": "alexander@enterprise.com",
"status": "asserted",
"verified_at": null
}
}The Sentinel user does not exist in this company.
The normalized email is already associated with another user in this company.
Request examples
curl --request PUT 'https://api.hub.donutwork.com/2026-02-01/idp/EXTERNALUSERID/external-accounts/HANDLER.json' \
--header 'Authorization: Bearer YOUR_API_KEY' \
--header 'Content-Type: application/json' \
--data '{
"external_account": {
"identifier": "alexander@enterprise.com"
}
}'const response = await fetch('https://api.hub.donutwork.com/2026-02-01/idp/EXTERNALUSERID/external-accounts/HANDLER.json', {
method: 'PUT',
headers: {
Authorization: 'Bearer ' + process.env.DONUTWORK_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"external_account": {
"identifier": "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/external-accounts/HANDLER.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 => '{
"external_account": {
"identifier": "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.
Changing the email resets its assurance to asserted; repeating the same association preserves an existing verified state. Retrieve the user profile to inspect external_accounts.
Remove an external account
idp:writePath, query, and header parameters
externalUserIdRequiredhandlerRequiredResponses
Association removed.
{
"status": "success",
"external_account": {
"handler": "email",
"deleted": true
}
}The user or external account is not visible in this company.
{
"error": "External account not found"
}Request examples
curl --request DELETE 'https://api.hub.donutwork.com/2026-02-01/idp/EXTERNALUSERID/external-accounts/HANDLER.json' \
--header 'Authorization: Bearer YOUR_API_KEY'const response = await fetch('https://api.hub.donutwork.com/2026-02-01/idp/EXTERNALUSERID/external-accounts/HANDLER.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/external-accounts/HANDLER.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.
1. Create the PKCE proof
On your backend, generate a cryptographically random code_verifier of 43–128 characters. Keep it server-side, then calculate:
code_challenge = BASE64URL(SHA256(code_verifier))Only the S256 method is accepted. Store the verifier with your login attempt and bind it to a short-lived anti-CSRF state value.
2. Request email delivery
idp:writePath, query, and header parameters
No path, query, or additional header parameters.
Request Body
{
"magic_link": {
"external_id": "ORG_USR_9921_AFG",
"redirect_uri": "https://app.example.com/auth/magic/callback",
"code_challenge": "BASE64URL_SHA256_CHALLENGE",
"code_challenge_method": "S256",
"locale": "en",
"state": "RANDOM_ANTI_CSRF_VALUE"
}
}magic_link.external_idmagic_link.account.handlermagic_link.account.identifiermagic_link.redirect_uriRequiredmagic_link.code_challengeRequiredmagic_link.code_challenge_methodmagic_link.localemagic_link.stateResponses
The request was accepted without revealing whether the identity exists.
{
"magic_link": {
"status": "accepted",
"transaction_id": "RANDOM_TRANSACTION_ID",
"expires_at": "2026-09-13T21:30:00+00:00"
}
}The feature is disabled or the callback does not match the allowlist exactly.
The configured cooldown or request limit was exceeded.
Request examples
curl --request POST 'https://api.hub.donutwork.com/2026-02-01/idp/magic-link/request.json' \
--header 'Authorization: Bearer YOUR_API_KEY' \
--header 'Content-Type: application/json' \
--data '{
"magic_link": {
"external_id": "ORG_USR_9921_AFG",
"redirect_uri": "https://app.example.com/auth/magic/callback",
"code_challenge": "BASE64URL_SHA256_CHALLENGE",
"code_challenge_method": "S256",
"locale": "en",
"state": "RANDOM_ANTI_CSRF_VALUE"
}
}'const response = await fetch('https://api.hub.donutwork.com/2026-02-01/idp/magic-link/request.json', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.DONUTWORK_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"magic_link": {
"external_id": "ORG_USR_9921_AFG",
"redirect_uri": "https://app.example.com/auth/magic/callback",
"code_challenge": "BASE64URL_SHA256_CHALLENGE",
"code_challenge_method": "S256",
"locale": "en",
"state": "RANDOM_ANTI_CSRF_VALUE"
}
}),
});
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/magic-link/request.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 => '{
"magic_link": {
"external_id": "ORG_USR_9921_AFG",
"redirect_uri": "https://app.example.com/auth/magic/callback",
"code_challenge": "BASE64URL_SHA256_CHALLENGE",
"code_challenge_method": "S256",
"locale": "en",
"state": "RANDOM_ANTI_CSRF_VALUE"
}
}',
]);
$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.
You may replace external_id with account: { handler: "email", identifier: "alexander@enterprise.com" }. Responses remain indistinguishable for unknown, inactive, disabled, unassociated, or undeliverable identities. accepted confirms request handling, not email delivery; delivery failures are emitted as internal Sentinel events.
If eligible, Donutwork emails a URL containing dw_magic_transaction, dw_magic_token, and the original state query parameters. A new request for the same user and API key invalidates its previous active transaction.
3. Handle the browser callback
Compare state with your stored value before continuing. Send the transaction and token to your backend, then remove sensitive query parameters from browser history immediately. Do not log them or forward them to analytics and third-party resources.
Keep credentials server-side
Never call the Public API from the browser. The bearer API key and code_verifier must remain on your backend.
4. Verify and create your session
idp:writePath, query, and header parameters
No path, query, or additional header parameters.
Request Body
{
"magic_link": {
"transaction_id": "RANDOM_TRANSACTION_ID",
"token": "TOKEN_FROM_EMAIL_CALLBACK",
"code_verifier": "SERVER_SIDE_PRIVATE_VERIFIER",
"context": {
"ip": "203.0.113.10",
"user_agent": "Mozilla/5.0 ...",
"session_id": "YOUR_LOGIN_ATTEMPT_ID"
}
}
}magic_link.transaction_idRequiredmagic_link.tokenRequiredmagic_link.code_verifierRequiredmagic_link.contextResponses
Proof verified and consumed.
{
"status": "verified",
"authentication": {
"method": "email_magic_link",
"transaction_id": "RANDOM_TRANSACTION_ID",
"authenticated_at": "2026-09-13T21:25:14+00:00"
},
"user": {
"id": "6908...",
"external_id": "ORG_USR_9921_AFG",
"status": "active",
"metadata": {
"plan": "enterprise"
}
},
"sentinel": {
"decision": "allow",
"risk_score": 0.12,
"risk_band": "low",
"recommended_factor": null
}
}The verification-attempt limit was exceeded.
Request examples
curl --request POST 'https://api.hub.donutwork.com/2026-02-01/idp/magic-link/verify.json' \
--header 'Authorization: Bearer YOUR_API_KEY' \
--header 'Content-Type: application/json' \
--data '{
"magic_link": {
"transaction_id": "RANDOM_TRANSACTION_ID",
"token": "TOKEN_FROM_EMAIL_CALLBACK",
"code_verifier": "SERVER_SIDE_PRIVATE_VERIFIER",
"context": {
"ip": "203.0.113.10",
"user_agent": "Mozilla/5.0 ...",
"session_id": "YOUR_LOGIN_ATTEMPT_ID"
}
}
}'const response = await fetch('https://api.hub.donutwork.com/2026-02-01/idp/magic-link/verify.json', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.DONUTWORK_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"magic_link": {
"transaction_id": "RANDOM_TRANSACTION_ID",
"token": "TOKEN_FROM_EMAIL_CALLBACK",
"code_verifier": "SERVER_SIDE_PRIVATE_VERIFIER",
"context": {
"ip": "203.0.113.10",
"user_agent": "Mozilla/5.0 ...",
"session_id": "YOUR_LOGIN_ATTEMPT_ID"
}
}
}),
});
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/magic-link/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 => '{
"magic_link": {
"transaction_id": "RANDOM_TRANSACTION_ID",
"token": "TOKEN_FROM_EMAIL_CALLBACK",
"code_verifier": "SERVER_SIDE_PRIVATE_VERIFIER",
"context": {
"ip": "203.0.113.10",
"user_agent": "Mozilla/5.0 ...",
"session_id": "YOUR_LOGIN_ATTEMPT_ID"
}
}
}',
]);
$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.
The verification response is proof of authentication, not an application session. Validate the advisory sentinel.decision according to your policy, then issue and rotate your own session identifiers.
Configurable controls
Sentinel settings expose link lifetime, resend cooldown, request-limit window, requests per window, verification attempts, and up to 20 exact callbacks. Transactions are single-use, expire automatically, are bound to the company and requesting API key, and are stored without the raw email token.