# Gradally typed-answer integration — implemented sandbox contract

Version: 20260918-1. Audience: the platform backend team. Seven endpoints and the outgoing webhook event are described in `openapi.json`. Each platform has its own Gradally origin on gradally.io; this package does not create an exam or authorize student publication. The developer page is published at https://gradally.io/docs/ and questions go to contact@gradally.io. The result schema is `sanad-partner-result-1`, the manifest schema is `sanad-partner-stream-1` and the webhook event schema is `sanad-result-webhook-1`.

Three integration options exist; `index.html` compares them. Option A (zero work): Gradally's connector reads your answers table or view and writes one `gradally_results` table in your database, see "Zero-work connector" below. Option B: your server posts each answer and receives a signed webhook per changed result, see "Outgoing webhook". Option C: this full pull API. The identifiers, states and rules below apply to all three.

## Connection and ownership

Your origin is `https://<your-code>.gradally.io`, where the platform code is assigned by Gradally: one process, one database and one set of credentials per platform, on Gradally's production host behind TLS. There is no shared sandbox host; your first stream is a test exam on that origin, and production exams are new streams on the same origin with their own secrets. For every stream Gradally generates a random secret of 32 to 200 characters from `A-Za-z0-9_-`, keeps only its hash and delivers the platform code, the stream UUID and the secret once, over a channel separate from this kit (a password-manager share or an encrypted message). A stream belongs to one prepared exam. A different exam requires a separate stream and credential. A request header cannot choose another institution.

Use `Authorization: Bearer <stream secret>` and JSON UTF-8. No cookies, browser CORS integration or login are required for this server-to-server contract. Keep TLS verification enabled. Do not follow redirects with an Authorization header; correct the origin/path instead. Paths have trailing slashes. Returned `result_url` values are relative paths: resolve only on the configured origin and expected stream path, never on a caller-supplied host.

Pause/revoke/rotate are operator actions outside this public API. Rotating the bearer revokes the previous bearer immediately; there is no overlap window. Stream owner deactivation or loss of permission also invalidates access. Store the secret in the platform's secret store. Do not log Authorization or delivery tokens, and do not share a Postman export after filling secrets or real responses.

## Endpoints

All paths start with `/api/v1/streams/{stream_id}`.

| Method and suffix | Purpose |
|---|---|
| `GET /` | Manifest: current question IDs/fingerprints and maximum marks |
| `POST /answers/` | Store exactly one typed answer version; not a bulk array |
| `GET /results/{receipt_id}/` | Current result for one immutable receipt |
| `GET /submissions/?submission_id=…&submission_version=…` | Recover the receipt of a submission ID (latest version, or one exact version) with its current result |
| `GET /results/?cursor=…&limit=50` | Initial/incremental scan of receipts in arrival order |
| `GET /changes/?cursor=…&limit=50` | Committed invalidation feed; fetch affected current results |
| `POST /acknowledgements/` | Acknowledge durable storage of an exact applied shadow result |

There is no API for uploading a quiz/key in this release and no self-service subscription endpoint; the outgoing webhook is configured per stream by the Gradally operator from the receiver details you supply. Existing teacher preparation/onboarding is a separate flow. Do not scrape teacher pages to discover question mappings. Agree the platform question ID ↔ Gradally question ID mapping, and check it using the manifest; IDs alone do not prove semantic equivalence of two questions.

## Answer identity and exact retries

The required request fields are shown in `examples.json`. `submission_id` identifies one answer slot in one attempt, not a whole quiz and not a student name. Multiple questions in one attempt need distinct submission IDs. Map this ID back to the platform's student/attempt/question privately on the platform.

Versions must be consecutive integer literals: first `1`, then `2`, etc., maximum 2147483647. Do not send booleans or `1.0`. Both IDs have 1–160 characters, no outer whitespace or ASCII control characters. `question_fingerprint` is the exact lowercase 64-character digest returned by the current manifest. `text` may be empty, is limited to 20,000 Unicode characters and must contain no NUL or invalid surrogate code points. `has_attachment` must be false. No OCR/files/handwriting or extra identity fields are accepted. Total UTF-8 JSON body: at most 98,304 bytes; no compression contract is offered.

