Events
Three of the contract's nine events are sent today. Subscribing to one that is not yet sent is refused.
Framework releases (GET /v1/frameworks) send no event yet. To see a new one, list GET /v1/framework-versions?framework=… periodically.
| Event | Payload | Scope | Sent | Meaning |
|---|---|---|---|---|
| curriculum.version.published | curriculum_id, version, published_at | curriculum:read | Sent | A curriculum version was published — an erratum too, as a version one patch above. |
| curriculum.version.deprecated | curriculum_id, version, superseded_by? | curriculum:read | Sent | A version was deprecated, or superseded (with the version that replaces it). |
| outcome.changed | outcome_id, curriculum_version_id, diff_class, element_revision | outcome:read | Sent | An outcome changed: retreated (DEPRECATED), or corrected by an erratum (its diff class and new revision). |
| association.changed | association_id, curriculum_version_id, diff_class | association:read | Not yet | A graph relation changed. |
| alignment.approved | alignment_id, mapping_set_id, type | alignment:read | Not yet | A cross-curriculum alignment was approved. |
| mapping_set.needs_revalidation | mapping_set_id, curriculum_version_pair, reason | alignment:read | Not yet | A mapping set needs revalidation after a publish. |
| candidate.review_required | candidate_id, assertion_method, element_status | review:act | Not yet | A candidate awaits review (internal only). |
| license.changed | content_license_id, scope, affected_element_ids[] | curriculum:read | Not yet | Licensed content changed — ids only, no content. |
| tenant.access_revoked | tenant_id, access_entitlement_id, revoked_at | admin:tenant | Not yet | A tenant's access entitlement was revoked. |
There is no separate erratum event: an erratum arrives as curriculum.version.published of the patch version, with one outcome.changed for each outcome it corrected.
The developers page's “Send a test event” sends webhook.test, with the subscription's id, to that subscription alone.
Delivery
- At least once: the same event may arrive twice. Currixa-Event-Id is its dedupe key — the same on every retry and every replay — so process each id once.
- Every delivery is signed: HMAC-SHA256 over the time and the raw body, under your subscription's secret, in Currixa-Signature. Refuse a signature older than five minutes.
- A 2xx answer within 10 seconds is a success. Anything else — a timeout, a 3xx (redirects are never followed), a 4xx or 5xx — is retried 1, 5, 15, 30 and 60 minutes later. After the fifth retry the delivery is dead; you can send it again from the developers page.
- The payload carries ids and version numbers only — read the content from the API under your own scopes.
- An event reaches you only if your organisation can read its resource: public content, your own, or licensed content you hold an entitlement to. A test key's subscription receives public content only.
- The developers page lists every delivery of the last 30 days — its status, time and response code, never the response body.
Currixa-Event-Id: the event's id, its dedupe key.Currixa-Event-Type: the event's name.Currixa-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">.
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Currixa-Webhooks/1
Currixa-Event-Id: crx_whe_01912d3a-0000-7000-8000-000000000000
Currixa-Event-Type: curriculum.version.published
Currixa-Signature: t=1760400000,v1=5d41402abc4b2a76b9719d911017c592…
{"id":"crx_whe_01912d3a-0000-7000-8000-000000000000","type":"curriculum.version.published",
"created_at":"2026-10-14T08:00:00.000Z",
"data":{"curriculum_id":"crx_cur_01912d3a-0000-7000-8000-000000000001","version":"2026.1.0","published_at":"2026-10-14T08:00:00.000Z"}}The ids and the signature above are illustrative.
Verifying the signature
Compute HMAC-SHA256 of the time, a dot and the raw body exactly as received, under your secret, and compare it in constant time with v1. Never re-encode a parsed body: a single changed byte fails the check.
import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody: the request body exactly as it arrived (a Buffer), before any JSON parsing.
// header: the Currixa-Signature header. secret: your subscription's whsec_… secret.
export function verifyCurrixa(rawBody: Buffer, header: string, secret: string, toleranceSeconds = 300): boolean {
const parts = new Map(header.split(',').map((p) => [p.slice(0, p.indexOf('=')), p.slice(p.indexOf('=') + 1)]));
const t = Number(parts.get('t'));
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
const given = Buffer.from(parts.get('v1') ?? '', 'hex');
return given.length === expected.length && timingSafeEqual(given, expected);
}import hashlib
import hmac
import time
def verify_currixa(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
"""raw_body: the request body exactly as it arrived; header: Currixa-Signature."""
parts = dict(p.split("=", 1) for p in header.split(","))
t = int(parts.get("t", "0"))
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))# body.json holds the raw body; SIG the Currixa-Signature header; WEBHOOK_SECRET your whsec_… secret.
t=${SIG#t=}; t=${t%%,*}
v1=${SIG##*v1=}
calc=$( { printf '%s.' "$t"; cat body.json; } | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $NF}')
[ "$calc" = "$v1" ] && echo valid || echo invalidAddress safety
A subscription URL is HTTPS only, with no user name, password or fragment. Its name is resolved and every address it resolves to must be public — private, internal, loopback, link-local and cloud metadata addresses, and their IPv6 forms, are refused — when you subscribe and again before every delivery.
Currixa connects to the very address it just checked, so a name cannot be re-pointed in between. Redirects are not followed; the answer is read to 64 KiB at most and never kept.
Subscribing
Subscribe from your server with an API key that carries webhook:manage. The answer holds the signing secret once — keep it on your server; it is never shown again.
export CURRIXA_API_KEY="paste-your-key-here"
curl -s https://api.currixa.net/v1/webhooks \
-H "Authorization: Bearer $CURRIXA_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/currixa/webhooks","events":["curriculum.version.published","outcome.changed"]}'webhook:manage is never given by default: your organisation's owner grants it to an application on the developers page, then makes a key for it. A key sees and deletes only its own application's subscriptions (GET and DELETE /v1/webhooks/{id}); POST /v1/webhooks/{id}/rotate-secret makes a new secret, and the old one stops at once.
Your plan sets how many subscriptions your organisation may hold (webhook subscriptions on the pricing page).