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

  1. Register your App — see Level 0: Register Your App. You receive your App ID and OAuth credentials in the response.
  2. Authenticate — exchange your credentials for an access token using the OAuth client credentials flow.
  3. Create a webhook subscriptionPOST /webhooks/1.0/{sourcedApplicationId}/subscriptions with your consumer URL and the event types you want.
  4. Store the signing secret — the response includes a signingSecret (prefixed whsec_). This is shown exactly once. Store it in your secrets manager immediately.
  5. Implement your consumer endpoint — an HTTPS endpoint that verifies the signature and returns 2xx within 10 seconds.
  6. 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/scope resource 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
    …/subscriptions POST webhooks.write
    …/subscriptions GET webhooks.read
    …/subscriptions/{id} GET webhooks.read
    …/subscriptions/{id} PUT webhooks.write
    …/subscriptions/{id} DELETE webhooks.delete
    …/subscriptions/{id}/rotate-secret POST webhooks.write

    Each 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_client to its tenants and checks that at least one of those tenants holds the app:manage_webhooks tenant grant on urn:app:{sourcedApplicationId}. A caller whose tenants hold no such grant on the App receives 403.

  • 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 same auth_client that 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_webhooks on 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_user from the JWT and checks the user-keyed permissions_grant for app:manage_webhooks on urn: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 signingSecret is 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:

  • url must be a valid HTTPS URL with a domain name (HTTP is rejected with 400, raw IP addresses like https://1.2.3.4/hook are rejected with 400).
  • eventTypes must contain at least one entry, and every entry must be a recognized event type (see Event Types Reference). Unknown types are rejected with 400.

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: paused is a lossless pause — events accumulate and are delivered when you resume. disabled is a hard stop — events that occur while disabled are dropped. Use paused for planned maintenance windows; disabled is 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

  1. Extract the X-TimeBack-Webhook-Timestamp header value.
  2. Read the raw request body as a UTF-8 string (before JSON parsing).
  3. Concatenate: {timestamp}.{body} (the timestamp, a literal dot, then the raw body).
  4. Compute HMAC-SHA256 using your signingSecret as the key and the concatenated string as the message.
  5. Hex-encode the result.
  6. Compare the computed hex string against the X-TimeBack-Webhook-Signature header using a constant-time comparison function to prevent timing attacks.
  7. (Recommended) Reject deliveries whose timestamp is more than 5 minutes old (300 seconds) 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() (not express.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:

  1. Diagnose and fix your endpoint (check logs, DNS, TLS certificate, firewall rules).
  2. Re-enable the subscription: PUT /webhooks/1.0/{sourcedApplicationId}/subscriptions/{subscriptionId} with {"status": "active"}.
  3. 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:

  1. Verify the HMAC-SHA256 signature using your stored signingSecret.
  2. Check the timestamp is within 5 minutes.
  3. Deduplicate by id (check your database or cache).
  4. Return 200 OK immediately.
  5. 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 status is active (not paused or disabled).
  • Confirm the events being emitted match one of the eventTypes in your subscription.
  • Ensure your endpoint is publicly reachable from AWS us-east-1 (test with a curl from an EC2 instance or similar).
  • Check that your endpoint returns 2xx4xx responses 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:

  1. Subscribe to enrollment.created, enrollment.updated, enrollment.deleted via the webhook subscription API.
  2. 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.
  3. 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 like 05).
  • mastery.updated — the student's HMG advances (e.g., 0506) 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:

  1. Subscribe to mastery.created, mastery.updated, mastery.deleted via the webhook subscription API.
  2. 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.
  3. 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.


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.