Webhooks

Currixa calls your server when a curriculum version is published or deprecated, or an outcome changes — signed, retried, and only for what your organisation can read.

Available

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.

The nine contract events, and which are sent today
EventPayloadScopeSentMeaning
curriculum.version.publishedcurriculum_id, version, published_atcurriculum:readSentA curriculum version was published — an erratum too, as a version one patch above.
curriculum.version.deprecatedcurriculum_id, version, superseded_by?curriculum:readSentA version was deprecated, or superseded (with the version that replaces it).
outcome.changedoutcome_id, curriculum_version_id, diff_class, element_revisionoutcome:readSentAn outcome changed: retreated (DEPRECATED), or corrected by an erratum (its diff class and new revision).
association.changedassociation_id, curriculum_version_id, diff_classassociation:readNot yetA graph relation changed.
alignment.approvedalignment_id, mapping_set_id, typealignment:readNot yetA cross-curriculum alignment was approved.
mapping_set.needs_revalidationmapping_set_id, curriculum_version_pair, reasonalignment:readNot yetA mapping set needs revalidation after a publish.
candidate.review_requiredcandidate_id, assertion_method, element_statusreview:actNot yetA candidate awaits review (internal only).
license.changedcontent_license_id, scope, affected_element_ids[]curriculum:readNot yetLicensed content changed — ids only, no content.
tenant.access_revokedtenant_id, access_entitlement_id, revoked_atadmin:tenantNot yetA 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>">.
One delivery
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.

TypeScript (Node.js)
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);
}
Python
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", ""))
curl + openssl
# 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 invalid

Address 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.

POST /v1/webhooks
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).