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 about a minute 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 about a minute 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.
Empty-orgIds carve-out: some event families (notably application.*) attribute to no org. Those events are delivered only to subscriptions whose auth client is internal to the platform. An org-scoped external client can subscribe to those types, and may be able to GET the entity, but will never receive a delivery for them.
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.
Delete events are the exception that still fits that rule: after the entity leaves the API view, a GET cannot resolve it, so the payload may include a tombstone identifier the consumer already keys on (for user.deleted, the user's email) alongside the entity id.
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": "PhysicalVerbalSocializing",
"insightGroup": "DistractionFreeSpace",
"userId": "def456",
"caliperSessionId": "https://timeback.com/sessions/2026-06-13T08-42-09-000Z-abc123",
"rolloutState": "NOTIFY"
}
}
| 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, plus the small number of immutable classification values documented per event type — never mutable 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": "PhysicalVerbalSocializing",
"insightGroup": "DistractionFreeSpace",
"userId": "def456",
"caliperSessionId": "https://timeback.com/sessions/2026-06-13T08-42-09-000Z-abc123",
"rolloutState": "NOTIFY"
}
}
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, insightGroup, userId, caliperSessionId, rolloutState } |
v1.0 |
insight.updated |
An existing visible insight was updated (e.g., time-window extension from a new session). | { insightId, insightType, insightGroup, userId, caliperSessionId, rolloutState } |
v1.0 |
insight.deleted |
A visible insight was removed from the consumer's view (review reversed to FalsePositive). |
{ insightId, insightType, insightGroup, userId, caliperSessionId, rolloutState } |
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.created |
A user became visible through GET /rostering/1.0/users/{sourcedId}. |
{ userId } |
v1.4 |
user.updated |
A user already visible through the rostering API; subsequent successful upserts fire this type. | { userId } |
v1.4 |
user.deleted |
A user is no longer returned by GET /rostering/1.0/users/{sourcedId}. |
{ userId, email } |
v1.4 |
user_role.created |
A role became visible through GET /rostering/1.0/users/{sourcedId}/roles. |
{ roleId, userId, orgId } |
v1.4 |
user_role.updated |
A role already visible through the rostering API changed (role, type, or org). | { roleId, userId, orgId } |
v1.4 |
user_role.deleted |
Role membership ended (soft-deleted); the historical row may still appear on GET with endDate set. |
{ roleId, userId, orgId } |
v1.4 |
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 |
application.created |
An application became visible through GET /applications/1.0/applications. |
{ applicationId } |
v1.4 |
application.updated |
An application already visible through the applications API was updated (launch, metadata, proctoring, or catalog fields). | { applicationId } |
v1.4 |
application.deleted |
An application is no longer returned by GET /applications/1.0/applications. |
{ applicationId } |
v1.4 |
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 |
test.started |
A student started a test (AssessmentEvent Started). |
{ userId, applicationId, assessmentId, caliperSessionId, attemptId, action, isProctored } |
v1.5 |
test.ended |
A student finished a test by submitting (AssessmentEvent Submitted). |
{ userId, applicationId, assessmentId, caliperSessionId, attemptId, action, isProctored } |
v1.5 |
test.exited |
A student left a test without submitting (AssessmentEvent Abandoned). |
{ userId, applicationId, assessmentId, caliperSessionId, attemptId, action, isProctored } |
v1.5 |
proctoring_enforcement.recorded |
A proctoring enforcement event was newly recorded for a student (warning, lockout, or related action). The kind is in data.enforcementType. |
{ enforcementEventId, enforcementType, userId, caliperSessionId, occurredAt, lockoutId, triggeringInsightId } |
v1.6 |
org.config.updated |
An organization's config row set changed via PUT /rostering/1.0/orgs/{sourcedId}/config (any registered or unregistered key write). |
{ orgId } |
v1.7 |
Insight Events
The three insight.* events share the same data shape:
{
"insightId": "<uuid of the insight>",
"insightType": "<slug of the insight type, e.g. 'PhysicalVerbalSocializing'>",
"insightGroup": "<Insight Group slug for the type, e.g. 'DistractionFreeSpace', or null when the type has no group (e.g. Journal)>",
"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}>",
"rolloutState": "<NOTIFY or ENFORCE — SHADOW findings produce no event; remaining nulls will be filled to SHADOW>"
}
insightGroup is the stable group slug, not the display name. Resolve labels via GET /insights/1.0/insight-groups.
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.
A finding whose detector was in shadow for that student's school when it fired produces no insight.* event. New findings default to SHADOW when no snapshot stage is available. Remaining rolloutState nulls will be filled to SHADOW.
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 from a polled outbox, so expect delivery on the order of a minute after the enrollment change — not within seconds.
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 from a polled outbox, so expect delivery on the order of a minute after the placement change — not within seconds.
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.
User Events
user.created and user.updated share this data shape:
{
"userId": "<uuid of the user>"
}
user.deleted adds the email the user held at deletion time, captured before the delete commits. After this event the user is no longer returned by GET, so the email is a tombstone identifier, not a snapshot of current state:
{
"userId": "<uuid of the user>",
"email": "<email the user held at deletion>"
}
A user becomes visible (user.created) when a new row is upserted through PUT /rostering/1.0/users/{sourcedId} for an id that did not previously exist. Subsequent upserts fire user.updated. When a user is deleted, they leave the API view and user.deleted fires.
Cascade delete of a user (or student) hard-removes related roles, profiles, enrollments, and agents in the same transaction, but emits only user.deleted — there is no child user_role.deleted / user_profile.deleted / enrollment.deleted fan-out on that path. Subscribers that care about those child resources must treat user.deleted as the cleanup signal for cascade, or listen to the dedicated child endpoints when those resources are ended individually.
Fetch the current state of a still-visible user with your own OAuth token:
GET /rostering/1.0/users/{userId}
After user.deleted that GET returns nothing. Use data.email when your own state is keyed by email.
Per the Event Routing rule, your subscription receives a user.* event iff your auth client's expanded org scope covers the user's primaryOrgId or any of the user's role-attached orgs.
User Role Events
The three user_role.* events share the same data shape:
{
"roleId": "<uuid of the role>",
"userId": "<uuid of the user the role belongs to>",
"orgId": "<uuid of the organization the role is scoped to>"
}
A role becomes visible (user_role.created) when it is upserted through PUT /rostering/1.0/users/{sourcedId}/roles/{roleId} for an id that did not previously exist, or when a user upsert's roles list creates a new membership. The dedicated role PUT emits user_role.created / user_role.updated for role, type, or org changes. Syncing a user's roles list emits user_role.deleted / user_role.created only for membership-key changes (roleType + role + org); date-only diffs on a live triple do not emit. Soft-ending a role (dedicated DELETE or sync removal) fires user_role.deleted; the historical row may still appear on GET with endDate set.
Fetch the current state of the user's roles with your own OAuth token:
GET /rostering/1.0/users/{userId}/roles
Per the Event Routing rule, your subscription receives a user_role.* event iff your auth client's expanded org scope covers the user's org memberships or the affected role's own org. On delete the role's org is still attributed even when that membership has already left the user's live roles list.
Application Events
The three application.* events share the same data shape:
{
"applicationId": "<uuid of the application>"
}
An application becomes visible (application.created) when it is first upserted through PUT /applications/1.0/applications/{sourcedId}. Subsequent upserts fire application.updated. Deleting an application fires application.deleted.
Fetch the current state of the application with your own OAuth token:
GET /applications/1.0/applications/{applicationId}
Applications carry no org attribution. These events are delivered only to subscriptions whose auth client is internal to the platform; an org-scoped external subscription can subscribe to them but will never receive one.
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.
Test Events
The three test.started / test.ended / test.exited events share the same data shape. They are not related to test.ping: test.ping is the synthetic event for verifying your integration's delivery plumbing and carries no student data; these three are student assessment events. The shared test. prefix is not a family relationship.
{
"userId": "<uuid of the student — the Caliper actor>",
"applicationId": "<uuid of the application the test ran under; falls back to the event's edApp id when the app cannot be resolved, and is an empty string only when neither is present>",
"assessmentId": "<id of the assessment object the event names>",
"caliperSessionId": "<Caliper session IRI, or null when the assessment event carries no session — do not key on this field>",
"attemptId": "<Caliper Attempt id when the event carries `generated`, otherwise the Caliper event id — sitting identity for this delivery>",
"action": "<Started | Submitted | Abandoned — the Caliper action that triggered this delivery>",
"isProctored": "<true if the application's proctoring configuration is enabled; not a guarantee this particular test was proctored>"
}
caliperSessionId is null when the assessment event carries no session. Some learning apps (notably NWEA MAP) omit session attribution on a substantial share of assessment events, so a consumer must not require this field.
attemptId distinguishes two sittings that would otherwise share the same student, application, and assessment. The dispatcher coalesces pending outbox rows that share an event type and identical data within one drain window (~1 minute); without attemptId, two NWEA test.started events with caliperSessionId: null in the same window would collapse to one delivery.
isProctored describes the application's configuration at emit time — the same criterion that gates proctoring at launch — not whether this particular test was proctored or completed under proctoring. It is false when the originating application cannot be resolved. Standardized-test producers (Alpha Test, NWEA MAP) send a synthetic Caliper edApp id that is not the LTI application id; the platform resolves the application from the attached session's clientId first, then the edApp id, then the edApp name (Alpha Test / NWEA MAP).
What test.exited does and does not cover. It fires when the learning application reports that the student abandoned the test (AssessmentEvent with action Abandoned), so it covers an abandon the app itself detects and reports. It does not cover every way a student can fail to finish — an app that never sends the abandon signal produces no test.exited, and a student whose device dies mid-test is invisible to it. A consumer that needs "unfinished" as a state must reconcile starts against ends itself rather than treating test.exited as a complete census. Reviewed, Restarted, Reset, Paused, and Resumed AssessmentEvent actions map to no webhook.
Deliveries are dispatched from a polled outbox, so expect delivery on the order of a minute after the platform ingests the assessment event — not within seconds.
Per the Event Routing rule, your subscription receives a test.* assessment event iff your auth client's expanded org scope covers the student's primaryOrgId or an org where the student holds an active student role.
Proctoring Enforcement Events
The single proctoring_enforcement.recorded event covers every enforcement kind listed below. Subscribers filter by data.enforcementType client-side; there is no per-kind event type.
{
"enforcementEventId": "<uuid of the enforcement-event row — same as REST `id`; joins to GET enforcement-event reads>",
"enforcementType": "<one of the kinds listed below>",
"userId": "<uuid of the student the enforcement action applies to>",
"caliperSessionId": "<Caliper session IRI associated with the enforcement action>",
"occurredAt": "<ISO 8601 timestamp of the enforcement action>",
"lockoutId": "<uuid of the related lockout, or null when the kind has no lockout>",
"triggeringInsightId": "<uuid of the insight that triggered the action, or null when none>"
}
enforcementEventId uniquely identifies the recorded enforcement row (the REST id). It is required so append-only records that share the other keys do not coalesce in the outbox drain.
enforcementType values:
| Value | Meaning |
|---|---|
prevented_start |
A launch was blocked because an active lockout was in force. |
warning_accrued |
A strike was accrued server-side against the student. |
warning_displayed |
The student confirmed they saw the warning modal. |
warning_released |
An accrued strike was given back (for example after a dispute). |
forced_end |
The session was force-ended and a lockout began. |
locked_out_restart |
The student attempted to restart while still locked out. |
warnings_reset |
A guide reset the student's warning strikes for the session. |
warning_budget_granted |
A guide granted additional warning budget for the session. |
blocked_mid_test |
The platform recorded that the app's mid-test readiness gate hard-blocked the student for this occurrence. Minted at insight ingest; not client-writable. |
unblocked_mid_test |
The client recorded that the mid-test readiness dialog dismissed and the student resumed. Append-only. |
exited_mid_test |
The client recorded that the student left the mid-test readiness screen without resuming. Append-only. |
lockoutId and triggeringInsightId are null for kinds that have no lockout or triggering insight. The REST enforcement-event reads remain the source for full internal state; this payload is references only.
Per the Event Routing rule, your subscription receives a proctoring_enforcement.recorded event iff your auth client's expanded org scope covers the organization the enforcement event was recorded against.
Org Config Events
The single org.config.updated event fires when an organization's config row set is written through PUT /rostering/1.0/orgs/{sourcedId}/config. Any registered or unregistered key write is included. A write that sets multiple keys fires one event for the whole write, not one event per key. A no-op write (empty body) does not fire.
{
"orgId": "<uuid of the organization whose config changed>"
}
Fetch the current config with your own OAuth token:
GET /rostering/1.0/orgs/{orgId}/config
The envelope carries references only; refetch the config from that endpoint for current values.
Per the Event Routing rule, your subscription receives an org.config.updated event iff your auth client's expanded org scope covers the organization being written.
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.