Unknown/duplicate JSON keys and nonfinite numbers are rejected. JSON Schema's mathematical `integer` type cannot express the implementation's additional integer-literal distinction; follow the wire examples. Preserve text, whitespace, fingerprint and other values in a durable outbox record before attempting delivery. JSON property order may differ; values must not. Do not normalize an answer between retries.

An optional `Idempotency-Key` header (1–160 characters from `A-Za-z0-9._~-`, for example the outbox row key) binds that key to the receipt: the same key with the same body returns the same receipt, and the same key with a different body is a 409 conflict. The body alone already makes an exact replay safe, so the header is a convenience, not a requirement. If a receipt was lost after a timeout, either replay the exact payload or read `GET /submissions/?submission_id=…` to recover it.

- First accepted version: HTTP 202, `created=true`, an immutable receipt ID and relative result URL. Grading is asynchronous.
- Exact replay: HTTP 200, `created=false`, the same receipt. No duplicate answer is created. An intake retry still consumes request allowance.
- Same ID/version with changed content: HTTP 409. Do not invent a new ID to hide the conflict.
- New version: a new receipt; the previous receipt becomes `superseded`. The same submission ID cannot switch to another question.
- A skipped version or current key mismatch: HTTP 409. Reconcile the outbox and manifest; do not blindly increment version or rewrite the frozen request.

For a timeout after sending, the response may have been lost after storage. Retry the exact outbox payload and record the returned receipt. For a delivery-token timeout, retry the same acknowledgement as described below.

## Outbox and result synchronization

Recommended first implementation:

1. In the platform transaction that accepts a submission, persist an outbox row containing the immutable payload and platform mapping. A background sender calls Gradally. Mark that row accepted only after storing a valid receipt. User-facing submission should not wait for grading.
2. Start the receipt scan with an empty receipt cursor. Follow pages of 1–100 results until `has_more=false`. Save the cursor even on the final nonempty page. This cursor discovers receipts only; it does not rediscover earlier receipts whose grades later change.
3. Independently consume `/changes/`, starting with an empty **change** cursor. Do not reuse the receipt cursor. A suggested starting poll interval is 10 seconds while active, backing off toward 60 seconds while idle. This is a client tuning suggestion, not an SLA. Drain available pages with bounded concurrency; avoid polling every pending answer individually.
4. For each change, persist a durable fetch task keyed by `(stream_id, sequence)` or process the current result. Commit the next change cursor only in the same transaction as durable storage/enqueueing of **all** page items. A worker failure must not advance past lost work. Empty pages can still return a cursor: retain the returned value.
5. Read the current receipt result. Serialise updates per answer slot, reject stale in-flight fetches and check the platform's latest submitted version before applying a value. Events can coalesce or repeat: the event is an invalidation notice, not a historic grade snapshot. Persist a result digest even if its state is non-ready. Do not ignore an update merely because `decision_revision` stayed equal; the availability of the result can change without raising that revision.
6. For an applied ready result, persist the exact snapshot and a stable destination record ID in shadow storage, then acknowledge it. For a non-ready result, invalidate any previously usable shadow value and keep its history. Do not acknowledge an unavailable result or convert it into zero.

Maintain separate durable cursors per stream and feed. `next_cursor` is opaque; never decode/rebuild it or derive it from a receipt ID. Only the change sequence orders notifications, and it is not a global sequence across institutions. Downtime may delay notifications. The partner can recover its own lost checkpoint by replaying from empty cursors, with idempotent storage. There is no negotiated retention SLA yet. An invalidated cursor requires a fresh scan; bearer rotation does not by itself rewrite cursor history.

## Interpreting results

Use `state`, `decision_applied`, `score` and `delivery_token` together. Store Decimal values: `fraction`, `earned` and `maximum` are decimal **strings**. `criteria[].score=null` means no available per-criterion allocation, not zero; an empty criteria list is valid. Only ready results carry a score and feedback. Unknown additive response fields should be tolerated; unrecognized future states should be treated as unavailable until supported.

