Webhooks
Instead of polling the catalog, you can register a webhook receiver and have the
Data API POST an event to your endpoint whenever a learning opportunity changes.
Snapshot events carry the full public resource snapshot, so most consumers can
apply them directly without a follow-up read. Delete events carry data: null.
Webhook receivers are currently provisioned manually for internal consumers, alongside API keys. Self-serve receiver management is planned but not yet available.
Event types
You subscribe a receiver to one or more event types. A receiver with no subscription set receives all of them.
| Type | When it fires |
|---|---|
LEARNING_OPPORTUNITY_PUBLISHED | A learning opportunity becomes publicly visible. |
LEARNING_OPPORTUNITY_UPDATED | A published learning opportunity's public data changes. |
LEARNING_OPPORTUNITY_ARCHIVED | A learning opportunity is archived and is no longer publicly visible. |
LEARNING_OPPORTUNITY_DELETED | A learning opportunity is deleted. |
Changes to records that are not publicly visible are suppressed: you only receive events that correspond to a change a public consumer can observe.
Event payload
Every delivery is a POST with a JSON body in this shape:
{
"id": "5f0d8f3e-0b6a-4a3e-9b1a-2c9b1e7d4f21",
"type": "LEARNING_OPPORTUNITY_UPDATED",
"occurredAt": "2026-07-01T12:34:56.000Z",
"resource": {
"type": "LEARNING_OPPORTUNITY",
"id": "9c3a1b2d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"
},
"data": {
// Full public learning opportunity resource, identical in shape to a
// GET /v1/learning-opportunities/{id} result.
}
}
idis stable across delivery attempts and redeliveries of the same change. Dedupe on it — treat delivery as at-least-once.resource.idmatches the/v1resource id, so you can reconcile against the REST API.dataholds the full snapshot at event time for published/updated/archived events, and isnullforLEARNING_OPPORTUNITY_DELETED(the resource no longer exists).
Delivery headers
| Header | Value |
|---|---|
content-type | application/json |
user-agent | Snowday-Data-API-Webhooks/1 |
x-snowday-event-id | The event id, so you can dedupe without parsing the body. |
x-snowday-event-type | The event type, so you can route without parsing the body. |
x-snowday-timestamp | Delivery timestamp (epoch milliseconds), part of the signature. |
x-snowday-signature | HMAC signature of the delivery (only when a signing secret is set). |
Verifying signatures
If your receiver is configured with a signing secret, every delivery includes an
x-snowday-signature header of the form sha256=<hex>. The signature is an
HMAC-SHA256, keyed with your shared secret, over the string
"{x-snowday-timestamp}.{raw request body}".
To verify a delivery:
- Read the
x-snowday-timestampandx-snowday-signatureheaders. - Recompute
HMAC_SHA256(secret, timestamp + "." + rawBody)over the raw request body bytes (before any JSON parsing/re-serialization). - Compare in constant time against the hex in the header.
- Reject deliveries whose timestamp is outside your tolerance window (for example a few minutes) to bound replay.
import { createHmac, timingSafeEqual } from "node:crypto"
function isValidSignature(
secret: string,
timestamp: string,
rawBody: string,
signatureHeader: string,
): boolean {
const expected = `sha256=${createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex")}`
const a = Buffer.from(signatureHeader)
const b = Buffer.from(expected)
return a.length === b.length && timingSafeEqual(a, b)
}
Responding
Return a 2xx status to acknowledge receipt. Any non-2xx response, a
connection error, or a timeout is treated as a failed attempt and retried with
exponential backoff. After the configured maximum attempts, the delivery is
dead-lettered and no longer retried.
Because deliveries are retried and can be redelivered, your handler must be
idempotent: dedupe on the event id. For snapshot events, apply data as
an upsert keyed by resource.id; for LEARNING_OPPORTUNITY_DELETED (where
data is null), remove the record keyed by resource.id instead.
Acknowledge quickly (persist the raw event and return 2xx), then process
asynchronously. Slow handlers risk hitting the delivery timeout and being
retried even though they eventually succeeded.