DonutworkHelp Center
Documentazione Donutwork

Magic link email IDP

Associa identità email e integra un accesso passwordless monouso vincolato tramite PKCE.

Magic link email IDP

I magic link permettono a un'applicazione terza di autenticare un'identità registrata in Sentinel senza esporre la propria API key al browser. L'applicazione richiede l'invio, riceve il callback email, verifica la prova server-to-server e crea infine la propria sessione.

Tutti gli endpoint richiedono una API key bearer con permesso idp:write e sono isolati sulla company associata alla chiave.

Prima di iniziare

  1. Abilita Magic link via email in Sentinel → Impostazioni.
  2. Registra ogni URL di callback HTTPS esatto. Credenziali e frammenti URL non sono ammessi.
  3. Associa un account email esterno all'utente Sentinel.

La allowlist può rimanere vuota finché la funzionalità è disabilitata. Per abilitarla è obbligatorio almeno un callback.

Associa un account email

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

Path, query, and header parameters

externalUserIdstring· pathRequired
L'external_id dell'utente.
handlerstring· pathRequired
Handler dell'account esterno. Usa email per il flusso magic link.

Request Body

JSON
{
  "external_account": {
    "identifier": "alexander@enterprise.com"
  }
}
external_account.identifierstringRequired
Indirizzo email valido e univoco nella company.

Responses

Identità email associata. L'attendibilità resta asserted fino alla prima verifica magic link riuscita.

{
  "external_account": {
    "handler": "email",
    "identifier": "alexander@enterprise.com",
    "status": "asserted",
    "verified_at": null
  }
}

L'utente Sentinel non esiste nella company.

L'email normalizzata è già associata a un altro utente della 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.

Cambiare l'email riporta il suo stato a asserted; ripetere la stessa associazione conserva un eventuale stato verified. Il profilo utente espone l'elenco external_accounts. Per rimuovere l'associazione:

DELETE /2026-02-01/idp/{externalUserId}/external-accounts/{handler}.json

1. Crea la prova PKCE

Sul backend genera un code_verifier crittograficamente casuale di 43–128 caratteri. Conservalo solo lato server e calcola:

code_challenge = BASE64URL(SHA256(code_verifier))

È accettato esclusivamente il metodo S256. Associa il verifier al tentativo di login e a un valore state anti-CSRF di breve durata.

2. Richiedi l'invio email

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": "it",
    "state": "VALORE_CASUALE_ANTI_CSRF"
  }
}
magic_link.external_idstring
ID esterno noto. Obbligatorio se non si usa account.identifier.
magic_link.account.handlerstring
Usa email per la ricerca tramite identificativo account.
magic_link.account.identifierstring
Email alternativa a external_id per identificare l'utente.
magic_link.redirect_uristringRequired
Callback HTTPS registrato, con corrispondenza esatta.
magic_link.code_challengestringRequired
Digest SHA-256 base64url del verifier privato.
magic_link.code_challenge_methodstring
Deve essere S256, valore predefinito.
magic_link.localestring
Lingua dell'email: en oppure it. Se omessa, Donutwork usa la preferenza nota del destinatario e poi l'inglese.
magic_link.statestring
Stato opaco dell'applicazione, massimo 512 caratteri.

Responses

Richiesta accettata senza rivelare se l'identità esiste.

{
  "magic_link": {
    "status": "accepted",
    "transaction_id": "ID_TRANSAZIONE_CASUALE",
    "expires_at": "2026-09-13T21:30:00+00:00"
  }
}

Funzionalità disabilitata o callback non presente esattamente nella allowlist.

Cooldown o limite richieste configurato superato.

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": "it",
    "state": "VALORE_CASUALE_ANTI_CSRF"
  }
}'
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": "it",
      "state": "VALORE_CASUALE_ANTI_CSRF"
    }
  }),
});
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": "it",
    "state": "VALORE_CASUALE_ANTI_CSRF"
  }
}',
]);
$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.