| State | Backend handling |
|---|---|
| `ready` | Available result if the applied flag/score/token also agree; shadow-store and acknowledge |
| `processing` | Persist waiting status; continue consuming changes |
| `unavailable` | No usable result; retain the receipt and synchronize future changes |
| `action_required` | Contact Gradally operations; never create a teacher marking queue automatically |
| `stale` | Prior grading context/decision is invalid; invalidate the shadow result and reconcile |
| `superseded` | This receipt belongs to an earlier answer version; use the latest version's receipt |

`question_fingerprint` records the context at receipt creation. `grading_fingerprint` records the effective grading context and may change without a new student submission. Both belong in stored provenance. `result_digest` is opaque; echo it, never reconstruct it from client serialization. `decision_revision` is local to the answer. ETag is the quoted digest, but conditional GET/304 is not implemented: do not assume it saves validation work. All responses are private/no-store.

**Every response remains `publishable=false`.** An applied score and an accepted acknowledgement do not authorize displaying a final grade to students. Keep the platform's existing grade untouched during this shadow integration. A publication contract and acceptance decision are a later joint step.

## Acknowledgements

Only after durable shadow storage, post exactly the seven fields of `sanad-result-ack-1` from `examples.json`. Use the result's receipt, submission version, decision revision, digest and delivery token unchanged. `destination_record_id` is the platform's stable internal shadow-result record ID, 1–160 characters; not a person identifier. Keep it stable when replaying an acknowledgement for the same digest.

First acknowledgement: HTTP 201. Identical replay: HTTP 200 with the same acknowledgement. Reusing the same digest with a different destination record conflicts. Tokens are valid for 600 seconds from generation; they do not replace stream bearer authentication. A new GET can generate a fresh token for an unchanged result; tokens are opaque and may differ while the result digest stays the same.

If acknowledgement returns 409, the result may have expired, been superseded or lost validity. Mark the stored snapshot unconfirmed, fetch current state, update/invalidate shadow storage, and acknowledge the current applied snapshot if eligible. After an ambiguous network response, replay the exact acknowledgement; if it has since expired, follow the same refresh path. Never publish a previously fetched value while resolving this conflict.

## Errors and retry control

Application JSON errors use `{ "error": "code", "message": "…" }`. Messages are diagnostic text, not stable branching keys. Proxies, TLS/Host rejection and unexpected errors can have non-JSON bodies: inspect HTTP status before parsing. Do not log bodies containing real answers/tokens.

| Status | Action |
|---|---|
| 400 | Fix invalid JSON/schema/cursor/HTTPS; no blind retry |
| 401 | Check assigned credential, rotation and stream access; pause automatic retry until corrected |
| 404 | Confirm origin, stream and receipt scope |
| 405 | Use the documented method and trailing slash |
| 409 | Reconcile answer/version/key/result; do not change identity to bypass it |
| 413 | Enforce body/answer size; never split one answer into separately graded fragments |
| 415 | Send `Content-Type: application/json` |
| 429 | Wait at least `Retry-After` seconds plus jitter; retry the exact payload |
| 503 | If temporarily unavailable, back off; if `capture_disabled`, operations must resolve it |
| Timeout, 5xx, 502/504 from proxy | Bounded backoff and exact replay; retain outbox/inbox work and escalate persistent failure to operations |

Initial client sender suggestion: one worker per stream, up to 5 requests/second, with queued backpressure and bounded retries. Current app intake windows are 600/60 seconds per authenticated stream and 1200/60 seconds per IP within the institution; these are allowances, not a throughput guarantee. The edge may impose a stricter burst limit. Result polling has no advertised capacity SLA. Do not interpret Retry-After=2 on an accepted receipt as a promised completion time.

