# WriteStamp Enterprise API — LLM / Agent Integration Guide > This file is written for AI coding agents. It is a complete, self-contained > specification of the WriteStamp (ProofWrite) Enterprise API: a read-only REST > API plus outbound webhooks. An agent should be able to build a correct, working > integration from THIS FILE ALONE. Field names, types, status codes, payload > shapes and the webhook signature scheme below are exact — do not invent fields. ## What this API is for WriteStamp runs writing competitions and "Write & Certify" sessions, and proves human authorship via keystroke monitoring + Ed25519-signed certificates. This API lets an organisation: - **Read** its competitions, submissions (incl. the essay text + authorship scores), published results, and Proof-of-Human certificates. - **Receive webhooks** when events happen (a submission, results published, a new applicant, a certificate issued/revoked), so you don't poll. - **Verify** certificates cryptographically. It is **read-only**. There are no create/update/delete endpoints. The only writes a client makes are managing its own keys/webhooks **in the dashboard UI** (not via this API). ## Base URL & host ``` BASE_URL = https://{WRITESTAMP_HOST}/api/v1 ``` `{WRITESTAMP_HOST}` is the platform's deployment host (the same origin that serves this `/llms.txt`). All paths below are relative to `BASE_URL`. ## Conventions (apply to every request) - **Transport:** HTTPS, JSON request/response. `Content-Type: application/json`. - **Timestamps:** ISO-8601 UTC strings (e.g. `"2026-06-08T14:05:00.000Z"`), or `null`. - **IDs:** UUID strings. - **Auth:** required on every endpoint — an API key as a bearer token (see below). - **Success envelope:** list endpoints return `{ "data": [ ... ], "hasMore": boolean }`; single-resource endpoints return `{ "data": { ... } }`. (Two endpoints differ slightly — `/results` and `/verify` — documented inline.) - **Pagination:** list endpoints accept `?limit=` (default `50`, min `1`, max `200`) and `?offset=` (default `0`). Keep increasing `offset` until `hasMore` is `false`. --- # 1. Authentication Send the API key as a bearer token on **every** request: ``` Authorization: Bearer pwk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` - Keys look like `pwk_live_` + 32 url-safe chars. They are created by an org admin in the dashboard (**Organiser → Developers**) and shown **once**. - A key is scoped to one organisation and can read only that org's data. - The org must have the **`api_access`** feature enabled; otherwise every call is `403`. (This is an Enterprise entitlement, enabled per organisation.) - **Rate limit:** 120 requests/minute per key. On exceed → `429` with a `Retry-After: 60` response header. Back off and retry after the window. - Store keys server-side only. Never expose them in a browser/mobile client. ## Error envelope (all 4xx) ```json { "error": { "code": "string", "message": "string" } } ``` | HTTP | code | When | |------|------|------| | 401 | `unauthorized` | Missing/malformed/invalid/revoked key | | 403 | `forbidden` | `api_access` not enabled for this org | | 404 | `not_found` | Resource doesn't exist OR isn't in your org | | 429 | `rate_limited` | Rate limit exceeded (see `Retry-After`) | **Important:** a resource that belongs to a *different* organisation returns **404**, not 403 — the API never confirms the existence of data you can't access. Treat 404 as "not yours / not found", indistinguishable on purpose. --- # 2. Read API — endpoints All are `GET`, key-authenticated, scoped to your org. TypeScript response types are given for precision (`T | null` means the field may be null). ### TypeScript types (responses) ```ts type Competition = { id: string; name: string; slug: string; prompt: string; description: string | null; status: string; // 'draft'|'open'|'closed'|'judging'|'complete'|'archived' minWords: number; maxWords: number | null; opensAt: string | null; closesAt: string | null; resultsPublishedAt: string | null; createdAt: string; } type CompetitionListItem = { // list view (fewer fields) id: string; name: string; slug: string; status: string; opensAt: string | null; closesAt: string | null; resultsPublishedAt: string | null; createdAt: string; } type SubmissionListItem = { id: string; // the submission/session id (use it for /submissions/{id}) writerId: string; finalWordCount: number | null; placement: number | null; // 1=1st, 2=2nd, …; null=unplaced submittedAt: string | null; } type Submission = { id: string; writerId: string; competitionId: string; finalWordCount: number | null; placement: number | null; submittedAt: string | null; finalText: string | null; // the full essay text score: number | null; // overall authorship confidence, 0..1 behaviourScore: number | null; // 0..1 aiPatternScore: number | null; // 0..1 (higher = more AI-like signals) confidenceLabel: string | null; // e.g. 'high' | 'medium' | 'low' flags: unknown[]; // behavioural flags raised during writing } type ResultRow = { sessionId: string; writerId: string; placement: number | null; finalWordCount: number | null } type Certificate = { id: string; sessionId: string; competitionId: string | null; writerId: string; visibility: { name: boolean; text: boolean; replay: boolean }; issuedAt: string; revokedAt: string | null; revoked: boolean; } type Verification = { certId: string; valid: boolean; validSignature: boolean; revoked: boolean; writerId: string; competitionId: string | null; issuedAt: string; signature: string; keyId: string; } type ApiError = { error: { code: string; message: string } } ``` ### `GET /competitions` List your org's competitions (newest first). Paginated. → `{ data: CompetitionListItem[], hasMore: boolean }` ### `GET /competitions/{id}` One competition. → `{ data: Competition }` · `404` if not in your org. ### `GET /competitions/{id}/submissions` Submitted entries for a competition. Paginated, newest first. → `{ data: SubmissionListItem[], hasMore: boolean }` · `404` if competition not in your org. ### `GET /submissions/{id}` One submission — full essay text + authorship score + flags. → `{ data: Submission }` · `404` if not in your org. ### `GET /competitions/{id}/results` Published rankings. **Different envelope:** → `{ resultsPublished: boolean, data: ResultRow[] }` - Before results are published: `{ "resultsPublished": false, "data": [] }` with HTTP `200` (safe to poll). - After: `resultsPublished: true` and `data` ordered by `placement` ascending. ### `GET /certificates/{id}` Certificate metadata. → `{ data: Certificate }` · `404` if its competition isn't in your org. (Note: a certificate whose competition was deleted has `competitionId: null` and is not reachable here — there's no org to scope it to.) ### `GET /verify/{certId}` Re-verify a Proof-of-Human certificate's Ed25519 signature server-side. → `{ data: Verification }` - `valid` = `validSignature && !revoked`. - `validSignature` = the signature checks out against the published public key. - For fully-independent verification, the public keys are also at `GET https://{WRITESTAMP_HOST}/api/public/cert-keys` (no auth) and the portable signed credential at `GET /api/public/certificates/{certId}/credential`. ### Example request/response ```bash curl "https://{WRITESTAMP_HOST}/api/v1/competitions?limit=50&offset=0" \ -H "Authorization: Bearer pwk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" ``` ```json { "data": [ { "id": "c1f0…", "name": "Spring Essay Prize", "slug": "spring-essay-prize", "status": "open", "opensAt": "2026-05-01T00:00:00.000Z", "closesAt": "2026-06-01T00:00:00.000Z", "resultsPublishedAt": null, "createdAt": "2026-04-20T10:00:00.000Z" } ], "hasMore": false } ``` ### Minimal client (TypeScript) ```ts const BASE = "https://{WRITESTAMP_HOST}/api/v1" const KEY = process.env.WRITESTAMP_API_KEY! // "pwk_live_…" async function api(path: string): Promise { const res = await fetch(BASE + path, { headers: { Authorization: `Bearer ${KEY}` } }) if (res.status === 429) { // respect rate limit const wait = Number(res.headers.get("Retry-After") ?? 60) await new Promise(r => setTimeout(r, wait * 1000)) return api(path) } const body = await res.json() if (!res.ok) throw new Error(`${res.status} ${body?.error?.code}: ${body?.error?.message}`) return body as T } // paginate any list endpoint: async function* pages(path: string): AsyncGenerator { let offset = 0 for (;;) { const sep = path.includes("?") ? "&" : "?" const { data, hasMore } = await api<{ data: T[]; hasMore: boolean }>(`${path}${sep}limit=200&offset=${offset}`) for (const x of data) yield x if (!hasMore) return offset += 200 } } ``` --- # ML data export (gated) Raw per-session data for building ML models — beyond the thin verdict in `GET /v1/submissions/{id}`. **Gate:** requires the **`bulk_export`** entitlement *on top of* `api_access`. Without it → **403** `forbidden`. **Rate limit:** **30 req/min/key** (a separate, tighter bucket than the 120/min read API). **Org-scope:** the session must belong to a competition in your org, else **404** (same indistinguishable-not-found rule). ### `GET /v1/submissions/{id}/score` The full authorship-score breakdown (a superset of the thin verdict). ```ts type ScoreDetail = { score: number; behaviourScore: number | null; aiPatternScore: number | null; sourceSimilarity: number | null; // 0..1 overlap with known sources (null if not run) qualityScore: number | null; // 0..1 writing-quality signal revisionDepth: string | null; // e.g. 'shallow' | 'moderate' | 'deep' confidenceLabel: string | null; // 'high' | 'medium' | 'low' flags: unknown[]; modelVersion: string; // e.g. 'v1' — pin/branch your features on this computedAt: string; // ISO-8601 } ``` → `{ data: ScoreDetail }` · **404** if the session was never scored. ### `GET /v1/submissions/{id}/events` The raw behavioural event stream from `ledger_events`, **NDJSON** (`application/x-ndjson`), **one event per line, ordered by `sequenceNumber`**, streamed (handles 50k+ events). Parse line-by-line; the stream just ends when done (a dropped connection mid-stream = an error, not a complete export — re-pull). ```ts type LedgerEvent = { type: string; // see catalogue below sequenceNumber: number; // monotonic per session — your sort/dedup key clientTimestamp: number; // unix ms (client clock) payload: object; // the full original SDK event (see fidelity note) } ``` **`type` catalogue:** `keydown`, `paste` / `paste_detected` / `paste:blocked`, `focus` / `blur` / `focus_lost` / `focus:returned`, `tab_hidden`, `checkpoint:prompted` / `checkpoint:answered`, formatting actions, draft snapshots. Treat it as an open set — tolerate unknown `type`s. **⚠ Fidelity (critical for ML):** `payload` is the raw SDK event, but **high-fidelity content (the typed character, caret position, draft text) is present ONLY for sessions where full-replay capture was enabled**. For non-capture sessions it's **stripped server-side** to structural/timing fields (key counts, timings, paste sizes/hashes — no literal characters). So keystroke-level models only work on capture-enabled sessions; behavioural/timing models work on all. ### `GET /v1/submissions/{id}/capture` The archived **full-replay capture**, **NDJSON**, **proxy-streamed from object storage** — **one event `payload` per line** (the same full-fidelity payloads as `/events` for that session, pre-archived as a single blob for fast bulk download). → **404** when the session has no capture (writer didn't opt in / capture wasn't enabled for that competition). This 404 is the natural consent boundary — capture only exists for opted-in sessions. **`/events` vs `/capture`:** `/events` is the live DB query (always present; fidelity depends on capture-on); `/capture` is the pre-archived full-fidelity blob (capture-on sessions only, faster for bulk pulls). For an ML training pull on opted-in sessions, prefer `/capture`. ```python # parse either NDJSON stream line-by-line import json, requests with requests.get(f"{BASE}/submissions/{sid}/events", headers={"Authorization": f"Bearer {KEY}"}, stream=True) as r: for line in r.iter_lines(): if line: ev = json.loads(line) # {type, sequenceNumber, clientTimestamp, payload} ``` --- # 3. Webhooks Register an HTTPS endpoint in the dashboard (**Organiser → Webhooks**); we POST a signed JSON event to it when something happens. You verify the signature, ACK with `2xx`, and (if needed) fetch details via the read API using the ids in the payload. ## Event catalogue | `type` | Fires when | `data` shape | |--------|-----------|--------------| | `session.submitted` | a writer submits an entry | `{ sessionId, competitionId, writerId, submittedAt, finalWordCount }` | | `results.published` | an organiser publishes results | `{ competitionId, slug, publishedAt }` | | `application.received` | someone applies to a competition | `{ applicationId, competitionId, applicantEmail, applicantName }` | | `certificate.issued` | a certificate is issued | `{ certId, sessionId, competitionId, writerId, issuedAt }` | | `certificate.revoked` | a certificate is revoked | `{ certId, competitionId, revokedAt }` | A `ping` event (`type: "ping"`, `data: { message }`) is sent when you click "Send test" — use it to validate connectivity + signature handling. ## Payload envelope (every delivery) ```jsonc { "id": "550e8400-e29b-41d4-a716-446655440000", // UUID — ALSO the webhook-id header; use it for idempotency "type": "session.submitted", "createdAt": "2026-06-08T14:05:00.000Z", "data": { /* per-type, see table above */ } } ``` ```ts type WebhookEvent = | { id: string; type: "session.submitted"; createdAt: string; data: { sessionId: string; competitionId: string; writerId: string; submittedAt: string; finalWordCount: number | null } } | { id: string; type: "results.published"; createdAt: string; data: { competitionId: string; slug: string; publishedAt: string } } | { id: string; type: "application.received"; createdAt: string; data: { applicationId: string; competitionId: string; applicantEmail: string; applicantName: string } } | { id: string; type: "certificate.issued"; createdAt: string; data: { certId: string; sessionId: string; competitionId: string; writerId: string; issuedAt: string } } | { id: string; type: "certificate.revoked"; createdAt: string; data: { certId: string; competitionId: string; revokedAt: string } } | { id: string; type: "ping"; createdAt: string; data: { message: string } } ``` Payloads are intentionally small (ids + key fields). To get the full essay text, authorship score, or certificate, call the **read API** with the ids from `data`. ## Request headers (Standard Webhooks) ``` Content-Type: application/json webhook-id: webhook-timestamp: webhook-signature: v1, ``` ## Signature verification — DO THIS ON EVERY DELIVERY This follows the [Standard Webhooks](https://www.standardwebhooks.com/) spec. - The **signing secret** is shown once at registration and looks like `whsec_`. - The signature is `base64( HMAC_SHA256(key, "{webhook-id}.{webhook-timestamp}.{rawBody}") )`, prefixed `v1,`. The HMAC key is the base64-decode of the secret's part after `whsec_`. - **Verify against the RAW request body bytes** — do NOT JSON-parse-then-reserialize first. - Reject if `|now − webhook-timestamp| > 5 minutes` (replay protection — the library does this). ### Easiest: the official library (your secret passes in directly) **Node.js (Express)** ```js import express from "express" import { Webhook } from "standardwebhooks" // npm i standardwebhooks const wh = new Webhook(process.env.WRITESTAMP_WEBHOOK_SECRET) // "whsec_…", pass as-is const app = express() app.post("/webhooks/writestamp", express.raw({ type: "application/json" }), // RAW body required (req, res) => { let event try { event = wh.verify(req.body, { // throws on bad signature "webhook-id": req.header("webhook-id"), "webhook-timestamp": req.header("webhook-timestamp"), "webhook-signature": req.header("webhook-signature"), }) } catch { return res.sendStatus(400) // bad signature → we will retry } res.sendStatus(200) // ACK FAST (within ~10s) enqueue(event) // then do work async + dedupe on event.id }) ``` **Next.js App Router (route handler)** ```ts import { Webhook } from "standardwebhooks" const wh = new Webhook(process.env.WRITESTAMP_WEBHOOK_SECRET!) export const dynamic = "force-dynamic" export async function POST(req: Request) { const raw = await req.text() // RAW body try { const event = wh.verify(raw, { "webhook-id": req.headers.get("webhook-id")!, "webhook-timestamp": req.headers.get("webhook-timestamp")!, "webhook-signature": req.headers.get("webhook-signature")!, }) await handle(event) // idempotent on event.id return new Response("ok", { status: 200 }) } catch { return new Response("bad signature", { status: 400 }) } } ``` **Python (FastAPI)** ```python from standardwebhooks import Webhook # pip install standardwebhooks wh = Webhook(os.environ["WRITESTAMP_WEBHOOK_SECRET"]) # "whsec_…" @app.post("/webhooks/writestamp") async def hook(request: Request): body = await request.body() # raw bytes try: event = wh.verify(body, { "webhook-id": request.headers["webhook-id"], "webhook-timestamp": request.headers["webhook-timestamp"], "webhook-signature": request.headers["webhook-signature"], }) except Exception: return Response(status_code=400) await handle(event) return Response(status_code=200) ``` ### Manual verification (no library) ``` parts = webhook-signature.split(" ") # may contain several "v1," signed = webhook-id + "." + webhook-timestamp + "." + rawBody key = base64_decode(secret.removeprefix("whsec_")) expected = base64( HMAC_SHA256(key, signed) ) ok = any( constant_time_eq(expected, p.split(",")[1]) for p in parts where p starts with "v1," ) # also require: abs(now_unix - int(webhook-timestamp)) <= 300 ``` ## Delivery semantics — build for these - **Respond `2xx` within ~10 seconds.** Do real work asynchronously. A non-2xx, timeout, or connection error counts as a failure. - **Retries (at-least-once):** failures retry with backoff **1m → 5m → 30m → 2h → 6h**, then the delivery is marked `failed`. You can redeliver from the dashboard. - **Idempotency is YOUR responsibility:** the same `webhook-id` may arrive more than once. Persist processed `webhook-id`s and skip duplicates. Make handling idempotent. - **Ordering is not guaranteed.** Order by `createdAt` if you need to. - **Auto-disable:** an endpoint that fails ~15 times in a row is disabled; re-enable it in the dashboard once healthy. - **HTTPS only**; private/loopback/internal hosts are rejected at registration. --- # 4. Recipes (common agent tasks) **Sync new submissions into your system (event-driven):** 1. Register a webhook for `session.submitted`. 2. On delivery: verify signature → ACK 200 → for `event.data.sessionId`, call `GET /submissions/{sessionId}` to pull `finalText`, `score`, `flags`, etc. **Pull a competition's full leaderboard once results are out:** 1. Register a webhook for `results.published` (or poll `GET /competitions/{id}/results` until `resultsPublished` is true). 2. Read `GET /competitions/{id}/results` for the ranked `ResultRow[]`, then `GET /submissions/{sessionId}` per row for details. **Validate an authorship certificate a user gives you:** - `GET /verify/{certId}` → trust `data.valid`. For zero-trust, fetch the public keys at `/api/public/cert-keys` and verify `data.signature` over the canonical payload yourself (Ed25519). **Backfill all submissions for an org:** - For each competition from `GET /competitions` (paginate), iterate `GET /competitions/{id}/submissions` (paginate), then `GET /submissions/{id}`. Respect the 120 req/min limit (the sample client above auto-retries on 429). --- # 5. Hard rules / gotchas (do not get these wrong) 1. **Every request needs `Authorization: Bearer pwk_live_…`.** No cookies, no query-param keys. 2. **404 = not-found OR not-your-org.** Don't treat 404 as "exists but forbidden". 3. **Webhook verification uses the RAW body.** Parsing first and re-serializing breaks the signature. 4. **The webhook secret is `whsec_…`** — pass it to `new Webhook(secret)` unchanged; don't base64-encode it yourself. 5. **Dedupe on `webhook-id`.** Deliveries are at-least-once. 6. **ACK webhooks fast (2xx).** Anything else triggers retries and eventually auto-disables your endpoint. 7. **Read-only API.** There is no endpoint to create/modify competitions, submissions, or certificates. 8. **Pagination caps at `limit=200`.** Loop with `offset` until `hasMore` is false. 9. **Rate limit 120/min/key** → handle `429` + `Retry-After`. --- *This document describes the live `/api/v1` endpoints and the Standard-Webhooks delivery used by WriteStamp. It is intended to be read by automated agents; field names and shapes are authoritative. A human-readable version is available from the in-app Developers and Webhooks pages.*