Add the IntraID button
Authorization code flow with PKCE, plus two endpoints you will not have seen before: the relay.
1 · Register the app
Register your company, create an application, and declare each field you want with the purpose and the reason. That reason is shown to the person, in your words. You get a client_id and a secret shown exactly once.
2 · Put the button on your page
<!-- your page -->
<div id="intraid-button"></div>
<script src="https://intraid.aiutil.in/button.js" defer
data-start="/auth/intraid/start"
data-label="Continue with IntraID"></script>
3 · Your start endpoint
It mints the PKCE verifier and the state, keeps them in your session, and redirects. Ask for scope=field:purpose pairs — only pairs you declared are offered.
// /auth/intraid/start
session_start();
$verifier = rtrim(strtr(base64_encode(random_bytes(48)), '+/', '-_'), '=');
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
$_SESSION['iid_verifier'] = $verifier;
$_SESSION['iid_state'] = $state = bin2hex(random_bytes(8));
header('Location: https://intraid.aiutil.in/authorize?' . http_build_query([
'client_id' => 'cli_8e384dc89f95d9ac21eb97ec',
'redirect_uri' => 'https://yoursite.com/auth/intraid/callback',
'scope' => 'profile.name:account contact.email:transactional contact.email:otp',
'state' => $state,
'code_challenge' => $challenge,
'code_challenge_method' => 'S256',
]));
4 · Your callback, and the token exchange
// /auth/intraid/callback?code=…&state=… if (!hash_equals($_SESSION['iid_state'], $_GET['state'] ?? '')) exit('bad state'); $r = http_post('https://intraid.aiutil.in/oauth/token', [ 'grant_type' => 'authorization_code', 'code' => $_GET['code'], 'client_id' => 'cli_8e384dc89f95d9ac21eb97ec', 'client_secret' => INTRAID_SECRET, 'code_verifier' => $_SESSION['iid_verifier'], 'redirect_uri' => 'https://yoursite.com/auth/intraid/callback', ]); // { // "access_token": "iat_…", // "sub": "usr_9f21c8…", ← your only identifier for this person // "granted": {"profile.name":["account"],"contact.email":["transactional","otp"]}, // "relay_handles": {"contact.email":"eml_4a1f…"} // }
5 · Read the fields you were granted
GET https://intraid.aiutil.in/api/userinfo?purpose=account
Authorization: Bearer iat_…
{
"sub": "usr_9f21c8…",
"purpose": "account",
"profile": { "profile.name": "Rahul", "profile.city": "Lucknow" },
"relay": { "contact.email": { "handle": "eml_4a1f…", "readable": false } }
}
The purpose is required and is checked against what the person approved. Asking for a purpose you were not granted returns 403 and appears in their ledger as a refused attempt.
6 · Send them an email
POST https://intraid.aiutil.in/api/relay/email
Authorization: Bearer iat_…
Content-Type: application/json
{
"purpose": "transactional",
"subject": "Your order #4821 has shipped",
"body": "It left our Lucknow warehouse this morning…"
}
→ { "sent": true, "purpose": "transactional", "remaining_today": 9 }
7 · One-time codes
POST https://intraid.aiutil.in/api/relay/otp { "channel": "email" }
→ { "ref": "otp_8c21…", "expires_in": 300 }
POST https://intraid.aiutil.in/api/relay/otp/verify { "ref": "otp_8c21…", "code": "418290" }
→ { "verified": true }
You never see the code and you never see where it went. You learn one bit: it matched, or it did not.
Ceilings and refusals
| Limit | Value |
|---|---|
| Relayed emails per person per day | 10 |
| Codes per person per hour | 5 |
| Field reads per person per hour | 60 |
| Access token lifetime | 3600s |
| Purposes refused outright | marketing, profiling, resale, advertising |
Going far past a ceiling is treated as intent, not accident: the connection is suspended and the person is told what happened.