Webhooks
Receive real-time notifications when events happen on the TimeBack Platform. Instead of polling APIs, register a webhook subscription and the platform will POST a signed JSON payload to your HTTPS endpoint within seconds of the event.
Quick Start
- Register your App — see Level 0: Register Your App. You receive your App ID and OAuth credentials in the response.
- Authenticate — exchange your credentials for an access token using the OAuth client credentials flow.
- Create a webhook subscription —
POST /webhooks/1.0/{sourcedApplicationId}/subscriptionswith your consumer URL and the event types you want. - Store the signing secret — the response includes a
signingSecret(prefixedwhsec_). This is shown exactly once. Store it in your secrets manager immediately. - Implement your consumer endpoint — an HTTPS endpoint that verifies the signature and returns
2xxwithin 10 seconds. - Go live — the platform starts delivering matching events to your endpoint within seconds of occurrence.
Prerequisites
- A registered App with an App ID (see Level 0: Register Your App)
- OAuth credentials (
clientId+clientSecret) from registration (see Authentication) - An HTTPS endpoint reachable from the public internet (HTTP is rejected, raw IP addresses are rejected — use a domain name)
Concepts
Subscriptions
A subscription tells the platform where to deliver events and which event types to include. Each subscription belongs to one App and has:
| Field | Type | Description |
|---|---|---|
id |
UUID | Platform-assigned subscription identifier. |
url |
string | Your HTTPS consumer endpoint. Must use https:// with a domain name (raw IP addresses are not allowed). |
eventTypes |
string[] | Event types to subscribe to (e.g., ["insight.created"]). Only events matching these types are delivered. |
status |
enum | active — receiving deliveries. paused — events are queued but held until you resume. disabled — terminal; set by the platform after repeated delivery failures. |
dateCreated |
ISO 8601 | When the subscription was created. |
Event Types
Each event type is a dot-separated string describing what happened (e.g., insight.created, user_profile.updated). Event types are registered by platform modules as they ship — your subscription only receives events matching the types you listed.
Available event types are listed in the Event Types Reference section below. The engine is live; production event types will be added incrementally as platform modules integrate with it.
Event Routing — Who Receives What
Event-type filtering is only half the story. For events tied to a specific entity (a user profile, an insight, an enrollment), the platform also enforces an org-scope check: your subscription receives the event if and only if the subscription's auth_client org scope covers the org(s) the event is attributed to. This is the same rule the REST APIs apply on reads — you receive a webhook iff a GET for the entity, called as the subscription's auth_client, would return it.
The org scope used for matching follows the subscription's auth_client_id, which depends on how the subscription was created:
- REST — the subscription is bound to your M2M client's
auth_client, so it inherits your client's org scope. - MCP — the subscription is bound to the App's
auth_client, so it inherits the App's org scope.
No additional configuration on your side. Subscribing to a broader event-type set is safe — events for entities outside the subscription's scope are silently filtered before delivery.
Self-Originated Event Suppression
When an event's originating auth_client_id equals a subscription's auth_client_id, the subscription is not notified for that event. The originator already knows the new state from the write response — a webhook telling them to re-fetch would be redundant.
Concretely:
- REST-created subscriptions suppress events whose originator is the same M2M client that created the subscription.
- MCP-created subscriptions suppress events whose originator is the App itself (since MCP subscriptions are owned by the App's
auth_client).
Other subscribers whose owner differs from the event's originator and whose org scope covers the event receive the delivery normally. Events emitted by platform-internal flows (scheduled jobs, system processes) are delivered to all matching subscribers.
This is a non-configurable default, consistent with established webhook systems (Stripe, GitHub).
Thin Payloads
Webhook payloads contain entity references only, not full entity data. This keeps payloads small, avoids stale-data issues, and respects access control — your consumer fetches the current state from the standard REST APIs using its own credentials and scopes.
For example, an insight.created event would include the insight ID and student ID, but not the insight text or student profile. Your consumer calls GET /insights/... with its own token to retrieve the full data.
Authentication for Webhook Management
Note: Webhook management (creating/updating subscriptions) uses OAuth/SSO tokens. Webhook deliveries (the POST requests to your endpoint) are authenticated differently — via HMAC-SHA256 signatures, not OAuth. See Verifying Signatures below.
The webhook management endpoints are reachable through two paths with separate authorization rules. Pick the one that matches your caller.
REST — M2M client credentials
Use this from your backend services, CI/CD pipelines, and any caller that authenticates as an OAuth M2M client.
-
Token: a Cognito access token obtained via the client credentials flow.
-
Per-endpoint scope: each endpoint requires a specific OAuth scope under the
https://timeback-platform.trilogy.com/webhooks/scoperesource server. A token without the required scope is rejected at the API Gateway before the request reaches the handler. The full mapping:Endpoint Method Required scope …/subscriptionsPOSTwebhooks.write…/subscriptionsGETwebhooks.read…/subscriptions/{id}GETwebhooks.read…/subscriptions/{id}PUTwebhooks.write…/subscriptions/{id}DELETEwebhooks.delete…/subscriptions/{id}/rotate-secretPOSTwebhooks.writeEach scope is published at
https://timeback-platform.trilogy.com/webhooks/scope/<scope-name>(e.g.…/scope/webhooks.read). -
Authorization: the platform resolves the caller's
auth_clientto its tenants and checks that at least one of those tenants holds theapp:manage_webhookstenant grant onurn:app:{sourcedApplicationId}. A caller whose tenants hold no such grant on the App receives403. -
Subscription ownership: the subscription is owned by the caller's
auth_client. Update / delete / rotate-secret / get on a subscription is permitted only to the sameauth_clientthat created it — even if a different caller also holds the tenant grant on the App. See Ownership invariant below.
Tenant-grant issuance: tenant grants are issued by the TimeBack Platform team and not exposed through a self-service write surface in this release. Contact platform support to have the tenant that owns your M2M client granted
app:manage_webhookson the App(s) you operate.
Developer-Platform MCP — developer SSO
Use this from AI-agent workflows (Cursor, Claude Desktop, etc.) via the developer-platform MCP. Six tools mirror the REST surface one-to-one:
| MCP tool | REST equivalent |
|---|---|
devplatform_create_webhook_subscription |
POST /webhooks/1.0/{sourcedApplicationId}/subscriptions |
devplatform_list_webhook_subscriptions |
GET /webhooks/1.0/{sourcedApplicationId}/subscriptions |
devplatform_get_webhook_subscription |
GET /webhooks/1.0/{sourcedApplicationId}/subscriptions/{subscriptionId} |
devplatform_update_webhook_subscription |
PUT /webhooks/1.0/{sourcedApplicationId}/subscriptions/{subscriptionId} |
devplatform_delete_webhook_subscription |
DELETE /webhooks/1.0/{sourcedApplicationId}/subscriptions/{subscriptionId} |
devplatform_rotate_webhook_subscription_secret |
POST /webhooks/1.0/{sourcedApplicationId}/subscriptions/{subscriptionId}/rotate-secret |
- Token: the developer's SSO JWT (not an M2M client-credentials token).
- OAuth scopes: not applicable — the MCP path runs in-process and bypasses the API Gateway authorizer entirely.
- Authorization: the platform resolves the developer's
one_roster_userfrom the JWT and checks the user-keyedpermissions_grantforapp:manage_webhooksonurn:app:{sourcedApplicationId}. This grant is issued automatically to the registering developer at App-registration time, so a developer who registered an App can manage its webhooks through MCP without further action. - Subscription ownership: subscriptions created via MCP are owned by the App's
auth_client. Every developer who holds the user-grant on the App can list / get / update / delete the MCP-created subscriptions for that App.
Pick whichever fits your workflow — REST for CI/CD pipelines and server-side automation; MCP for AI-agent-driven flows. The two paths produce subscriptions visible only to their own owners (see the matrix below); a subscription created via REST is not visible to MCP callers and vice versa.
Ownership invariant
app:manage_webhooks is a single coarse permission — "the caller can manage webhooks on this App." Update / delete / rotate-secret / get additionally verify that the subscription belongs to the caller, and list filters to only the caller's subscriptions. The full access matrix:
| Caller and operation | Subscription's auth_client_id |
Caller's authorized auth_client_id |
Outcome |
|---|---|---|---|
| REST client X manages X's own subscription | X | X | match → allowed |
| REST client X manages REST client Y's subscription (same App) | Y | X | mismatch → 404 |
| REST client X manages MCP-created subscription on App A | App A's auth_client_id |
X | mismatch → 404 |
| MCP developer manages MCP-created subscription on App A | App A's auth_client_id |
App A's auth_client_id |
match → allowed |
| MCP developer manages REST-created subscription on App A | REST caller's auth_client_id |
App A's auth_client_id |
mismatch → 404 |
list for REST client X |
filter WHERE auth_client_id = X |
— | returns only X's subscriptions |
list for MCP developer on App A |
filter WHERE auth_client_id = App A's |
— | returns all MCP-created on A |
A caller that is authorized on the App but addresses a subscription not owned by them receives 404 — the same status as a missing subscription, by design, so the existence of subscriptions belonging to other owners is not leaked.
Subscription Management
Create a Subscription
POST /webhooks/1.0/{sourcedApplicationId}/subscriptions
Request body:
{
"url": "https://your-service.example.com/webhooks",
"eventTypes": ["insight.created"]
}
Response (201):
{
"id": "eee11b2d-66db-4ed0-85c4-3438eccf733f",
"url": "https://your-service.example.com/webhooks",
"eventTypes": ["insight.created"],
"status": "active",
"dateCreated": "2026-06-13T08:37:30.542Z",
"signingSecret": "whsec_123a4ae3aa67fc9ed203aef225f8ee9abc28f43b..."
}
Important: The
signingSecretis only returned at creation time and when you explicitly rotate it. It is never included in list or get responses. Store it immediately — if you lose it, rotate via the rotate-secret endpoint.
Validation rules:
urlmust be a valid HTTPS URL with a domain name (HTTP is rejected with400, raw IP addresses likehttps://1.2.3.4/hookare rejected with400).eventTypesmust contain at least one entry, and every entry must be a recognized event type (see Event Types Reference). Unknown types are rejected with400.
List Subscriptions
GET /webhooks/1.0/{sourcedApplicationId}/subscriptions
Response (200):
[
{
"id": "eee11b2d-66db-4ed0-85c4-3438eccf733f",
"url": "https://your-service.example.com/webhooks",
"eventTypes": ["insight.created"],
"status": "active",
"dateCreated": "2026-06-13T08:37:30.542Z"
}
]
Returns all subscriptions for the App. The signing secret is never included.
Get a Subscription
GET /webhooks/1.0/{sourcedApplicationId}/subscriptions/{subscriptionId}
Returns a single subscription. The signing secret is never included.
Update a Subscription
PUT /webhooks/1.0/{sourcedApplicationId}/subscriptions/{subscriptionId}
Request body (all fields optional — include only what you want to change):
{
"url": "https://new-endpoint.example.com/webhooks",
"eventTypes": ["insight.created", "user_profile.updated"],
"status": "paused"
}
Status transitions:
| From | To | Effect |
|---|---|---|
active |
paused |
Delivery attempts stop, but the platform continues recording events for this subscription. No SQS messages are enqueued while paused — events queue silently in the DB. |
paused |
active |
All events that accumulated during the pause are flushed and delivered. No manual catch-up needed. |
disabled |
active |
Re-enables a subscription that the platform auto-disabled. Events that occurred while disabled are not retroactively delivered — catch up via the REST APIs. |
You cannot set status to disabled — that state is reserved for platform auto-disable after repeated delivery failures.
Paused vs Disabled:
pausedis a lossless pause — events accumulate and are delivered when you resume.disabledis a hard stop — events that occur while disabled are dropped. Usepausedfor planned maintenance windows;disabledis set automatically by the platform after terminal delivery failures.
Delete a Subscription
DELETE /webhooks/1.0/{sourcedApplicationId}/subscriptions/{subscriptionId}
Response: 204 No Content
Soft-deletes the subscription. No further deliveries are attempted. This action is not reversible — create a new subscription if you need to resume.
Rotate the Signing Secret
POST /webhooks/1.0/{sourcedApplicationId}/subscriptions/{subscriptionId}/rotate-secret
Response (200):
{
"id": "eee11b2d-66db-4ed0-85c4-3438eccf733f",
"url": "https://your-service.example.com/webhooks",
"eventTypes": ["insight.created"],
"status": "active",
"dateCreated": "2026-06-13T08:37:30.542Z",
"signingSecret": "whsec_newrotatedsecret..."
}
Generates a new signing secret and returns it. During the rotation window, both the old and new secrets are valid — in-flight deliveries signed with the previous secret still verify. Update your consumer to use the new secret as soon as possible.
When to rotate:
- Your secret may have been exposed (e.g., committed to a public repo).
- Your security policy requires periodic rotation.
- You lost the original secret and need a new one.
Delivery Format
When an event matches your subscription, the platform sends an HTTPS POST to your url with the following:
Headers
| Header | Value | Description |
|---|---|---|
Content-Type |
application/json |
Always JSON. |
X-TimeBack-Webhook-Timestamp |
Unix seconds (e.g., 1718267529) |
When the delivery was signed. Use for replay protection. |
X-TimeBack-Webhook-Signature |
Hex string (64 chars) | HMAC-SHA256 signature over the timestamp and body. |
Body (Envelope)
{
"id": "fec49ed7-2130-493b-95d6-089e91ffd92e",
"type": "insight.created",
"timestamp": "2026-06-13T08:42:09.204Z",
"data": {
"insightId": "abc123",
"insightType": "Socializing",
"userId": "def456",
"caliperSessionId": "https://timeback.com/sessions/2026-06-13T08-42-09-000Z-abc123"
}
}
| Field | Type | Description |
|---|---|---|
id |
UUID | Stable event identifier. The same id is sent to all matching subscriptions and across retries. Use it to deduplicate on your side. |
type |
string | The event type string (matches one of your subscribed eventTypes). |
timestamp |
ISO 8601 | When the event originally occurred on the platform. |
data |
object | Entity references specific to the event type. Contains IDs and URNs only — never full entity data. The shape of data varies by event type; see Event Types Reference. |
Verifying Signatures
Every delivery is signed with your subscription's signingSecret using HMAC-SHA256. Always verify signatures before processing a delivery — this confirms the request came from TimeBack and hasn't been tampered with.
Algorithm
- Extract the
X-TimeBack-Webhook-Timestampheader value. - Read the raw request body as a UTF-8 string (before JSON parsing).
- Concatenate:
{timestamp}.{body}(the timestamp, a literal dot, then the raw body). - Compute
HMAC-SHA256using yoursigningSecretas the key and the concatenated string as the message. - Hex-encode the result.
- Compare the computed hex string against the
X-TimeBack-Webhook-Signatureheader using a constant-time comparison function to prevent timing attacks. - (Recommended) Reject deliveries whose timestamp is more than 5 minutes old (
300seconds) to prevent replay attacks.
Node.js Example
import { createHmac, timingSafeEqual } from 'crypto';
function verifyWebhookSignature(secret, timestamp, body, signature) {
const payload = `${timestamp}.${body}`;
const expected = createHmac('sha256', secret).update(payload).digest('hex');
const expectedBuf = Buffer.from(expected, 'utf-8');
const signatureBuf = Buffer.from(signature, 'utf-8');
if (expectedBuf.length !== signatureBuf.length) return false;
return timingSafeEqual(expectedBuf, signatureBuf);
}
// Express.js handler example:
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.headers['x-timeback-webhook-timestamp'];
const signature = req.headers['x-timeback-webhook-signature'];
const body = req.body.toString('utf-8');
// 1. Verify signature
if (!verifyWebhookSignature(process.env.WEBHOOK_SECRET, timestamp, body, signature)) {
return res.status(401).send('Invalid signature');
}
// 2. Reject stale deliveries (replay protection)
const ageSeconds = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
if (ageSeconds > 300) {
return res.status(401).send('Timestamp too old');
}
// 3. Parse and process
const event = JSON.parse(body);
console.log(`Received ${event.type} event: ${event.id}`);
// 4. Deduplicate — check if you've already processed this event.id
// ...your deduplication logic here...
// 5. Respond 200 immediately, process asynchronously if needed
res.status(200).send('OK');
});
Gotcha: Use
express.raw()(notexpress.json()) to get the raw body bytes. If Express parses JSON first, the re-serialized body may differ from the original (key order, whitespace) and the signature won't match.
Python (Flask) Example
import hashlib
import hmac
import json
import time
from flask import Flask, request
app = Flask(__name__)
def verify_webhook(secret: str, timestamp: str, body: str, signature: str) -> bool:
payload = f"{timestamp}.{body}"
expected = hmac.new(
secret.encode(), payload.encode(), hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
@app.route('/webhooks', methods=['POST'])
def handle_webhook():
timestamp = request.headers.get('X-TimeBack-Webhook-Timestamp', '')
signature = request.headers.get('X-TimeBack-Webhook-Signature', '')
body = request.get_data(as_text=True)
if not verify_webhook(WEBHOOK_SECRET, timestamp, body, signature):
return 'Invalid signature', 401
if time.time() - int(timestamp) > 300:
return 'Timestamp too old', 401
event = json.loads(body)
print(f"Received {event['type']} event: {event['id']}")
# Deduplicate by event['id'] and process...
return 'OK', 200
Consumer Requirements
Your webhook consumer endpoint must satisfy all of the following:
| Requirement | Details |
|---|---|
| HTTPS only | HTTP URLs are rejected at subscription creation time. Self-signed certificates are not supported in production. |
| Respond within 10 seconds | The platform times out after 10 seconds. If your processing takes longer, return 200 immediately and process asynchronously (e.g., push to your own queue). |
Return 2xx on success |
Any 2xx status code (200, 201, 202, 204) marks the delivery as successful. |
| Be idempotent | The same event id may be delivered more than once (at-least-once delivery semantics). Always check the id field against your processed-events store before acting. |
| Verify signatures | Reject any request with an invalid or missing X-TimeBack-Webhook-Signature. See Verifying Signatures. |
| Publicly reachable | Your endpoint must be reachable from AWS us-east-1. Localhost URLs and private IPs will not work. |
Retry & Failure Behavior
| Your endpoint returns | Platform behavior |
|---|---|
2xx (200, 201, etc.) |
Delivery marked as delivered. Done. |
429 (Too Many Requests) |
Delivery stays pending and is retried — the platform respects your rate limit signal. |
408 (Request Timeout) |
Delivery stays pending and is retried — treated as a transient timeout. |
4xx (other: 400, 401, 403, etc.) |
Delivery marked as dead (terminal, no retry). The platform treats other 4xx as a permanent rejection. |
5xx (500, 502, 503, etc.) |
Delivery stays pending and is retried. Up to ~8 retry attempts over approximately 24 hours with exponential backoff (10 min → 20 min → 40 min → … → 6 h cap). |
| Timeout (no response in 10s) | Same as 5xx — retried with exponential backoff. |
| Connection refused / DNS failure | Same as 5xx — retried with exponential backoff. |
| All retries exhausted | Delivery marked as dead. The subscription is auto-disabled (status: "disabled") — the endpoint has been unreachable for the full retry window. |
Recovering from Auto-disable
If your subscription is auto-disabled after exhausting retries:
- Diagnose and fix your endpoint (check logs, DNS, TLS certificate, firewall rules).
- Re-enable the subscription:
PUT /webhooks/1.0/{sourcedApplicationId}/subscriptions/{subscriptionId}with{"status": "active"}. - Catch up on missed events. Events that occurred while the subscription was disabled are not retroactively delivered. Use the relevant REST APIs to poll for data you may have missed.
Why 4xx Is Terminal
Unlike 5xx (which indicates a transient server error), 4xx responses indicate a client-side issue — the consumer explicitly rejected the payload. Retrying the same payload to an endpoint that already returned 4xx wastes resources for both sides. Common causes:
401— your signature verification is rejecting valid signatures (check your secret).404— your endpoint URL changed but the subscription wasn't updated.400— your consumer has validation that rejects the payload shape.
If you receive unexpected 4xx rejections, check your consumer logs and update your subscription URL or signing secret as needed.
End-to-End Example
Here's the complete flow from subscription creation to processing a delivery:
Step 1 — Create the subscription (your backend, at setup time):
curl -X POST https://platform.timeback.com/webhooks/1.0/{sourcedApplicationId}/subscriptions \
-H "Authorization: Bearer <ACCESS_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-service.example.com/webhooks",
"eventTypes": ["insight.created"]
}'
Save signingSecret from the response to your secrets manager.
Step 2 — Platform delivers an event (automatic, when an insight is created):
POST /webhooks HTTP/1.1
Host: your-service.example.com
Content-Type: application/json
X-TimeBack-Webhook-Timestamp: 1718267529
X-TimeBack-Webhook-Signature: a1b2c3d4e5f6...
{
"id": "fec49ed7-2130-493b-95d6-089e91ffd92e",
"type": "insight.created",
"timestamp": "2026-06-13T08:42:09.204Z",
"data": {
"insightId": "abc123",
"insightType": "Socializing",
"userId": "def456",
"caliperSessionId": "https://timeback.com/sessions/2026-06-13T08-42-09-000Z-abc123"
}
}
Step 3 — Your consumer processes the delivery:
- Verify the HMAC-SHA256 signature using your stored
signingSecret. - Check the timestamp is within 5 minutes.
- Deduplicate by
id(check your database or cache). - Return
200 OKimmediately. - Asynchronously: fetch the full insight data via
GET /insights/...with your own OAuth token, and act on it.
Troubleshooting
"Unknown webhook event type(s)" when creating a subscription
The eventTypes you specified aren't recognized by the platform. Check the Event Types Reference for the current list. Event types are added incrementally as platform modules integrate with the webhook engine.
Signature verification fails on your consumer
- Ensure you're verifying against the raw request body string, not a re-serialized version. JSON parsers may reorder keys or alter whitespace, which changes the signature.
- Confirm your stored secret matches the one returned at subscription creation (or last rotation). Secrets start with
whsec_. - Check that you're concatenating as
{timestamp}.{body}(with a literal.between them).
Subscription was auto-disabled
Your endpoint failed to return 2xx for approximately 24 hours (8 retry attempts with exponential backoff). See Recovering from Auto-disable.
Not receiving deliveries
- Verify the subscription
statusisactive(notpausedordisabled). - Confirm the events being emitted match one of the
eventTypesin your subscription. - Ensure your endpoint is publicly reachable from AWS
us-east-1(test with acurlfrom an EC2 instance or similar). - Check that your endpoint returns
2xx—4xxresponses mark deliveries as terminal without retry.
Lost your signing secret
Rotate the secret: POST /webhooks/1.0/{sourcedApplicationId}/subscriptions/{subscriptionId}/rotate-secret. The new secret is returned in the response. Update your consumer immediately.
Event Types Reference
Event types are added as platform modules integrate with the webhook engine. Each event type documents its data shape so you know what entity references to expect.
| Event Type | Description | data Fields |
Since |
|---|---|---|---|
test.ping |
Synthetic event for verifying your integration end-to-end. | { "message": "..." } |
v1.0 |
insight.created |
A new insight became visible — either auto-confirmed at creation or confirmed via review (TruePositive). |
{ insightId, insightType, userId, caliperSessionId } |
v1.0 |
insight.updated |
An existing visible insight was updated (e.g., time-window extension from a new session). | { insightId, insightType, userId, caliperSessionId } |
v1.0 |
insight.deleted |
A visible insight was removed from the consumer's view (review reversed to FalsePositive). |
{ insightId, insightType, userId, caliperSessionId } |
v1.0 |
enrollment.created |
An enrollment became visible through GET /rostering/1.0/enrollments (new row or status/date change makes it active). |
{ enrollmentId, classId, userId } |
v1.1 |
enrollment.updated |
A visible enrollment was updated (role, beginDate, endDate, status, metadata, or primary flag changed). | { enrollmentId, classId, userId } |
v1.1 |
enrollment.deleted |
An enrollment is no longer returned by GET /rostering/1.0/enrollments (deleted or status set to inactive/tobedeleted). |
{ enrollmentId, classId, userId } |
v1.1 |
user_profile.created |
A user profile was created or became visible through GET /rostering/1.0/users/{sourcedId}/profiles. |
{ profileId, userId, applicationId } |
v1.0 |
user_profile.updated |
A user profile already visible through the rostering API was updated (fields changed, credentials rotated, status flipped). | { profileId, userId, applicationId } |
v1.0 |
user_profile.deleted |
A user profile is no longer returned by GET /rostering/1.0/users/{sourcedId}/profiles. |
{ profileId, userId, applicationId } |
v1.0 |
mastery.created |
A student first reached a mastered grade for a subject (HMG transitioned from null to a value). | { userId, subject, masteredGrade } |
v1.3 |
mastery.updated |
A student's highest mastered grade advanced or was corrected (HMG changed from one grade to another). | { userId, subject, masteredGrade } |
v1.3 |
mastery.deleted |
A student's mastery was retracted — a correction dropped below threshold (HMG went from a value to null). | { userId, subject, masteredGrade } |
v1.3 |
session.started |
A learning session started and is in progress (no end yet). | { caliperSessionId, userId, applicationId, isProctored } |
v1.2 |
session.ended |
A learning session ended (clean logout or timeout); fetch its final duration. | { caliperSessionId, userId, applicationId, isProctored } |
v1.2 |
Insight Events
The three insight.* events share the same data shape:
{
"insightId": "<uuid of the insight>",
"insightType": "<slug of the insight type, e.g. 'consumer-reported-anti-pattern'>",
"userId": "<uuid of the student the insight belongs to>",
"caliperSessionId": "<Caliper session IRI that triggered the insight — use as sessionId parameter for GET /insights/1.0/sessions/{sessionId}>"
}
An insight becomes visible (insight.created) when it is either auto-confirmed at creation (requiresReview = false) or when a reviewer marks it as TruePositive. If a reviewer later reverses the decision to FalsePositive, the insight leaves the consumer's view and an insight.deleted event fires.
Per the Event Routing rule, your subscription receives an insight.* event iff your auth client's expanded org scope covers the student's primaryOrgId.
Enrollment Events
The three enrollment.* events share the same data shape:
{
"enrollmentId": "<uuid of the enrollment>",
"classId": "<uuid of the class the enrollment belongs to>",
"userId": "<uuid of the enrolled user>"
}
An enrollment becomes visible (enrollment.created) when a new enrollment row is created or when a status/date change makes an existing enrollment active in the OneRoster API view. Updates to any public enrollment field (role, beginDate, endDate, status, metadata, primary flag) fire enrollment.updated. When an enrollment is deleted or its status is set to tobedeleted/inactive, it leaves the API view and enrollment.deleted fires.
Fetch the current state of the enrollment with your own OAuth token:
GET /rostering/1.0/enrollments/{enrollmentId}
Per the Event Routing rule, your subscription receives an enrollment.* event iff your auth client's expanded org scope covers the enrollment's class school org.
Poll-sync cutover
Enrollment webhooks replace the 30-minute polling pattern used by consumers like P-DASH. The recommended cutover sequence:
- Subscribe to
enrollment.created,enrollment.updated,enrollment.deletedvia the webhook subscription API. - Burn-in — run webhooks and polling in parallel to verify webhook coverage matches the polling results. Compare webhook-received enrollments against a polling sweep to confirm parity.
- Retire the polling timer once burn-in confirms parity. The webhook system delivers events within seconds of the enrollment change.
Mastery Events
The three mastery.* events share the same data shape:
{
"userId": "<uuid of the student>",
"subject": "<subject title, e.g. 'Math'>",
"masteredGrade": "<CEDS grade code of the highest mastered grade, or null for mastery.deleted>"
}
Mastery events track the Highest Grade Mastered (HMG) — a derived value representing the highest grade level a student has mastered in a given subject. The three event types correspond to HMG transitions:
mastery.created— the student first reaches a mastered grade for this subject (HMG goes from null to a grade code like05).mastery.updated— the student's HMG advances (e.g.,05→06) or is corrected to a different grade.mastery.deleted— mastery is retracted due to a data correction (HMG goes from a grade back to null). This is rare and indicates the underlying passing results were withdrawn.
Fetch the current HMG with your own OAuth token:
GET /beyond/1.0/educator/students/grades?studentId={userId}&subject={subject}
Per the Event Routing rule, your subscription receives a mastery.* event iff your auth client's expanded org scope covers the student's primaryOrgId.
Tie-break enforcement
The masteredGrade in the payload always reflects the same resolved value that the HMG API returns. When platform-derived data conflicts with source data (e.g., a grade is recorded but no supporting passing sitting exists), the mastery-transition evaluator resolves the conflict at emit time — the payload carries the placement-authoritative value, ensuring webhook consumers and API consumers see the same grade.
Poll-sync cutover
Mastery webhooks replace the 30-minute polling pattern used by K-8 consumers. The recommended cutover sequence:
- Subscribe to
mastery.created,mastery.updated,mastery.deletedvia the webhook subscription API. - Burn-in — run webhooks and polling in parallel. Compare the webhook-delivered mastery state against a polling sweep of the HMG API for all students to confirm parity.
- Retire the polling timer once burn-in confirms parity. The webhook system delivers events within seconds of the placement change.
User Profile Events
The three user_profile.* events share the same data shape:
{
"profileId": "<uuid of the profile>",
"userId": "<uuid of the user the profile belongs to>",
"applicationId": "<uuid of the linked App, or null>"
}
applicationId is null for profiles that are not bound to a specific learning App (users can hold one un-linked profile in addition to per-App profiles).
Fetch the current state of the profile with your own OAuth token:
GET /rostering/1.0/users/{userId}/profiles/{profileId}
Per the Event Routing rule, your subscription receives a user_profile.* event iff your auth client can see the user — that is, iff your expanded org scope covers the user's primaryOrgId or any of the user's role-attached orgs.
Session Events
The two session.* events share the same data shape:
{
"caliperSessionId": "<Caliper session IRI; use as the sessionId parameter for GET /events/1.0/sessions/{sessionId}/events>",
"userId": "<uuid of the user whose session it is>",
"applicationId": "<uuid of the application the session ran under; falls back to the session's client id when the app cannot be resolved, and is an empty string only when the session has no client id>",
"isProctored": "<true if the session's application has proctoring enabled; not a guarantee a proctored test was actually taken>"
}
applicationId and isProctored let a subscriber tell, from the event payload alone, which application the session belongs to and whether that application has proctoring enabled — without a follow-up GET /events/1.0/sessions/{sessionId}/events call. Both fields are identical on the session.started and session.ended events. isProctored is derived from the application's proctoring configuration at emit time (the same criterion that gates proctoring at launch), so it is available the instant the session starts; it is false when the originating application cannot be resolved.
What isProctored does and does not tell you. It signals only that the session ran under a proctoring-enabled application — not that a proctored assessment was actually taken or completed. At session start this is inherent: the payload cannot predict whether the student will take a proctored test. It also holds at session.ended, because the flag is not currently bound to actual test-taking — a session that merely opened the proctored application and closed it without taking a test still reports isProctored: true. The value reflects the application, not the assessment. This matches the platform's REST behavior: the same app-level signal backs SessionOutput.isProctored in the session insights API, so the webhook does not diverge from REST; it surfaces the signal earlier.
session.started fires when a learning session is first recorded while still open: the start arrives while the session is in progress, so the session has no end yet (endedAtTime is null). Derive the live in-progress duration from startedAtTime, and treat the first multi-minute session.started as the "student is active" marker. session.ended fires once when the session is closed by a clean logout or an inactivity timeout. A session that only extends its activity window (heartbeats, intermediate activity events) does not emit any session.* event — only the open and the close transitions do.
Fetch the current state of the session with your own OAuth token:
GET /events/1.0/sessions/{sessionId}/events
This reads the Caliper session directly (the same layer the events are emitted from): while the session is open it returns endedAtTime: null, and after logout/timeout it returns the populated endedAtTime. A richer per-session summary is available through GET /insights/1.0/sessions only for sessions whose insights processing is enabled; the session.* contract above always reflects the Caliper layer.
Per the Event Routing rule, your subscription receives a session.* event iff your auth client's expanded org scope covers the session user's primaryOrgId or an org where the user holds an active student role.
Tie-Break Rules
When platform-derived data conflicts with source data (e.g., the Highest Grade Mastered computation says G5 but no passing G5 sitting exists in the source records), authoritative tie-break rules determine which value the platform serves and emits in webhook payloads.
Current Rules
| Conflict | Authoritative Source | Enforcement Point |
|---|---|---|
| Enrollment start date: SIS import vs EduBridge enroll | SIS import (later write wins) | OneRosterEnrollmentRepository.upsert — metadata merge preserves earlier values unless explicitly overwritten |
| Enrollment status: concurrent writes from different sources | Last-writer-wins with transactional consistency | OneRosterEnrollmentRepository.upsert — TypeORM save inside a serializable transaction; last committed write wins |
| HMG vs sitting data: placement says G5 but no passing G5 sitting exists | Placement grade (set by PlacementService.completeStudentSubjectPlacement) | MasteryTransitionEvaluatorService — reads the placement grade before/after the write and emits the placement-authoritative value in mastery.* payloads |
Future Tie-Break Rules (deferred)
The following rules will be defined and enforced when their entities become first-class:
- MAP/NWEA RIT scores — when raw MAP data is stored as a first-class entity (
map.*event work), tie-break rules for conflicting RIT scores from different test windows will be documented here.
The single enforcement point for each conflict is the service method that computes or emits the relevant event payload. This prevents "tie-break drift" where multiple codepaths disagree on which value wins.
Related Docs
Level 0: Register Your App
Register your App to get an App ID and OAuth credentials — the prerequisite for creating webhook subscriptions.
Authentication
Exchange your OAuth credentials for access tokens to call the webhook management endpoints.
App Lifecycle
Understand draft vs active tiers and how they affect your App's capabilities, including webhook subscriptions.
API Reference
Interactive API reference with full request/response schemas for all webhook endpoints.
