Teams docs
Session webhook
Each Mac posts a JSON event to your URL when a session starts and when it ends. Requests are signed so you can tell they came from your Macs.
Events
session.started and session.ended, sent as a POST with Content-Type: application/json.
{
"event": "session.ended",
"id": "5e0b6b0e-1f0f-4c1a-9a63-2c0d7c4a1e55",
"occurredAt": "2026-10-02T07:00:03Z",
"device": { "id": "mac_0b4b5e2c-…", "label": "Build Mac 1", "appVersion": "1.4.0" },
"session": {
"startedAt": "2026-10-01T23:00:00Z",
"endedAt": "2026-10-02T07:00:03Z",
"source": "schedule",
"sourceName": "Overnight agents",
"allowsDisplaySleep": true,
"staysAwakeWithLidClosed": false,
"endCause": "scheduleEnded",
"awakeSeconds": 28803,
"awaySeconds": 27950
}
}
idis unique per delivery. Retries reuse it, so you can ignore one you've already handled.sourceismanual,schedule, ortrigger;sourceNameis the schedule or trigger's name.endsAtappears when the session has an end time.endedAt,endCause,awakeSeconds, andawaySecondsappear onsession.ended.endCauseis one ofyou,timeUp,appQuit,downloadFinished,lowBattery,unplugged,triggerEnded,scheduleEnded,schedulePaused,policyLimit,replaced,midnightOilQuit.
Verifying the signature
The profile builder requires a signing secret (except for Slack), and each request carries X-MidnightOil-Signature: t=<unix seconds>,v1=<hex>. v1 is the HMAC-SHA256 of <t>.<raw body> using your secret, the same scheme as Stripe's. Compare in constant time, and reject timestamps more than five minutes old.
// Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, header, secret) {
const { t, v1 } = Object.fromEntries(header.split(",").map((part) => part.split("=")));
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return v1.length === expected.length && timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}
# Python
import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(part.split("=", 1) for part in header.split(","))
if abs(time.time() - int(parts["t"])) > 300:
return False
expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
Delivery
- Your URL must use
https; plainhttpis allowed only to the Mac itself, for a local relay. Redirects aren't followed. - Each retry first checks the license and URL are still in place, so removing either stops further attempts.
- Answer with any 2xx. Network errors, 429, and 5xx are retried after 2 seconds, 10 seconds, and a minute; other responses aren't.
- When Midnight Oil quits mid-session, it waits up to three seconds to send
session.ended.
Slack
Use a Slack incoming-webhook URL (https://hooks.slack.com/…) and Macs post readable messages instead, like “🔥 Build Mac 1 is staying awake on the “Overnight agents” schedule.” Slack messages aren't signed.