GradallyGrading API · contract 20260917-2 · OpenAPI 1.0.4

Integration guide for platform developers

Connect your platform to Gradally's answer grading

Gradally grades typed student answers against an approved key and hands the grade back to your platform. Pick the connection that costs your team the least. Every option below is implemented in the current build; nothing runs in the student's browser, and nothing here publishes a grade to a student.

ZERO WORKOption AGradally reads your answers, writes your results

You grant a read-only database user on your answers table (or a view) and one results table that Gradally fills. No code on your side.

~1 DAYOption BTwo calls and a signed webhook

Your server posts each answer; Gradally posts each grade back to an HTTPS endpoint you expose, signed and retried.

FULL CONTROLOption CThe complete pull API

Receipts, change feed, cursors and acknowledgements. For platforms that want to own every step.

Verified. Every request and response on this page was recorded from a real run of the current build (31 API exchanges plus a connector run, see Recorded run). Identifiers are synthetic; secrets, tokens and cursors are redacted.

Three ways to connect

A · Zero-work connectorB · Two calls + webhookC · Full API
Who moves the answersGradally reads them from your databaseYour server posts themYour server posts them
Who moves the gradesGradally writes one results table in your databaseGradally posts a signed webhook to your endpointYour server polls the change feed and acknowledges
Your workGrant access, create one table from our DDL, allow our IPOne outbound call per answer, one HTTPS receiverOutbox, poller, cursors, shadow table, acknowledgements
Best whenYou can open a replica or a view to usDatabase access is not possibleYou want full control and audit on your side
RecoveryCursor kept by Gradally; reruns are idempotentChange feed and current-result reads stay availableReplay from empty cursors

Options mix: a platform can start with A and add the webhook later, or use B for intake and C's feed for audit. The identifiers are the same everywhere: your attempt reference plus your question reference name one answer slot, and each content change is a new version.

Option A · Zero-work connector

Gradally runs a connector on its side that polls your answers table every 15 seconds, admits every new or changed answer through the same rules as the API (one version per content change, approved key only), grades it, and mirrors the current result into one dedicated table in your database. Your application reads that table.

What we need from you

  1. Read access to the table or view that holds typed answers: a database user that can only SELECT it. Tell us the database product (PostgreSQL, MySQL or SQL Server), host, port, database name, and send the password over a separate channel.
  2. Five columns in that table or view: a monotonic row id, the attempt (or submission) reference, your question reference, the answer text, and a last-updated time. An exam reference column lets us filter one exam per stream. Gradally selects only these columns; names, phones and e-mails are never read.
  3. One results table created from the DDL below, with a user that can INSERT and UPDATE it. Gradally never touches your business tables.
  4. Network: allow Gradally's public IPv4 address to reach the database over TLS (verified certificate; we accept your CA file), or a replica if you prefer.
  5. The question mapping: which of your question references corresponds to each Gradally question, agreed once per exam.

The results table Gradally writes

PostgreSQL
CREATE TABLE gradally_results (
  submission_ref      VARCHAR(160) NOT NULL,   -- "<attempt>:<your question ref>"
  submission_version  INTEGER      NOT NULL,   -- 1, 2, … per content change
  attempt_ref         VARCHAR(70),
  question_ref        VARCHAR(70),
  state               VARCHAR(20)  NOT NULL,   -- ready | processing | unavailable | …
  fraction            VARCHAR(16),             -- decimal strings; NULL when no grade
  earned              VARCHAR(16),
  maximum             VARCHAR(16),
  feedback            TEXT,
  result_digest       CHAR(64)     NOT NULL,   -- changes whenever the row changes
  updated_at          TIMESTAMPTZ  NOT NULL,
  PRIMARY KEY (submission_ref, submission_version)
);
MySQL 8
CREATE TABLE gradally_results (
  submission_ref      VARCHAR(160) NOT NULL,
  submission_version  INT          NOT NULL,
  attempt_ref         VARCHAR(70),
  question_ref        VARCHAR(70),
  state               VARCHAR(20)  NOT NULL,
  fraction            VARCHAR(16),
  earned              VARCHAR(16),
  maximum             VARCHAR(16),
  feedback            TEXT,
  result_digest       CHAR(64)     NOT NULL,
  updated_at          VARCHAR(40)  NOT NULL,
  PRIMARY KEY (submission_ref, submission_version)
) CHARACTER SET utf8mb4;

How to read it

If database access is not possible but your backend has an HTTP endpoint that lists answers changed since a time, tell us its shape; the connector can read it instead of a table. If neither exists, Option B is the next smallest step.

Recorded: on the sandbox the connector pulled 3 rows of the configured exam (a fourth row of another exam was filtered out), admitted the 2 mapped answers, skipped the one with an unknown question reference, and after the grading pass wrote their result rows as ready. An edited answer was then picked up as version 2 with version 1 marked superseded, and after the next grading pass version 2 was written as ready. Its SQL never named the personal columns present in the table.

Option B · Two calls and a signed webhook

