caputchin
All docs
View raw .md

API endpoints

Three HTTP endpoints. All follow the inverted envelope — Cap's wire format is the top level, Caputchin data rides under platform. The platform server is a dumb proxy for Cap parts and never parses or transforms Cap's shapes. See ADR-0003 for why.

The browser-side endpoints (/game/start, /game/complete) are reached via Cap's CAP_CUSTOM_FETCH hook, which the widget uses to inject the platform field into Cap's outgoing request bodies. The customer-backend endpoint (/siteverify) is reCAPTCHA-shape-compatible at the top level.

Inverted envelope

{
  ...cap_native_shape,
  "platform": { ...caputchin_extensions }
}

Why this shape: Cap's protocol can change without affecting platform code; the server forwards bytes and only reads the platform field. See cap-integration for the full proxy posture.

POST /game/start (browser → platform)

Fires when the user starts the game, not on widget mount. The widget calls Cap's solve() lazily so users who never play don't generate challenges.

Request:

{
  "...cap_challenge_request fields": "...",
  "platform": { "sitekey": "cpt_pub_...", "gameId": "bubble-pop" }
}

sitekey is the customer's public site key (visible in the widget mount attribute). gameId is optional — absent for the invisible-default mode, present otherwise (marketplace ID or customer-supplied label).

Response:

{
  "...cap_challenge_response fields": "...",
  "platform": { "sessionId": "...", "gameNonce": "..." }
}

The gameNonce is server-issued, signed with the platform secret, and carries { sessionId, gameId, issuedAt, expiresAt }. See wrapped token.

POST /game/complete (browser → platform)

Fires every time the game's onComplete callback fires. Games may legitimately fire onComplete multiple times (replay, multi-round) — see game-sdk. The server dedups Cap's redeem per session: only the first call proxies redeem and constructs the wrapped token; subsequent calls only record score for scoreboard purposes.

The endpoint never blocks on user input. Score persistence is decoupled from nickname collection — the row is recorded immediately, and the optional scoreboard handle is set later via the separate /game/nickname endpoint. See ADR-0014.

Request:

{
  "...cap_redeem_request fields": "...",
  "platform": { "sessionId": "...", "score": 0.847, "durationMs": 4200 }
}

score and durationMs are both nullable. The invisible default widget mode (no game configured) sends score: null; manual mode customers may also omit either field. Either way they are client-claimed metadata, not security signals.

Response (first call):

{
  "...cap_redeem_response fields": "...",
  "platform": { "wrappedToken": "..." }
}

Response (subsequent calls):

{ "platform": { "recorded": true } }

The wrapped token is locked at first completion. The widget uses Cap's CAP_CUSTOM_FETCH to hold the redeem call until the first onComplete fires.

POST /game/nickname (browser → platform)

Sets or updates the 3-letter scoreboard handle for the session. Non-blocking from the verification flow: the wrapped token is already returned by the time this fires, and the customer's /siteverify does not depend on it. A session that never calls this endpoint records its entries with a NULL nickname (rendered as --- on the dashboard scoreboard view). See ADR-0014.

Request:

{
  "platform": { "wrappedToken": "...", "nickname": "AAA" }
}

Response:

{ "platform": { "set": true } }
Field Constraint
wrappedToken Authenticates the call; must be unexpired
nickname Server-validates ^[A-Z]{3}$ and a small profanity blocklist; reject → 400

The call is idempotent and last-write-wins; the user may resubmit until the wrapped token expires. The update applies to all scoreboard entries for the session — one nickname per session, not per round. Rate-limited per session to bound abuse without identifying.

GET /games/:id/resolve (browser → platform)

Returns the script URL and SRI integrity hash for a marketplace game ID so the widget can build its sandboxed iframe at mount. Customer-hosted games skip this endpoint — the customer supplies the URL via the widget's game-src attribute. See widget, marketplace, and ADR-0015.

Request: none (URL parameter only).

Response:

{
  "url": "https://cdn.jsdelivr.net/npm/@cooperative-games/bubble-pop@1.2.0/dist/bubble-pop.js",
  "integrity": "sha384-..."
}

The hash is computed by the marketplace indexer at index time and pinned per version. The widget passes both into the iframe's <script src integrity>; jsDelivr tampering between index time and load time fails closed.

404 when the ID is not in the marketplace index. Customer-hosted games never resolve here by design.

POST /siteverify (customer backend → platform)

Top level is reCAPTCHA-shape-compatible (Cap's siteverify shape). Platform extensions ride flat under platform.

Request:

{ "secret": "cpt_sec_...", "response": "<wrappedToken>" }

Response:

{
  "...cap_siteverify_response fields": "...",
  "platform": {
    "game_id": "bubble-pop",
    "score": 0.847,
    "duration_ms": 4200
  }
}

The platform server verifies the wrapped token's HMAC, checks replay, calls Cap's /siteverify server-to-server, and merges responses. See verify server-side guide.

The platform.score, platform.duration_ms, and platform.game_id are client-claimed metadata, not security signals — see principles and ADR-0002.

Wrapped token

Built server-side at the first /game/complete call, returned to the client, dropped into the form by the widget.

Field Source Purpose
powToken Opaque, from Cap's redeem response The actual humanity proof
gameNonce Server-issued HMAC Replay-tracking ID; encodes { sessionId, gameId, issuedAt, expiresAt }
score Client-claimed, nullable Scoreboard metadata. null for invisible-default and manual-mode flows that did not supply a score.
durationMs Client-claimed, nullable Scoreboard metadata

No v field. Versioning rides on token expiry plus the bundled-widget / server deploy cycle (they ship together).

No separate tokenId. gameNonce is the unique identifier for replay tracking.

No top-level gameId. It's encoded inside the signed gameNonce, so it cannot be spoofed by mutating the token.

Wire encoding. wrappedToken is base64url-JSON of the four fields above. gameNonce is base64url(payload).base64url(HMAC-SHA256(payload, PLATFORM_HMAC_SECRET)). Only the gameNonce is signed — score and durationMs are tamperable in transit and explicitly not security signals per ADR-0002. See ADR-0018 for the encoding rationale.

OpenAPI spec

The platform serves a machine-readable OpenAPI spec at a stable URL (e.g. https://api.caputchin.com/openapi.json) covering both the runtime /siteverify endpoint and the management API (site keys, secrets, hosted-verification config, tokens, etc.). Customers wanting a typed client in any language can run their preferred generator against it (openapi-typescript, openapi-generator, etc.) — Caputchin does not ship per-language SDKs. See ADR-0011 for the rationale and snippets for copy-paste examples that don't need a generator.

OpenAPI is one of four management modalities; MCP and Terraform are also first-class — see ADR-0012.