Manifest reads, current results, submission lookups, receipt/change feeds and acknowledgements share a separate delivery quota: 600 requests per authenticated stream and 1200 per IP per 60 seconds, including replays. They do not consume the answer-intake quota. All seven endpoints may return 429 with `Retry-After`; retry the same request after that delay plus jitter. Size polling pages and acknowledgement concurrency together against the shared delivery allowance. Confirm assigned limits during sandbox onboarding.

## Outgoing webhook (implemented)

When the Gradally operator configures a receiver for your stream, every committed result change is also delivered by POST to that receiver. Requirements for the receiver: HTTPS with a certificate valid for its hostname, a public IPv4 address that you supply and Gradally pins (private ranges, IPv6 and redirects are refused), no query string and no credentials in the URL.

Every event carries `Content-Type: application/json`, `User-Agent: Gradally-Result-Webhook/1`, `X-Gradally-Webhook-Id` (the event id), `X-Gradally-Webhook-Timestamp` (Unix seconds) and `X-Gradally-Webhook-Signature` as `sha256=<hex>`, where the hex is HMAC-SHA256 with the shared signing secret over `ASCII(timestamp) + "." + raw body bytes`. The body is the `sanad-result-webhook-1` event in `examples.json`: `event_id`, `stream_id`, `sequence`, `receipt_id`, `observed_at`, `result_url`, `publishable` (always false) and `result`, the same object as `GET /results/{receipt_id}/` at delivery time.

Receiver rules: reject bodies over 262,144 bytes; verify the signature over the untouched bytes with a constant-time comparison and require the timestamp within 300 seconds of your clock; check `schema_version`, your `stream_id`, the header/body event id and `publishable=false`; insert a durable inbox row keyed by `(stream_id, event_id)` and commit; only then answer 2xx (a duplicate already stored also gets 2xx). Answer 503 when you cannot store. Apply events per answer slot in a worker: the highest version wins, non-ready states invalidate an earlier grade, `null` is never zero.

Delivery is at least once and in sequence per stream; a retry keeps `event_id` and `sequence` but refreshes `observed_at` and `result`. Network failures and 408, 425, 429 and 5xx are retried with backoff for up to eight attempts; any other non-2xx stops that stream's queue until the Gradally operator retries it. A 2xx confirms transport only: it is not an acknowledgement and not permission to publish. The pull feed and current-result reads remain available for recovery. The reference client in the kit implements `verify_webhook` with exactly these checks. These rules follow common [webhook delivery practices](https://docs.github.com/en/webhooks/using-webhooks/best-practices-for-using-webhooks).

## Zero-work connector (Option A)

Gradally runs the connector; the platform supplies access. Source: a read-only database user (PostgreSQL, MySQL or SQL Server, TLS with a verified certificate, optionally your CA file) on the answers table or a view with five columns: a monotonic row id, the attempt reference, your question reference, the answer text and a last-updated time, plus an exam reference column used as the stream's filter. The connector selects only those columns with bound parameters; names, phones and e-mails are never selected. Destination: the `gradally_results` table created from the DDL in `index.html`, with a user that can insert and update it; Gradally never writes to any other table.

Behaviour: it polls every 15 seconds with a durable cursor of (updated time, row id), up to 1,000 rows per pull; a row whose question reference is not in the agreed mapping is skipped and counted; a row whose text equals the latest admitted version is skipped; a changed text becomes the next version through the same admission rules as `POST /answers/`. Results are mirrored from the change feed page by page: one row per (submission reference, version) with `state`, decimal-string scores, `feedback`, `result_digest` and `updated_at`; older versions are marked `superseded` with `NULL` scores; a row can move from `ready` to `stale` or `unavailable` after a key correction, so watch `result_digest` rather than only new rows. The usable grade of an answer slot is the highest version whose state is `ready`. If database access is impossible but an HTTP endpoint lists answers changed since a time, describe it and the connector can read it instead of a table.

The current asynchronous receipt follows [HTTP 202 semantics](https://www.rfc-editor.org/rfc/rfc9110.html#section-15.3.3). The machine-readable schema uses [OpenAPI 3.1.1](https://spec.openapis.org/oas/v3.1.1.html).