Your server makes one call when a student submits (POST /answers/, one answer per request) and receives one signed POST from Gradally whenever a result changes. Nothing else is required; the feed and result reads in Option C stay available for audit and recovery.

What we need from you

Verify, store, then answer 2xx

Headers on every event
POST /your/receiver HTTP/1.1
Content-Type: application/json
User-Agent: Gradally-Result-Webhook/1
X-Gradally-Webhook-Id: 33333333-3333-4333-8333-333333333333
X-Gradally-Webhook-Timestamp: 1789992000
X-Gradally-Webhook-Signature: sha256=<hex HMAC-SHA256>

signature = HMAC-SHA256(secret, ASCII(timestamp) + "." + raw body bytes)
Body (recorded shape)
{
  "schema_version": "sanad-result-webhook-1",
  "event_id": "33333333-3333-4333-8333-333333333333",
  "stream_id": "11111111-1111-4111-8111-111111111111",
  "sequence": 1,
  "receipt_id": "22222222-2222-4222-8222-222222222222",
  "observed_at": "2026-09-17T12:00:00+00:00",
  "result_url": "/api/v1/streams/11111111-1111-4111-8111-111111111111/results/22222222-2222-4222-8222-222222222222/",
  "publishable": false,
  "result": { …the same Result object as GET /results/{receipt_id}/… }
}
  1. Reject bodies over 262,144 bytes. Compute the HMAC over the untouched bytes with the timestamp header; compare in constant time; require the timestamp within 300 seconds of your clock.
  2. Check schema_version, your stream_id, that event_id equals the header, and publishable: false.
  3. Insert a durable inbox row keyed by (stream_id, event_id) and commit. Only then answer 204 (any 2xx). A duplicate already stored gets the same 2xx. If you cannot store right now, answer 503.
  4. Apply result per answer slot in your 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: retries keep event_id and sequence but refresh observed_at and result. Gradally retries network failures and 408, 425, 429 and 5xx with backoff for up to eight attempts; any other non-2xx stops that stream's queue until the Gradally operator retries it, so keep the endpoint boring: verify, store, 2xx. A 2xx confirms transport only; it is neither an acknowledgement nor permission to publish.

The reference client in the kit (partner_api_client.py, function verify_webhook) implements exactly these checks.

Before production

All three options are implemented and tested, but the service is not yet in production. Still to be done, in order:

  1. A reachable HTTPS sandbox for your team, with a prepared exam, a stream and its credentials; then the mapping of your question references.
  2. For A: the database grants and the results table; for B: your receiver and its public address.
  3. The acceptance run on synthetic answers (checklist), then a shadow period on real answers with grades kept out of students' view.
  4. The publication agreement: until then every result carries publishable: false and your student-facing grade stays as it is.

Option C · Connection & credentials

Base path
https://<assigned-origin>/api/v1/streams/{stream_id}/. All paths end with a slash.
Authentication
Authorization: Bearer <stream secret> on every request. No cookies, no CSRF token, no login.
Content
Requests with a body: Content-Type: application/json, UTF-8. Send Accept: application/json.
Transport
HTTPS with certificate verification. In production a plain-HTTP request is refused with 400 https_required. Do not follow redirects while carrying the Authorization header.
Caching
Every response is Cache-Control: private, no-store. Result reads carry an ETag equal to the quoted result_digest; conditional requests (304) are not implemented.
Secret handling
Keep the secret in your server's secret store. Never put it in a browser, a mobile app, a URL or a log line. Rotation by the Gradally operator revokes the previous secret immediately, with no overlap window.
Scope
The secret authenticates one stream only. A receipt, cursor or token from another stream is rejected (404/400/409); there is no header that switches institutions.

The flow

Platform server and Gradally exchange: submit, receipt, poll changes, fetch result, store shadow, acknowledge Platform serverGradally API POST answers/ — one answer version202 receipt_id + result_urlGET changes/?cursor=… (every 10–60 s) — or a webhook arrivesreceipts whose result changedGET results/{receipt_id}/state, score, delivery_tokenPOST acknowledgements/ (after storing the shadow result)201 ack_id · publishable=false grading runs asynchronouslyplatform stores the exact snapshot
  1. Outbox. In the same transaction that accepts a submission, store an outbox row with the immutable payload (IDs, version, fingerprint, text) and your private mapping to student and attempt.
  2. Send. A sender posts the row to answers/. On 202 or 200 store the receipt_id and mark the row delivered. On a timeout, replay the exact same payload: it returns the same receipt.
  3. Follow. A consumer polls changes/ with its saved cursor (or your webhook receiver queues the event). For each change it fetches the current result, stores it as the shadow grade for that answer version, and commits the cursor in the same transaction.
  4. Acknowledge. When a stored result is ready and applied, post the acknowledgement with the result's digest and delivery token. Replaying the same acknowledgement is safe.
  5. Revisions. If the student edits, send version 2. Version 1's result becomes superseded; only the latest version carries a usable grade.

