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.”
  • 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.