How to Setup a Workshop Project for - Microsoft Graph Subscription Manager
This workshop demonstrates how to create and manage Microsoft Graph change-notification subscriptions using PHP, with:
- Config form (config.html)
- JSON config storage (config.json)
- PHP dashboard (index.php) for create/renew/list/delete actions
- Webhook (callback.php) for subscription validation & receiving notifications
1️⃣ Create a Workshop Project
- Project name: Microsoft Graph Subscription Manager
- Category: API
- Description: Creates and manages Microsoft Graph change-notification subscriptions with a simple form (config.html), JSON config storage, PHP dashboard (create/renew/list/delete), and a webhook for validation + notifications.
2️⃣ Create a Workshop Object
- Object Name: Graph Subscription Tool
- Object Type: Transform & Prepare
- Development Environment: PHP 7.4 Application
- Click Create.
3️⃣ Create config.html
New File → name: config.html → Scope: Local → paste → Save.
<!DOCTYPE html>
<html>
<body style="font-family: system-ui, sans-serif; max-width: 920px; margin: 24px auto;">
<h2>Microsoft Graph — Subscription Config</h2>
<form method="post" action="save_config.php" style="display:grid; gap:16px;">
<fieldset style="padding:12px;border:1px solid #ccc;">
<legend>Azure AD App (Entra ID)</legend>
<label>Tenant ID <input name="tenant_id" required></label><br>
<label>Client ID <input name="client_id" required></label><br>
<label>Client Secret <input name="client_secret" type="password" required></label>
<div style="font-size:12px;color:#666;">Tip: For production, use environment variables for secrets.</div>
</fieldset>
<fieldset style="padding:12px;border:1px solid #ccc;">
<legend>Subscription Fields</legend>
<label>changeType <input name="changeType" value="created" required></label><br>
<label>resource
<input name="resource" value="/users('[email protected]')/mailFolders('Inbox')/messages" required>
</label><br>
<label>notificationUrl
<input name="notificationUrl" type="url" placeholder="https://<your-public-domain>/callback.php" required>
</label><br>
<label>lifecycleNotificationUrl (optional)
<input name="lifecycleNotificationUrl" type="url">
</label><br>
<label>clientState <input name="clientState" value="secretToTest" required></label><br>
<label>expirationDateTime (ISO 8601 UTC)
<input name="expirationDateTime" placeholder="2025-12-31T23:59:59Z">
</label>
</fieldset>
<div>
<button type="submit">Save Config</button>
<a href="index.php" style="margin-left:8px;">Cancel</a>
</div>
</form>
</body>
</html>
4️⃣ Create config.json
New File → name: config.json → Scope: Local → paste:
{}
5️⃣ Create save_config.php
New File → name: save_config.php → Scope: Local → paste:
<?php
header('Content-Type: text/plain; charset=UTF-8');
$target = __DIR__ . '/config.json';
$existing = file_exists($target) ? json_decode(file_get_contents($target), true) : [];
$fields = [
'tenant_id','client_id','client_secret',
'changeType','resource','notificationUrl','lifecycleNotificationUrl','clientState','expirationDateTime'
];
$data = $existing;
foreach ($fields as $f) {
if (isset($_POST[$f])) $data[$f] = trim((string)$_POST[$f]);
}
file_put_contents($target, json_encode($data, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES));
echo "Saved to config.json\nReturn to index.php";
6️⃣ Create the runtime files
- 6a) graph.php
New File → name: graph.php → Scope: Local → paste → Save.
<?php
function getAccessToken($tenantId, $clientId, $clientSecret) {
$url = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token";
$post = http_build_query([
'client_id' => $clientId,
'client_secret' => $clientSecret,
'scope' => 'https://graph.microsoft.com/.default',
'grant_type' => 'client_credentials'
]);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $post,
CURLOPT_RETURNTRANSFER => true
]);
$res = curl_exec($ch);
if ($res === false) throw new Exception('OAuth error: '.curl_error($ch));
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$j = json_decode($res, true);
if ($code >= 300 || !isset($j['access_token'])) throw new Exception("OAuth $code: $res");
return $j['access_token'];
}
function gpost($path, $token, $payload) {
$ch = curl_init("https://graph.microsoft.com/v1.0$path");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $token",
"Content-Type: application/json"
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_RETURNTRANSFER => true
]);
$res = curl_exec($ch);
if ($res === false) throw new Exception('POST error: '.curl_error($ch));
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return [$code, json_decode($res,true), $res];
}
function gpatch($path, $token, $payload) {
$ch = curl_init("https://graph.microsoft.com/v1.0$path");
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'PATCH',
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $token",
"Content-Type: application/json"
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_RETURNTRANSFER => true
]);
$res = curl_exec($ch);
if ($res === false) throw new Exception('PATCH error: '.curl_error($ch));
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return [$code, json_decode($res,true), $res];
}
function gdel($path, $token) {
$ch = curl_init("https://graph.microsoft.com/v1.0$path");
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token"],
CURLOPT_RETURNTRANSFER => true
]);
$res = curl_exec($ch);
if ($res === false) throw new Exception('DELETE error: '.curl_error($ch));
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return [$code, null, $res];
}
function gget($path, $token) {
$ch = curl_init("https://graph.microsoft.com/v1.0$path");
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token"],
CURLOPT_RETURNTRANSFER => true
]);
$res = curl_exec($ch);
if ($res === false) throw new Exception('GET error: '.curl_error($ch));
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return [$code, json_decode($res,true), $res];
}
function createSubscription($cfg, $token) {
$p = [
'changeType' => $cfg['changeType'],
'resource' => $cfg['resource'],
'notificationUrl' => $cfg['notificationUrl'],
'clientState' => $cfg['clientState']
];
if (!empty($cfg['expirationDateTime'])) $p['expirationDateTime'] = $cfg['expirationDateTime'];
if (!empty($cfg['lifecycleNotificationUrl'])) $p['lifecycleNotificationUrl'] = $cfg['lifecycleNotificationUrl'];
return gpost('/subscriptions', $token, $p);
}
function renewSubscription($id, $newExp, $token) { return gpatch("/subscriptions/$id", $token, ['expirationDateTime'=>$newExp]); }
function deleteSubscription($id, $token) { return gdel("/subscriptions/$id", $token); }
function listSubscriptions($token) { return gget('/subscriptions', $token); }
- 6b) callback.php (the webhook)
New File → name: callback.php → Scope: Local → paste → Save.
<?php
// Validation handshake
if (isset($_GET['validationToken'])) {
header('Content-Type: text/plain');
http_response_code(200);
echo $_GET['validationToken']; // respond only the token within ≤10s
exit;
}
// Notifications
$raw = file_get_contents('php://input');
$payload = json_decode($raw, true) ?: [];
$cfg = file_exists(__DIR__.'/config.json') ? json_decode(file_get_contents(__DIR__.'/config.json'), true) : [];
$expected = $cfg['clientState'] ?? null;
$ok = true;
if (isset($payload['value'][0]['clientState']) && $expected) {
$ok = hash_equals($expected, $payload['value'][0]['clientState']);
}
if (!$ok) { http_response_code(403); echo "clientState mismatch"; exit; }
// log and accept
file_put_contents(__DIR__.'/notifications.log', date('c')." ".($raw ?: '{}').PHP_EOL, FILE_APPEND);
http_response_code(202);
header('Content-Type: application/json');
echo json_encode(['status'=>'accepted']);
- 6c) index.php (dashboard)
New File → name: index.php → Scope: Local → paste → Save.
<?php
$cfgFile = __DIR__.'/config.json';
$cfg = file_exists($cfgFile) ? json_decode(file_get_contents($cfgFile), true) : [];
function h($x){return htmlspecialchars($x??'',ENT_QUOTES,'UTF-8');}
require_once __DIR__.'/graph.php';
$notice = $error = $out = null;
if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['action'])) {
try {
$tenant = $cfg['tenant_id'] ?? getenv('TENANT_ID');
$client = $cfg['client_id'] ?? getenv('CLIENT_ID');
$secret = $cfg['client_secret'] ?? getenv('CLIENT_SECRET');
if (!$tenant || !$client || !$secret) throw new Exception('Missing Tenant/Client/Secret');
$token = getAccessToken($tenant,$client,$secret);
switch ($_POST['action']) {
case 'create':
[$code,$json,$raw] = createSubscription($cfg,$token);
$out = "Create HTTP $code\n$raw";
if ($code>=200 && $code<300 && isset($json['id'])) {
$cfg['subscriptionId'] = $json['id'];
if (isset($json['expirationDateTime'])) $cfg['expirationDateTime']=$json['expirationDateTime'];
file_put_contents($cfgFile, json_encode($cfg, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES));
$notice = "Saved subscriptionId and expirationDateTime.";
}
break;
case 'renew':
if (empty($cfg['subscriptionId'])) throw new Exception('No subscriptionId in config.json');
if (empty($cfg['expirationDateTime'])) throw new Exception('Set new expirationDateTime in config.');
[$code,$json,$raw] = renewSubscription($cfg['subscriptionId'],$cfg['expirationDateTime'],$token);
$out = "Renew HTTP $code\n$raw";
break;
case 'list':
[$code,$json,$raw] = listSubscriptions($token);
$out = "List HTTP $code\n".json_encode($json, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
break;
case 'delete':
if (empty($cfg['subscriptionId'])) throw new Exception('No subscriptionId in config.json');
[$code,$json,$raw] = deleteSubscription($cfg['subscriptionId'],$token);
$out = "Delete HTTP $code\n$raw";
break;
}
} catch (Exception $e) {
$error = $e->getMessage();
}
}
?>
<!DOCTYPE html>
<html>
<body style="font-family: system-ui, sans-serif; max-width: 980px; margin: 24px auto;">
<h1>Graph Subscriptions — Dashboard</h1>
<p><a href="config.html">Edit Config</a></p>
<h2>config.json (safe view)</h2>
<pre style="background:#f8f8f8; padding:12px; border:1px solid #ccc;"><?php
$safe=$cfg; if(!empty($safe['client_secret'])) $safe['client_secret']='••••••';
echo h(json_encode($safe, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES));
?></pre>
<form method="post" style="display:flex; gap:8px; flex-wrap:wrap;">
<button name="action" value="create">Create</button>
<button name="action" value="renew">Renew</button>
<button name="action" value="list">List</button>
<button name="action" value="delete" onclick="return confirm('Delete current subscription?')">Delete</button>
</form>
<?php if ($notice): ?><div style="color:#065; margin-top:12px;">✅ <?=h($notice)?></div><?php endif; ?>
<?php if ($error): ?><div style="color:#b00; margin-top:12px;">❌ <?=h($error)?></div><?php endif; ?>
<?php if ($out): ?>
<h3>Result</h3>
<pre style="background:#f0f0f0; padding:12px; border:1px solid #ccc;"><?=h($out)?></pre>
<?php endif; ?>
<hr>
<h3>Webhook URL to use in <code>notificationUrl</code></h3>
<code><?php
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS']!=='off') ? 'https':'http';
$host = $_SERVER['HTTP_HOST'] ?? '<host>';
echo h("$scheme://$host/callback.php");
?></code>
</body>
</html>
- 6d) notifications.log
New File → name: notifications.log → Scope: Local → leave empty → Save.
7️⃣ Run the Workshop Object
- Click Run (or open the public link).
- Copy the public base URL (you’ll need it for notificationUrl in the next step).
Webhook quick-test (optional but recommended):
- Open: https:///callback.php?validationToken=ping
- Expected: it returns ping as plain text.
8️⃣ Fill the Form & Save (populate config.json)
- Open config.html and fill:
- Tenant ID / Client ID / Client Secret (from your Entra ID app)
- resource: /users('[email protected]')/mailFolders('Inbox')/messages
- notificationUrl: https:///callback.php
- clientState: secretToTest
- expirationDateTime: leave blank or set a valid future ISO 8601 UTC
- Click Save Config.
- Open index.php to verify your values show in the config.json (safe view).
9️⃣ Create / Test / Renew
- In index.php:
- Click Create → expect HTTP 201/200 and a JSON with "id" + "expirationDateTime".
- You’ll also see “Saved subscriptionId and expirationDateTime.”
- Click Create → expect HTTP 201/200 and a JSON with "id" + "expirationDateTime".
- Send a new email to the mailbox you targeted → open notifications.log and see a new line with the event JSON.
- Renew: set a later expirationDateTime in config.html (or directly in config.json) → click Renew.
- List/Delete: use the buttons as needed.
Tiny troubleshooting
- Create fails with reachability/validation: Make sure your notificationUrl is public HTTPS and the ping test returns ping.
- 401/403: Ensure the app has Graph Application permission (e.g., Mail.Read) and admin consent is granted.
- No notifications: Send a new email after creation; confirm the subscription is active via List.
Updated 1 day ago