Endpoints

All seven live under /api/v1/streams/{stream_id}/. The bodies below are the recorded responses of the run.

GET/

Manifest: the questions this stream grades, their current key fingerprints and maximum marks. Read it at setup and whenever a 409 hints the key changed.

Request
GET /api/v1/streams/{stream_id}/
Authorization: Bearer <stream secret>
Accept: application/json
Response 200
{
  "schema_version": "sanad-partner-stream-1",
  "stream_id": "b27ad65b-07cf-443f-b326-35ac6a75e32b",
  "exam_id": "preview-api-2026",
  "questions": [
    {"question_id": "MCQ-01", "question_fingerprint": "8ee906b36a198f7cd37c1fa930cfc46f5a35b8df87a2a8505253d4265e47aa0a", "context_approved": true, "max_mark": "1"},
    {"question_id": "NUM-01", "question_fingerprint": "1d61a0e4b019681a2bb75cfd4fcd119a770c0415eb6a1d72da93aab93a00941c", "context_approved": true, "max_mark": "2"}
  ]
}

question_fingerprint is the 64-character lowercase digest of the current approved key; copy it into every answer for that question. max_mark is a decimal string. A question with context_approved: false is not accepting answers yet.

POST/answers/

Store exactly one version of one typed answer. Grading is asynchronous: the response is a receipt, not a grade.

Request
POST /api/v1/streams/{stream_id}/answers/
Authorization: Bearer <stream secret>
Content-Type: application/json
Idempotency-Key: outbox-row-77        (optional)

{
  "schema_version": "sanad-submission-1",
  "submission_id": "attempt-9001-mcq01",
  "submission_version": 1,
  "question_id": "MCQ-01",
  "question_fingerprint": "8ee906b36a198f7cd37c1fa930cfc46f5a35b8df87a2a8505253d4265e47aa0a",
  "text": "b",
  "has_attachment": false
}
Response 202 (first delivery) · 200 (exact replay)
{
  "schema_version": "sanad-receipt-1",
  "receipt_id": "d0ec959e-0e63-4705-a5ed-444b77a708ea",
  "submission_id": "attempt-9001-mcq01",
  "submission_version": 1,
  "created": true,
  "result_url": "/api/v1/streams/b27ad65b-07cf-443f-b326-35ac6a75e32b/results/d0ec959e-0e63-4705-a5ed-444b77a708ea/"
}

Location: <result_url> · Retry-After: 2

SituationStatusWhat happened
New answer version202created: true, a new receipt. Store it.
Exact replay (same ID, version and body)200created: false, the same receipt. Nothing duplicated.
Same ID and version, different body409The stored answer stays. Do not invent a new ID to get around it.
Version skipped (3 before 2), fingerprint not current, question changed for that ID, unknown question409Reconcile with your outbox and the manifest, then resend.
Unknown field, non-integer version, bad fingerprint format, text too long, attachment flag400Fix the payload; no retry will help.

result_url is a relative path: resolve it only against the assigned origin. The Retry-After: 2 on a receipt is a polite polling hint, not a promise that the grade is ready in two seconds.

GET/results/{receipt_id}/

The current result of one receipt. Read it after a change notification, or directly for a fresh receipt.

Response 200 — before grading
{
  "schema_version": "sanad-partner-result-1",
  "receipt_id": "d0ec959e-0e63-4705-a5ed-444b77a708ea",
  "submission_id": "attempt-9001-mcq01",
  "submission_version": 1,
  "question_id": "MCQ-01",
  "question_fingerprint": "8ee906b36a198f7cd37c1fa930cfc46f5a35b8df87a2a8505253d4265e47aa0a",
  "grading_fingerprint": "8ee906b36a198f7cd37c1fa930cfc46f5a35b8df87a2a8505253d4265e47aa0a",
  "decision_revision": 0,
  "state": "processing",
  "message": "الإجابة قيد المعالجة.",
  "decision_applied": false,
  "publishable": false,
  "score": null,
  "criteria": [],
  "feedback": [],
  "result_digest": "7021bffc8a3769e9d5b4608ad7e3d9cc8b2a3593c670a548bde1aeb65f870471",
  "delivery_token": null
}
Response 200 — graded and applied
{
  "schema_version": "sanad-partner-result-1",
  "receipt_id": "d0ec959e-0e63-4705-a5ed-444b77a708ea",
  "submission_id": "attempt-9001-mcq01",
  "submission_version": 1,
  "question_id": "MCQ-01",
  "question_fingerprint": "8ee906b36a198f7cd37c1fa930cfc46f5a35b8df87a2a8505253d4265e47aa0a",
  "grading_fingerprint": "8ee906b36a198f7cd37c1fa930cfc46f5a35b8df87a2a8505253d4265e47aa0a",
  "decision_revision": 1,
  "state": "ready",
  "message": "النتيجة جاهزة للاستلام.",
  "decision_applied": true,
  "publishable": false,
  "score": {"fraction": "1", "earned": "1", "maximum": "1"},
  "criteria": [],
  "feedback": [],
  "result_digest": "0cd8ffa7efb168943a004f207bfeb8c22c6e1223ef13ad12a1a5fad53062dbfc",
  "delivery_token": "EXAMPLE_ONLY_OPAQUE_TOKEN_VALID_600_SECONDS"
}