Puoi sostituire external_id con account: { handler: "email", identifier: "alexander@enterprise.com" }. La risposta è indistinguibile per identità sconosciute, inattive, disabilitate, prive di associazione o non raggiungibili. accepted conferma la gestione della richiesta, non la consegna email; gli errori di consegna generano eventi Sentinel interni.

Se l'identità è idonea, Donutwork invia un URL con i parametri query dw_magic_transaction, dw_magic_token e lo state originale. Una nuova richiesta per lo stesso utente e la stessa API key invalida la transazione precedente ancora attiva.

3. Gestisci il callback browser

Confronta state con il valore salvato prima di procedere. Invia transazione e token al tuo backend, quindi rimuovi subito i parametri sensibili dalla cronologia del browser. Non registrarli nei log e non inoltrarli a sistemi analytics o risorse di terze parti.

Mantieni le credenziali lato server

Non chiamare mai la Public API dal browser. La API key bearer e il code_verifier devono restare sul backend.

4. Verifica e crea la sessione

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": "ID_TRANSAZIONE_CASUALE",
    "token": "TOKEN_DAL_CALLBACK_EMAIL",
    "code_verifier": "VERIFIER_PRIVATO_LATO_SERVER",
    "context": {
      "ip": "203.0.113.10",
      "user_agent": "Mozilla/5.0 ...",
      "session_id": "ID_DEL_TUO_TENTATIVO_LOGIN"
    }
  }
}
magic_link.transaction_idstringRequired
Transazione ricevuta nel callback.
magic_link.tokenstringRequired
Token monouso ricevuto nel callback.
magic_link.code_verifierstringRequired
Verifier privato usato per creare la challenge iniziale.
magic_link.contextobject
Contesto dell'utente finale per la valutazione advisory di Sentinel.

Responses

Prova verificata e consumata.

{
  "status": "verified",
  "authentication": {
    "method": "email_magic_link",
    "transaction_id": "ID_TRANSAZIONE_CASUALE",
    "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
  }
}

Prova errata, scaduta, già usata, sostituita o non più coerente con l'email utente.

Numero massimo di tentativi di verifica superato.

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": "ID_TRANSAZIONE_CASUALE",
    "token": "TOKEN_DAL_CALLBACK_EMAIL",
    "code_verifier": "VERIFIER_PRIVATO_LATO_SERVER",
    "context": {
      "ip": "203.0.113.10",
      "user_agent": "Mozilla/5.0 ...",
      "session_id": "ID_DEL_TUO_TENTATIVO_LOGIN"
    }
  }
}'
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": "ID_TRANSAZIONE_CASUALE",
      "token": "TOKEN_DAL_CALLBACK_EMAIL",
      "code_verifier": "VERIFIER_PRIVATO_LATO_SERVER",
      "context": {
        "ip": "203.0.113.10",
        "user_agent": "Mozilla/5.0 ...",
        "session_id": "ID_DEL_TUO_TENTATIVO_LOGIN"
      }
    }
  }),
});
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": "ID_TRANSAZIONE_CASUALE",
    "token": "TOKEN_DAL_CALLBACK_EMAIL",
    "code_verifier": "VERIFIER_PRIVATO_LATO_SERVER",
    "context": {
      "ip": "203.0.113.10",
      "user_agent": "Mozilla/5.0 ...",
      "session_id": "ID_DEL_TUO_TENTATIVO_LOGIN"
    }
  }
}',
]);
$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.

La risposta di verifica è una prova di autenticazione, non una sessione applicativa. Valuta il consiglio sentinel.decision secondo la tua policy, quindi emetti e ruota i tuoi identificativi di sessione.

Controlli configurabili

Le impostazioni Sentinel espongono durata del link, cooldown tra invii, finestra del limite, richieste per finestra, tentativi di verifica e fino a 20 callback esatti. Le transazioni sono monouso, scadono automaticamente, sono vincolate a company e API key richiedente e vengono memorizzate senza il token email in chiaro.

On this page