Skip to main content

Docs · Federation

The federation protocol

A reference for game developers integrating with the Academy. The protocol is small, signed, and stable. The reference TypeScript client at packages/federation-client-reference/ is the executable copy of record.

← Your linked games

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_slug you 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 Gone to 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:

HeaderValue
X-TimestampUnix epoch in integer seconds, as a decimal string.
X-Signaturesha256=<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-Signature or X-Timestamp is 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

HTTPcodeWhen
401signature_invalidSignature, timestamp, or key wrong.
404token_invalidToken unknown, expired, consumed, or scoped to a different app.
409already_linkedEither side of the (app, external) or (user, app) pair conflicts. Resolve by unlinking first.
410app_inactiveAdmin 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:

Questquest_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_at and evidence_* are overwritten with the new values.
  • source_metadata is replaced wholesale.
  • status is preserved (a Fellow's peer endorsements survive a refresh).
  • created in the response is false on a refresh, true on first claim.

If you ship an evidence update, the response is HTTP 200; first claims are 201.

Error cases

HTTPcodeMeaning
400invalid_payloadBody failed schema validation. The message field has details.
400invalid_quest_slugSlug not of the form <discipline>/<archetype>.
401signature_invalidSee §2.
403quest_outside_app_disciplineSlug names a quest in another discipline.
404link_not_foundNo Fellow is linked to this external_user_id yet — finish §3 first.
404unknown_questDiscipline or archetype does not match any quest.
404unknown_appNo registered app with that slug.
410app_inactiveAdmin 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 with sendAttestation and verifyLinkToken functions, ~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.