ETag: "0cd8ffa7…dbfc" (the quoted result_digest)

Field by field, see Results & states. A result is usable only when all four agree: state: "ready", decision_applied: true, a non-null score and a non-null delivery_token.

GET/submissions/?submission_id=…&submission_version=…

Recover the receipt of a submission ID and read its current result: the latest version by default, or one exact version. Use it when a receipt was lost after a timeout, or to audit an answer slot.

Request
GET /api/v1/streams/{stream_id}/submissions/?submission_id=attempt-9001-mcq01
GET /api/v1/streams/{stream_id}/submissions/?submission_id=attempt-9001-mcq01&submission_version=1
Response 200

Same body as /results/{receipt_id}/ for the matched version, with its ETag. Exactly these two query parameters, once each; a missing submission is 404 not_found; a malformed ID or version is 400.

GET/results/?cursor=…&limit=50

Receipt scan in arrival order, 1–100 per page. It discovers receipts (for a first synchronisation or a rebuild); it does not tell you when a grade changed. That is the change feed's job.

Response 200
{
  "schema_version": "sanad-result-page-1",
  "results": [ { …one Result object per receipt… } ],
  "next_cursor": "EXAMPLE_ONLY_OPAQUE_CURSOR",
  "has_more": false,
  "cursor_semantics": "received_submissions_only; poll individual receipts for changed results"
}
Rules
  • Start with no cursor. Pass back next_cursor until has_more is false; keep the last cursor for incremental scans.
  • next_cursor is null only when the stream has no receipts yet.
  • The cursor is signed and bound to this stream; never build or edit one.
  • Do not use this cursor with changes/: the two feeds have separate cursors.
GET/changes/?cursor=…&limit=50

The change feed: committed notifications that a receipt's current result may have changed (graded, revised, key changed, superseded). Fetch the current result for each; the notification itself carries no grade. The webhook of Option B delivers the same sequence by push.

Response 200 — after the grading pass
{
  "schema_version": "sanad-result-changes-1",
  "changes": [
    {"sequence": 1, "receipt_id": "d0ec959e-0e63-4705-a5ed-444b77a708ea", "result_url": "/api/v1/streams/b27ad65b-07cf-443f-b326-35ac6a75e32b/results/d0ec959e-0e63-4705-a5ed-444b77a708ea/"}
  ],
  "next_cursor": "EXAMPLE_ONLY_OPAQUE_CURSOR",
  "has_more": false,
  "publishable": false,
  "semantics": "coalesced_invalidation; fetch_current_receipt_result; replay_is_safe"
}
Rules
  • sequence increases per stream; it orders notifications and nothing else.
  • Notifications coalesce and may repeat: treat each as "re-read this receipt now".
  • Commit next_cursor only after every item on the page is durably stored or queued. An empty page still returns a cursor: keep it.
  • Poll every ~10 s while answers are flowing, backing off to ~60 s when idle. Drain pages while has_more is true.
POST/acknowledgements/

Tell Gradally that you durably stored one exact applied result. Send it only after your own commit, and only for a result that is ready and applied. Optional in Options A and B.

Request
{
  "schema_version": "sanad-result-ack-1",
  "receipt_id": "d0ec959e-0e63-4705-a5ed-444b77a708ea",
  "submission_version": 1,
  "decision_revision": 1,
  "result_digest": "0cd8ffa7efb168943a004f207bfeb8c22c6e1223ef13ad12a1a5fad53062dbfc",
  "delivery_token": "EXAMPLE_ONLY_OPAQUE_TOKEN_VALID_600_SECONDS",
  "destination_record_id": "lms-shadow-row-41"
}
Response 201 (first) · 200 (identical replay)
{
  "schema_version": "sanad-result-acknowledged-1",
  "ack_id": 1,
  "created": true,
  "result_digest": "0cd8ffa7efb168943a004f207bfeb8c22c6e1223ef13ad12a1a5fad53062dbfc",
  "publishable": false
}

