Federation Protocol
The Academy does not host federated games. Each game runs on its own infrastructure — mobile native, browser, decentralised platform, AR app — and reports completions to the Academy through a uniform signed-webhook protocol. This guide is the protocol's contract.
The protocol is intentionally small and forgiving:
- One transport. HTTPS POST to two endpoints. No socket, no queue, no SDK lock-in.
- One signature scheme. HMAC-SHA256 over
<timestamp>.<raw-body>, sent in two headers. No JWT, no JOSE, no key-discovery dance. - One identity-linking handshake. A Fellow generates a token in the Academy and enters it in your game; your game posts it back signed.
- No requirement on your data model. You decide what an attestable accomplishment is in your game; you map it to one of the discipline's three quests.
The reference client at packages/federation-client-reference/ is the canonical executable version of every example here.
1. Registration
Before your game can federate, an Academy admin registers a row in federated_apps:
- slug — short, lowercase, hyphenated. The same value your client sends in
federated_app_slug. - display_name — the human-legible name shown on the Fellow's Profile and as the source badge on the monad.
- public_key — the shared secret used to sign webhook requests. Despite the name, the scheme is symmetric. The admin stores this in your game's environment.
- discipline_id — the discipline you map to. Every
quest_slugyou send must belong to this discipline. - is_active — false until your client is verified end-to-end against the test harness. The Academy responds
410 Goneto webhook traffic while inactive.
Keys are 96-character hex by default and may be rotated at any time via /admin/federation. Rotation invalidates the prior key immediately — in-flight requests signed with it are rejected, so coordinate rotation with the running client.
2. Signature scheme
Every signed request carries two headers:
| Header | Value |
|---|---|
X-Timestamp | Unix epoch in integer seconds, as a decimal string. |
X-Signature | sha256=<hex>, where <hex> is the HMAC-SHA256 of the canonical input below. |
The canonical input is the concatenation of the timestamp, a single ., and the exact request body bytes — not a re-serialized version. The signer is responsible for committing to a serialization and not changing it after the HMAC is computed.
canonical = X-Timestamp + "." + raw_body
signature = "sha256=" + hex(HMAC-SHA256(public_key, canonical))
The Academy rejects requests where:
X-SignatureorX-Timestampis missing →401 signature_invalid.- The timestamp is outside ±5 minutes of server time →
401 signature_invalid: timestamp_out_of_range. Adjust your client clock. - The HMAC does not match →
401 signature_invalid: signature_mismatch. Inspect the canonical input on both sides.
Comparison is constant-time. The window is non-configurable so the wire contract stays simple; if you need a longer window, your clock is wrong.
Replay protection
The ±5 minute window blocks bulk replay. Semantic deduplication is enforced separately at the data layer: the attestations table is unique on (user_id, quest_id), so a replay merely refreshes the existing row rather than creating a duplicate. You can therefore treat the /attestations endpoint as idempotent.
3. Identity linking
Linking a Fellow's Academy account to their identity in your game is a one-time handshake:
┌──────────┐ 1. initiate ┌───────────┐
│ Fellow │ ─────────────────► │ Academy │
│ (in web) │ ◄───────────────── │ │ link_token (10 min TTL)
└──────────┘ {link_token} └───────────┘
┌──────────┐ 2. enters token in ┌────────────┐
│ Fellow │ ─────────────────────► │ Your game │
└──────────┘ └────────────┘
┌────────────┐ 3. verify (signed) ┌───────────┐
│ Your game │ ────────────────────►│ Academy │
│ │ ◄────────────────── │ │ 201 Created
└────────────┘ {ok, linked_at} └───────────┘
3a. POST /api/v1/federation/link/initiate
Called by the Academy's web app on behalf of a signed-in Fellow. Returns a single-use link token tied to the Fellow and your app.
Body:
{ "federated_app_slug": "your-app-slug" }
Response (201):
{
"ok": true,
"link_token": "550e8400-e29b-41d4-a716-446655440000",
"federated_app_slug": "your-app-slug",
"federated_app_display_name": "Your App",
"expires_at": "2026-05-22T10:30:00.000Z",
"ttl_seconds": 600
}
The Fellow copies the link_token and pastes it into your game.
3b. POST /api/v1/federation/link/verify
Called by your game once the Fellow has entered the token. Signed with HMAC.
Body:
{
"federated_app_slug": "your-app-slug",
"link_token": "550e8400-e29b-41d4-a716-446655440000",
"external_user_id": "your-internal-user-id-or-pubkey"
}
Response (201):
{
"ok": true,
"federated_app_slug": "your-app-slug",
"external_user_id": "your-internal-user-id-or-pubkey",
"linked_at": "2026-05-22T10:20:30.000Z"
}
The Academy stores the link in app_identity_links. Subsequent attestations addressed to this external_user_id route to this Fellow.
Tokens are single-use and expire after 10 minutes. Re-link if expired. The external_user_id may be anything stable in your game — an internal numeric id, a Nostr pubkey, an Ed25519 verifying key. Choose a value you do not rotate.
Error cases
| HTTP | code | When |
|---|---|---|
| 401 | signature_invalid | Signature, timestamp, or key wrong. |
| 404 | token_invalid | Token unknown, expired, consumed, or scoped to a different app. |
| 409 | already_linked | Either side of the (app, external) or (user, app) pair conflicts. Resolve by unlinking first. |
| 410 | app_inactive | Admin has not activated your app yet. |
4. Posting attestations
POST /api/v1/federation/attestations
Signed with HMAC. Sent whenever a Fellow completes an attestable accomplishment in your game.
Body:
{
"federated_app_slug": "your-app-slug",
"external_user_id": "your-internal-user-id-or-pubkey",
"quest_slug": "<discipline-slug>/<archetype>",
"attested_at": "2026-05-22T10:15:00.000Z",
"evidence_text": "Optional one-paragraph evidence summary.",
"evidence_url": "https://your-game.example/proof/abc123",
"source_metadata": { "game_version": "1.4.2", "match_id": "m-9af3" }
}
Response (201 or 200):
{ "ok": true, "attestation_id": "uuid", "created": true }
Quest slug format
The quest_slug is the discipline's slug joined to the archetype by a forward slash. Examples:
| Quest | quest_slug |
|---|---|
| Pente Grammai — A Crossing of the Five Lines (Adventurer) | pente-grammai/adventurer |
| Rithmomachia — A Harmony Closed (Adventurer) | rithmomachia/adventurer |
| Creatures of Dr. Dee — A Novel Correspondent (Magus) | creatures-of-dr-dee/magus |
The discipline encoded in the slug must equal the discipline you were registered to. Cross-discipline attestations are rejected with 403 quest_outside_app_discipline.
Quest-to-attestation mapping (recommended)
Most federated games will choose a small number of in-game events that fulfil each archetype's quest. Examples — these are conventions, not requirements:
- Magus — a variant designed and shared; an opening composed and saved.
- Adventurer — a complete game played to its conclusion (or to its tier, for ranked games).
- Sage — a teaching artefact authored: a written exposition, a recorded lesson, an analysis.
Send the attestation once, at the moment the criterion is met. The Academy's idempotency on (user_id, quest_id) makes resends safe but unnecessary; treat repeated sends as evidence updates rather than as semantic duplicates.
Idempotency
A repeated attestation for the same Fellow + quest refreshes the existing row:
attested_atandevidence_*are overwritten with the new values.source_metadatais replaced wholesale.statusis preserved (a Fellow's peer endorsements survive a refresh).createdin the response isfalseon a refresh,trueon first claim.
If you ship an evidence update, the response is HTTP 200; first claims are 201.
Error cases
| HTTP | code | Meaning |
|---|---|---|
| 400 | invalid_payload | Body failed schema validation. The message field has details. |
| 400 | invalid_quest_slug | Slug not of the form <discipline>/<archetype>. |
| 401 | signature_invalid | See §2. |
| 403 | quest_outside_app_discipline | Slug names a quest in another discipline. |
| 404 | link_not_found | No Fellow is linked to this external_user_id yet — finish §3 first. |
| 404 | unknown_quest | Discipline or archetype does not match any quest. |
| 404 | unknown_app | No registered app with that slug. |
| 410 | app_inactive | Admin has not activated your app yet. |
The response envelope is stable: { ok: false, code: "<string>", message: "<human>" }. Branch on code; the message text is allowed to drift.
5. Retry and back-off
The Academy treats POSTs as idempotent under the rules above. Recommended client retry policy:
- 5xx and network errors — exponential back-off, starting at 1 s, doubling, capped at 30 s. Give up after ~5 minutes; surface the failure to the Fellow.
- 401
signature_invalid— do not retry blindly. Verify your local clock, then your signing code. - 404
link_not_found— the Fellow has not completed the handshake. Prompt them to link first; do not retry without a state change. - 410
app_inactive— pause and surface the deactivation to the operator. Do not loop. - 409
already_linked— only relevant to link/verify. Surface to the Fellow and ask them to unlink the conflicting side from/profile.
Avoid retrying on a wall-clock that drifts past the 5-minute window — re-sign with a fresh timestamp.
6. Reference client and test harness
The reference TypeScript client at packages/federation-client-reference/ is the executable form of this document. It ships:
src/client.ts— a single-file client withsendAttestationandverifyLinkTokenfunctions, ~200 lines.examples/send-attestation.ts— calls a running Academy with a sample attestation.examples/link-handshake.ts— completes the link handshake against a running Academy.test-harness/mock-server.ts— a standalone Node server that mimics the Academy's federation endpoints, verifying signatures the same way and reporting which step failed. Useful when your real Academy instance is not handy.test-harness/example-client.ts— drives the mock server end-to-end.
To verify a brand-new client implementation, point it at the mock server with the harness's seeded key; you should see linked and accepted events in the harness logs. Then swap the URL and key for a real Academy instance.
7. Stability and versioning
The endpoint paths are versioned: /api/v1/federation/.... The protocol shipped in Phase 4 will be hard to change — once external clients depend on it, the cost of breakage is borne by every game. Expect:
- No breaking changes to v1. Additions to request and response bodies are allowed, provided clients ignore unknown fields. The Academy will continue to honour v1 indefinitely.
- A v2 only when a change cannot be made additively (e.g., asymmetric signatures). Both v1 and v2 will run side by side during any transition.
Field-level conventions:
- All timestamps on the wire are ISO 8601 in UTC.
- All identifiers in URL paths are slugs; ids in payloads are UUIDs.
- Unknown fields in requests are ignored (clients may extend); unknown fields in responses must be ignored by clients.
If you discover a case the protocol does not cover, open an issue or post in Castalia rather than working around it — the protocol's stability depends on the path of least surprise being the documented one.