DonutworkHelp Center
Donutwork documentation

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

  1. Enable Email magic links in Sentinel → Settings.
  2. Register every exact HTTPS callback URL. Credentials and URL fragments are not accepted.
  3. 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

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

Path, query, and header parameters

externalUserIdstring· pathRequired
The user external_id.
handlerstring· pathRequired
External account handler. Use email for the magic-link flow.

Request Body

JSON
{
  "external_account": {
    "identifier": "alexander@enterprise.com"
  }
}
external_account.identifierstringRequired
A valid email address, unique within the company.

Responses

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

DELETE
/2026-02-01/idp/{externalUserId}/external-accounts/{handler}.json
Required permissionidp:write

Path, query, and header parameters

externalUserIdstring· pathRequired
The user external_id.
handlerstring· pathRequired
External account handler. Use email for the magic-link flow.

Responses

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

POST
/2026-02-01/idp/magic-link/request.json
Required permissionidp:write

Path, query, and header parameters

No path, query, or additional header parameters.

Request Body

JSON
{
  "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_idstring
Known external user ID. Required unless account.identifier is used.
magic_link.account.handlerstring
Use email when looking up by account identifier.
magic_link.account.identifierstring
Email lookup alternative to external_id.
magic_link.redirect_uristringRequired
Exact registered HTTPS callback URL.
magic_link.code_challengestringRequired
Base64url SHA-256 digest of the private verifier.
magic_link.code_challenge_methodstring
Must be S256; this is the default.
magic_link.localestring
Email language: en or it. When omitted, Donutwork uses the known recipient preference and then English.
magic_link.statestring
Opaque application state, up to 512 characters.

Responses

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

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

Path, query, and header parameters

No path, query, or additional header parameters.

Request Body

JSON
{
  "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_idstringRequired
Transaction received in the callback.
magic_link.tokenstringRequired
Single-use token received in the callback.
magic_link.code_verifierstringRequired
Private verifier used to create the request challenge.
magic_link.contextobject
End-user context used for Sentinel advisory risk evaluation.

Responses

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 proof is wrong, expired, already used, superseded, or no longer matches the user email.

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.

On this page