Copy the five result fields unchanged. destination_record_id is your stable internal ID of the stored shadow result (1–160 characters, never a person's identifier); keep it identical when replaying. Conflicts are 409: the token expired (600 s), the result changed or was superseded, the digest does not match, or the same digest was already acknowledged for a different destination record.

Answer rules

FieldTypeRule
schema_versionstringExactly "sanad-submission-1". (Schema identifiers keep this prefix for compatibility; the product is Gradally.)
submission_idstring, 1–160One answer slot in one attempt: your (attempt, question) pair as an opaque ID. Not a student name, e-mail or phone. No surrounding whitespace, no control characters. Distinct questions in one attempt need distinct IDs.
submission_versioninteger literal1 for the first delivery, then 2, 3… consecutively, at most 2147483647. Never 1.0, "1" or true.
question_idstring, 1–160The Gradally question ID from the manifest. An ID cannot move to another question in a later version.
question_fingerprint64 hex charsThe current fingerprint from the manifest, lowercase. A stale fingerprint is a 409: re-read the manifest.
textstring ≤ 20,000 charsThe student's typed answer, unchanged: keep whitespace, do not trim or normalise, send the same bytes on every retry. Empty is allowed. No NUL or unpaired surrogates.
has_attachmentbooleanMust be false. Files, images and handwriting are not accepted by this API.

Results & states

FieldMeaning
receipt_id, submission_id, submission_version, question_idIdentity of the answer version this result belongs to.
question_fingerprintThe key fingerprint at the moment the receipt was created.
grading_fingerprintThe key fingerprint the current grade was produced under. It can change without a new student submission (for example after a key correction); store both.
decision_revisionRevision counter of the grading decision for this answer, starting at 0 before any decision. Echo it in the acknowledgement.
state, messageOne of the six states below; message is a short Arabic diagnostic for humans, not a branching key.
decision_appliedtrue only when a grade is applied inside Gradally.
publishableAlways false in this release. An applied result is a shadow grade, not authorisation to show a final mark to a student.
score{fraction, earned, maximum} as decimal strings (for example "0.5", "1", "2"); parse with a decimal type. null when no usable grade exists. null is never zero.
criteriaPer-criterion allocation for multi-part keys: {id, label, max_score, score, state, blocked_by[], quote, state_label, feedback}; score: null means no allocation for that part. Empty for single-part or objective questions (the run above graded a multiple-choice item).
feedbackHuman-readable lines derived from the criteria; empty when there are none.
result_digestOpaque version identifier of this exact representation. Store it; echo it; never rebuild it from your own serialisation.
delivery_tokenPresent only on an applied ready result; valid for 600 seconds; needed to acknowledge. A fresh GET issues a fresh token for an unchanged result.

States

StateMeaningYour handling
readyA grade is applied (when decision_applied, score and delivery_token agree).Store the exact snapshot as the shadow grade of this version, then acknowledge.
processingReceived, not yet decided.Record "waiting"; keep consuming the change feed. Do not poll one receipt in a tight loop.
unavailableNo usable result for now (for example the answer needs a decision that is not automatic).Keep the receipt; invalidate any earlier stored value for this version; do not convert to zero; keep following changes.
action_requiredGradally operations must act (configuration or an operational block).Alert your operations contact; never open a teacher marking task on your side.
staleThe earlier grade is no longer valid for the current key or decision.Invalidate the stored shadow value and reconcile when a new result arrives.
supersededA newer version of this answer exists.Use the latest version's receipt; this one never regains a score.

Unknown future states must be treated as unavailable; unknown additional fields must be ignored. A result can move from ready back to a non-ready state; the availability of a grade can change even when decision_revision stays equal, so store the digest and compare it, not the revision alone.

Feeds & cursors

Receipt scan · results/

Arrival order of receipts. Use it once to build the initial table and after any loss of your own state. Its cursor is a position in the receipt list.

Change feed · changes/

Committed invalidations in sequence order. Use it continuously. Its cursor is a sequence position. Never mix the two cursors.

Acknowledgements

  1. Fetch the current result. Check that it is ready, decision_applied: true, with a score and a delivery_token.
  2. Store the exact snapshot (all fields, digest included) in your shadow table and commit, together with a stable destination record ID.
  3. Post the acknowledgement built from the result's five fields plus your record ID. Expect 201; an identical replay returns 200.
  4. On 409, mark the stored snapshot unconfirmed, fetch the current result again, update or invalidate the shadow value, and acknowledge the new snapshot if it is applied. After an ambiguous network failure, replay the same acknowledgement first.

Never acknowledge a result you only saw in a tool and did not store, never acknowledge a non-ready result, and never treat an acknowledgement as permission to publish a grade.

Errors & retries

Application errors are JSON: {"error": "<code>", "message": "<diagnostic>"}. The error code is stable; the message (often Arabic, sometimes empty) is for humans. Proxies may answer with non-JSON bodies: branch on the HTTP status first.

StatuserrorWhenDo
400invalid_requestMalformed JSON or field, bad cursor, bad query, page size outside 1–100Fix the request; no blind retry.
400https_requiredPlain HTTP in productionUse HTTPS.
401unauthorizedMissing, wrong, rotated or revoked secret; stream paused; owner deactivatedStop automatic retries; check the credential with the operator. Header WWW-Authenticate: Bearer.
404not_foundUnknown stream, receipt or submission in this scopeCheck origin, stream ID and IDs.
405method_not_allowedWrong method (the Allow header lists the right one)Use the documented method and trailing slash.
409version_conflictContent differs for a stored version; version out of order; stale fingerprint; question moved; unknown question; acknowledgement mismatch or expiryRead the current state (manifest, result), reconcile, then resend. Never change identity to bypass it.
413payload_too_largeBody over 98,304 bytesEnforce the size on your side.
415json_requiredContent-Type is not application/jsonSet the header.
429rate_limitedAllowance exhausted; the request was not processedWait Retry-After seconds plus jitter, then retry the same request.
503capture_disabled · temporarily_unavailableIntake switched off by the operator · transient storage contentionBack off (Retry-After); if capture_disabled persists, contact operations.
timeout / 5xxNetwork or proxy failureBounded backoff, exact replay; keep the outbox row; escalate persistent failure.

Limits

LimitValue
Answer intake allowance600 requests per stream and 1,200 per source IP, per fixed 60-second window, replays included
Delivery allowance (manifest, results, lookups, both feeds, acknowledgements)A separate 600 per stream and 1,200 per IP per 60 s; it does not consume the intake allowance
Request body≤ 98,304 bytes; answer text ≤ 20,000 characters
Page size1–100 (default 50); cursor ≤ 2,048 characters
Delivery tokenValid 600 s from the GET that issued it
Webhook body≤ 262,144 bytes; up to 8 attempts with backoff; one event in flight per stream
ConnectorPolls every 15 s, up to 1,000 rows per pull, up to 20 result pages per turn
Sender concurrencySuggested: one worker per stream, up to 5 requests/s with queued backpressure; these are allowances, not a throughput promise

Implementation blueprint (Options B and C)

A minimal, correct integration is four tables and three workers. With the webhook, the follower is replaced by your receiver plus a small worker that applies queued events.

Tables

  • outbox: your attempt/question keys, submission_id, version, the immutable payload JSON, status (pending / delivered / conflict), attempts, receipt_id.
  • receipts: receipt_id → outbox row, result_url, last seen state and digest.
  • shadow_results: one row per (receipt_id, result_digest) with the exact snapshot, your destination_record_id, and ack status (pending / acknowledged / conflict).
  • cursors: (stream_id, feed) → cursor, updated only in the same transaction as the work it covers.

Workers

  • Sender: takes pending outbox rows in order per submission ID, posts them, stores receipts; on 409 marks the row for reconciliation instead of mutating it.
  • Follower: polls changes/ (or drains the webhook inbox), fetches each current result, upserts shadow_results, commits the cursor with the batch.
  • Acknowledger (optional): for every stored applied result not yet acknowledged, posts the acknowledgement; on 409 re-fetches and repeats the follower step for that receipt.

Invariants to keep

Code samples

Two minimal clients showing the exact wire format. They omit persistence and retries on purpose; wire them to your outbox and worker loop.

Python 3 (standard library only)

# pip-free client: urllib + json. Run on the server, never in a browser.
import json, urllib.request, urllib.error

ORIGIN = "https://<assigned-origin>"
STREAM = "<stream_id>"
SECRET = load_from_your_secret_store()   # never hard-code

def call(method, suffix, body=None, headers=None):
    url = f"{ORIGIN}/api/v1/streams/{STREAM}/{suffix}"
    data = None if body is None else json.dumps(body, ensure_ascii=False).encode("utf-8")
    h = {"Authorization": "Bearer " + SECRET, "Accept": "application/json"} | (headers or {})
    if data is not None:
        h["Content-Type"] = "application/json"
    req = urllib.request.Request(url, data=data, headers=h, method=method)
    try:
        with urllib.request.urlopen(req, timeout=15) as r:
            return r.status, json.loads(r.read()), dict(r.headers)
    except urllib.error.HTTPError as e:
        return e.code, json.loads(e.read() or b"{}"), dict(e.headers)

def submit(row):   # row = your outbox record
    status, body, head = call("POST", "answers/", {
        "schema_version": "sanad-submission-1",
        "submission_id": row.submission_id, "submission_version": row.version,
        "question_id": row.question_id, "question_fingerprint": row.fingerprint,
        "text": row.text, "has_attachment": False,
    }, {"Idempotency-Key": row.key})
    if status in (200, 202):
        return body["receipt_id"]            # store it, mark delivered
    if status == 409:
        raise Reconcile(body["error"])       # do not mutate identity
    if status == 429:
        raise RetryLater(int(head["Retry-After"]))
    raise Fatal(status, body)

def follow(cursor):
    status, page, _ = call("GET", f"changes/?limit=100&cursor={cursor}")
    for change in page["changes"]:
        s, result, _ = call("GET", f"results/{change['receipt_id']}/")
        store_shadow(result)                 # idempotent on (receipt_id, result_digest)
    return page["next_cursor"], page["has_more"]   # commit with the batch

def acknowledge(result, destination_record_id):
    assert result["state"] == "ready" and result["decision_applied"] and result["score"] and result["delivery_token"]
    return call("POST", "acknowledgements/", {
        "schema_version": "sanad-result-ack-1", "destination_record_id": destination_record_id,
        **{k: result[k] for k in ("receipt_id", "submission_version", "decision_revision", "result_digest", "delivery_token")},
    })

Node.js 18+ (fetch) and a webhook receiver

const ORIGIN = "https://<assigned-origin>", STREAM = "<stream_id>";
const SECRET = await secrets.get("gradally-stream");           // never in the client bundle
const base = `${ORIGIN}/api/v1/streams/${STREAM}/`;

async function call(method, suffix, body, extra = {}) {
  const res = await fetch(base + suffix, {
    method, redirect: "error",
    headers: { Authorization: `Bearer ${SECRET}`, Accept: "application/json",
               ...(body ? { "Content-Type": "application/json" } : {}), ...extra },
    body: body ? JSON.stringify(body) : undefined,
  });
  const text = await res.text();
  let json = null; try { json = JSON.parse(text); } catch {}   // proxies may return non-JSON
  return { status: res.status, json, retryAfter: res.headers.get("retry-after") };
}

export const submit = (row) => call("POST", "answers/", {
  schema_version: "sanad-submission-1",
  submission_id: row.submissionId, submission_version: row.version,   // integer literal
  question_id: row.questionId, question_fingerprint: row.fingerprint,
  text: row.text, has_attachment: false,
}, { "Idempotency-Key": row.key });

// Webhook receiver (Express-style). Verify on the RAW body, store, then 204.
import { createHmac, timingSafeEqual } from "node:crypto";
app.post("/gradally/results", express.raw({ type: "application/json", limit: "256kb" }), async (req, res) => {
  const ts = req.get("X-Gradally-Webhook-Timestamp"), sig = req.get("X-Gradally-Webhook-Signature") || "";
  if (!/^\d{1,12}$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400);
  const expected = "sha256=" + createHmac("sha256", WEBHOOK_SECRET).update(ts + ".").update(req.body).digest("hex");
  if (expected.length !== sig.length || !timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) return res.sendStatus(401);
  const event = JSON.parse(req.body);
  if (event.stream_id !== STREAM || event.event_id !== req.get("X-Gradally-Webhook-Id") || event.publishable !== false) return res.sendStatus(400);
  try { await inbox.insertIgnoreDuplicate(event.stream_id, event.event_id, event); }   // durable, unique (stream_id, event_id)
  catch { return res.sendStatus(503); }
  res.sendStatus(204);
});

Acceptance checklist

Run these against the sandbox from your own server, queues and storage (Options B and C) or against your database (Option A). A manual tool run proves the wire format, not your integration.

Recorded run

The exchanges behind this page, in order, from a synthetic sandbox exam with two questions. Statuses are the real responses.

#RequestStatusNote
1GET /200Manifest with two questions
2GET / wrong secret401unauthorized
3GET / no Authorization401unauthorized
4POST answers/ version 1202created: true, Location + Retry-After: 2
5POST answers/ exact replay200Same receipt, created: false
6POST answers/ same version, other text409version_conflict
7POST answers/ version 3 before 2409version_conflict
8POST answers/ stale fingerprint409version_conflict
9POST answers/ extra field400invalid_request
10POST answers/ version 1.0400invalid_request
11POST answers/ text/plain415json_required
12GET answers/405method_not_allowed, Allow: POST
13GET results/{receipt}/200processing, no token
14GET results/?limit=50200One receipt, cursor returned
15GET changes/?limit=50200Empty page, cursor returned
Grading pass runs on the Gradally side.
16GET changes/?limit=50200Sequence 1 for the receipt
17GET changes/?cursor=…200Nothing new after the cursor
18GET results/{receipt}/200ready, score 1/1, token present
19POST acknowledgements/201created: true
20POST acknowledgements/ replay200created: false
21POST acknowledgements/ other destination409version_conflict
22POST acknowledgements/ tampered digest409version_conflict
23GET submissions/?submission_id=…200Latest version's result
24POST answers/ version 2202New receipt
25GET results/{receipt v1}/200superseded, score null
26POST acknowledgements/ for v1409version_conflict
27GET submissions/?…&submission_version=1200Version 1, now superseded
28GET results/{unknown}/404not_found
29GET changes/?cursor=not-a-cursor400invalid_request
30GET results/?limit=500400invalid_request
31GET /api/v1/streams/{unknown}/404not_found

The connector run (Option A) on the same sandbox, four turns: plan (read-only), pull (3 rows of the exam, 2 admitted, 1 unmapped skipped, the other exam's row never selected), deliver after grading (both ready), the edited row admitted as version 2 with version 1 marked superseded, and version 2 delivered as ready after the next grading pass.

FAQ

We cannot open our database to anyone. What then?

Option B: one outbound call per answer and one receiver for the signed webhook, both on your server. If your backend already exposes an endpoint listing answers changed since a time, the connector can use that instead of a table; tell us its shape.

Can we send a whole quiz in one request?

No. One answer version per request. Queue them and send with a few workers; the allowance is 600 per stream per minute. With Option A there is nothing to send.

How fast is a grade ready?

Grading is asynchronous and batched by size and by a short deadline. There is no completion SLA in this release; the change feed, the webhook or the results table tells you when a result changed.

What if our request times out after Gradally stored the answer?

Replay the exact payload: you get 200 with the same receipt. Or read submissions/?submission_id=… to recover it.

Can the grade change after we stored it?

Yes: a key correction, a revision by the student, or a later decision can invalidate it. You will see it in the change feed, the webhook or the results table; store the new snapshot. Never show an old value while resolving a 409.

Can we show the grade to students?

Not from this integration alone. Results are shadow grades (publishable: false) until a separate publication agreement; keep your current grade untouched.

Which data must we never send or expose?

Student names, e-mails, phone numbers, parent numbers, previous grades, files or images. IDs are opaque on your side; Gradally receives the answer text and its identifiers only. A page that opens another site with student details in the URL is not an integration path for grades.

Downloads

These files travel with this page in the kit folder and in GRADALLY_PARTNER_API_KIT_2026-09-17-r2.zip:

The sandbox origin, the stream ID, the secrets and Gradally's public address are delivered separately by the Gradally operator; the placeholders in these files are not operational.

الملخص العربي

هذه الصفحة تشرح ثلاث طرق لربط منصتكم بخدمة تصحيح الإجابات النصية في Gradally. اختاروا الأقل عملًا عليكم؛ كلها منفذة في النسخة الحالية، ولا يعمل شيء منها في متصفح الطالب، ولا تُنشر درجة للطالب من أي منها.

الخيار أ — بلا عمل من جهتكم (موصى به)

  • تمنحوننا مستخدم قاعدة بيانات للقراءة فقط على جدول الإجابات (أو عرض view)، وجدول نتائج واحدًا ننشئه من تعريف DDL أعلاه مع مستخدم يكتب فيه، وتسمحون لعنوان Gradally بالوصول عبر TLS.
  • نقرأ خمسة أعمدة فقط: معرّف الصف، مرجع المحاولة، مرجع السؤال، نص الإجابة، وقت التحديث (ومرجع الامتحان للتصفية). لا نقرأ الاسم أو الهاتف أو البريد أبدًا، ولا نكتب في جداول أعمالكم.
  • كل تعديل على الإجابة يصبح إصدارًا جديدًا؛ النتيجة الصالحة هي أعلى إصدار بحالة ready، والإصدارات الأقدم تُعلَّم superseded.

الخيار ب — طلبان وWebhook موقّع

  • خادمكم يرسل كل إجابة عند التسليم (POST /answers/)، وGradally ترسل كل نتيجة إلى عنوان HTTPS تحددونه، بتوقيع HMAC ورأس X-Gradally-Webhook-Signature، مع إعادة المحاولة حتى ثماني مرات.
  • تتحققون من التوقيع على الجسم كما وصل، تخزنون الحدث بمفتاح (stream_id, event_id)، ثم تردون 2xx. الرد 2xx تأكيد نقل فقط، لا إقرار ولا نشر.

الخيار ج — الواجهة الكاملة

إيصالات وسجل تغييرات ومؤشرات وإقرارات كما في المرجع أعلاه، لمن يريد التحكم الكامل والتدقيق من جهته.

قواعد مشتركة

  1. الرمز السري يبقى في خادمكم فقط؛ لا يوضع في المتصفح أو التطبيق أو الروابط أو السجلات. تدويره يُلغي القديم فورًا.
  2. لا تُرسل أسماء الطلاب أو هواتفهم أو بريدهم أو أرقام أولياء الأمور؛ المعرفات مبهمة والربط بالطالب يبقى عندكم. فتح موقع آخر ببيانات الطالب في الرابط ليس مسارًا للدرجات.
  3. الحالات: processing انتظار، ready درجة مطبقة، unavailable لا نتيجة الآن، action_required تدخل تشغيلي من Gradally، stale نتيجة سابقة لم تعد صالحة، superseded توجد نسخة أحدث. null ليس صفرًا.
  4. كل نتيجة تحمل publishable: false؛ لا تُعرض كدرجة نهائية للطالب قبل اتفاق نشر منفصل، وتبقى درجتكم الحالية كما هي.
  5. الحصص: 600 طلب لكل ربط و1200 لكل عنوان IP في كل دقيقة للاستقبال، ومثلها منفصلة للقراءة والإقرارات. عند 429 انتظروا مدة Retry-After ثم أعيدوا الطلب نفسه.

قبل الإنتاج

  • بيئة اختبار HTTPS مع امتحان مجهز وربط وبيانات دخول، ثم خريطة الأسئلة، ثم اختبار القبول على إجابات مصطنعة، ثم فترة ظل على إجابات حقيقية دون عرضها للطلاب، ثم اتفاق النشر.

كل الأمثلة في هذه الصفحة مسجلة من تشغيل حقيقي للنسخة الحالية على بيانات مصطنعة، مع حجب الأسرار والرموز والمؤشرات.