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 connector
B · Two calls + webhook
C · Full API
Who moves the answers
Gradally reads them from your database
Your server posts them
Your server posts them
Who moves the grades
Gradally writes one results table in your database
Gradally posts a signed webhook to your endpoint
Your server polls the change feed and acknowledges
Your work
Grant access, create one table from our DDL, allow our IP
Change feed and current-result reads stay available
Replay 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
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.
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.
One results table created from the DDL below, with a user that can INSERT and UPDATE it. Gradally never touches your business tables.
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.
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
The usable grade of an answer slot is the row with the highestsubmission_version whose state is ready. Older versions are marked superseded and keep NULL scores.
fraction, earned and maximum are decimal strings (for example "0.5", "1", "2"); NULL never means zero.
A row can change from ready to stale or unavailable after a key correction; watch result_digest or updated_at, not only new rows.
States and their meaning are the same six as in the API: see Results & states.
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
An HTTPS receiver URL (no query string, no credentials in the URL) and its public IPv4 address. Gradally connects to that pinned address with a verified certificate for your hostname; private addresses, IPv6 and redirects are refused.
Somewhere safe to keep two secrets we deliver separately: the stream bearer for your calls and the webhook signing secret for ours.
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}/… }
}
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.
Check schema_version, your stream_id, that event_id equals the header, and publishable: false.
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.
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:
A reachable HTTPS sandbox for your team, with a prepared exam, a stream and its credentials; then the mapping of your question references.
For A: the database grants and the results table; for B: your receiver and its public address.
The acceptance run on synthetic answers (checklist), then a shadow period on real answers with grades kept out of students' view.
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
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.
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.
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.
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.
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
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.
created: false, the same receipt. Nothing duplicated.
Same ID and version, different body
409
The 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 question
409
Reconcile with your outbox and the manifest, then resend.
Unknown field, non-integer version, bad fingerprint format, text too long, attachment flag
400
Fix 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.
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.
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.
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.
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
Field
Type
Rule
schema_version
string
Exactly "sanad-submission-1". (Schema identifiers keep this prefix for compatibility; the product is Gradally.)
submission_id
string, 1–160
One 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_version
integer literal
1 for the first delivery, then 2, 3… consecutively, at most 2147483647. Never 1.0, "1" or true.
question_id
string, 1–160
The Gradally question ID from the manifest. An ID cannot move to another question in a later version.
question_fingerprint
64 hex chars
The current fingerprint from the manifest, lowercase. A stale fingerprint is a 409: re-read the manifest.
text
string ≤ 20,000 chars
The 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_attachment
boolean
Must be false. Files, images and handwriting are not accepted by this API.
Exactly these seven fields. Extra fields, duplicate keys, NaN/Infinity are rejected with 400.
Whole JSON body at most 98,304 bytes; larger bodies are 413. Do not split one answer into fragments.
Optional header Idempotency-Key (1–160 chars of A-Za-z0-9._~-, for example your outbox row key): the same key with the same body returns the same receipt; the same key with a different body is a 409. The body alone already makes exact replays safe, so the header is a convenience.
Identity of the answer version this result belongs to.
question_fingerprint
The key fingerprint at the moment the receipt was created.
grading_fingerprint
The 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_revision
Revision counter of the grading decision for this answer, starting at 0 before any decision. Echo it in the acknowledgement.
state, message
One of the six states below; message is a short Arabic diagnostic for humans, not a branching key.
decision_applied
true only when a grade is applied inside Gradally.
publishable
Always 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.
criteria
Per-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).
feedback
Human-readable lines derived from the criteria; empty when there are none.
result_digest
Opaque version identifier of this exact representation. Store it; echo it; never rebuild it from your own serialisation.
delivery_token
Present 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
State
Meaning
Your handling
ready
A grade is applied (when decision_applied, score and delivery_token agree).
Store the exact snapshot as the shadow grade of this version, then acknowledge.
processing
Received, not yet decided.
Record "waiting"; keep consuming the change feed. Do not poll one receipt in a tight loop.
unavailable
No 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_required
Gradally operations must act (configuration or an operational block).
Alert your operations contact; never open a teacher marking task on your side.
stale
The earlier grade is no longer valid for the current key or decision.
Invalidate the stored shadow value and reconcile when a new result arrives.
superseded
A 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.
Persist one cursor per (stream, feed). Cursors are opaque, signed and scoped to the stream; a cursor from another stream or a damaged cursor is 400 invalid_request.
Replay is safe: re-reading from an older cursor repeats notifications, and your storage must be idempotent per (receipt_id, result_digest).
The receipt scan's next_cursor is null only while the stream has no receipts. The change feed always returns a cursor, even for an empty page.
Recovering your own lost checkpoint: replay both feeds from an empty cursor with idempotent storage. No retention period is promised yet; confirm it during onboarding.
Acknowledgements
Fetch the current result. Check that it is ready, decision_applied: true, with a score and a delivery_token.
Store the exact snapshot (all fields, digest included) in your shadow table and commit, together with a stable destination record ID.
Post the acknowledgement built from the result's five fields plus your record ID. Expect 201; an identical replay returns 200.
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.
Status
error
When
Do
400
invalid_request
Malformed JSON or field, bad cursor, bad query, page size outside 1–100
Fix the request; no blind retry.
400
https_required
Plain HTTP in production
Use HTTPS.
401
unauthorized
Missing, wrong, rotated or revoked secret; stream paused; owner deactivated
Stop automatic retries; check the credential with the operator. Header WWW-Authenticate: Bearer.
404
not_found
Unknown stream, receipt or submission in this scope
Check origin, stream ID and IDs.
405
method_not_allowed
Wrong method (the Allow header lists the right one)
Use the documented method and trailing slash.
409
version_conflict
Content differs for a stored version; version out of order; stale fingerprint; question moved; unknown question; acknowledgement mismatch or expiry
Read the current state (manifest, result), reconcile, then resend. Never change identity to bypass it.
413
payload_too_large
Body over 98,304 bytes
Enforce the size on your side.
415
json_required
Content-Type is not application/json
Set the header.
429
rate_limited
Allowance exhausted; the request was not processed
Wait Retry-After seconds plus jitter, then retry the same request.
503
capture_disabled · temporarily_unavailable
Intake switched off by the operator · transient storage contention
Back off (Retry-After); if capture_disabled persists, contact operations.
600 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 size
1–100 (default 50); cursor ≤ 2,048 characters
Delivery token
Valid 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
Connector
Polls every 15 s, up to 1,000 rows per pull, up to 20 result pages per turn
Sender concurrency
Suggested: 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
Versions per submission_id are sent strictly in order; version n+1 only after version n has a receipt.
The payload bytes of a version never change after the outbox row is written.
Only the latest version's result is displayed as the shadow grade; older versions keep history.
Secrets and delivery tokens never appear in logs, tickets or exported Postman files.
Your existing grade shown to students is untouched until a publication agreement exists.
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")},
})
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.
Option A: the connector's plan lists the agreed table, columns and mapping; the first run creates one receipt per mapped answer and none for other exams or unmapped questions.
Option A: an edited answer appears as version 2 in the results table and version 1 reads superseded; a personal column added to the table is never read.
Manifest with the right secret lists the agreed questions; a missing or wrong secret is 401.
Version 1 → 202 and a stored receipt; the exact replay → 200 with the same receipt and no duplicate.
Same ID and version with a different text → 409, previous answer intact.
Version 3 before 2 → 409; wrong fingerprint → 409; extra field or attachment flag → 400.
A burst above the allowance → 429 with Retry-After; the same request succeeds after the wait.
Pending result reads as processing; after grading, the change feed (or the webhook) lists the receipt and the result is ready.
Option B: a webhook with a bad signature or a stale timestamp is refused; a valid one is stored once and answered 2xx; a duplicate gets 2xx without a second inbox row.
Paging through more than one page loses nothing; the receipt cursor and the change cursor are stored separately.
A stored ready result acknowledged → 201; the identical replay → 200; a changed or expired one → 409 and the shadow value is refreshed.
Sending version 2 supersedes version 1 (no score on version 1, its acknowledgement now 409); the shadow grade shows version 2 only.
Every stored result and acknowledgement shows publishable: false; the student-facing grade did not change.
Recorded run
The exchanges behind this page, in order, from a synthetic sandbox exam with two questions. Statuses are the real responses.
#
Request
Status
Note
1
GET /
200
Manifest with two questions
2
GET / wrong secret
401
unauthorized
3
GET / no Authorization
401
unauthorized
4
POST answers/ version 1
202
created: true, Location + Retry-After: 2
5
POST answers/ exact replay
200
Same receipt, created: false
6
POST answers/ same version, other text
409
version_conflict
7
POST answers/ version 3 before 2
409
version_conflict
8
POST answers/ stale fingerprint
409
version_conflict
9
POST answers/ extra field
400
invalid_request
10
POST answers/ version 1.0
400
invalid_request
11
POST answers/ text/plain
415
json_required
12
GET answers/
405
method_not_allowed, Allow: POST
13
GET results/{receipt}/
200
processing, no token
14
GET results/?limit=50
200
One receipt, cursor returned
15
GET changes/?limit=50
200
Empty page, cursor returned
Grading pass runs on the Gradally side.
16
GET changes/?limit=50
200
Sequence 1 for the receipt
17
GET changes/?cursor=…
200
Nothing new after the cursor
18
GET results/{receipt}/
200
ready, score 1/1, token present
19
POST acknowledgements/
201
created: true
20
POST acknowledgements/ replay
200
created: false
21
POST acknowledgements/ other destination
409
version_conflict
22
POST acknowledgements/ tampered digest
409
version_conflict
23
GET submissions/?submission_id=…
200
Latest version's result
24
POST answers/ version 2
202
New receipt
25
GET results/{receipt v1}/
200
superseded, score null
26
POST acknowledgements/ for v1
409
version_conflict
27
GET submissions/?…&submission_version=1
200
Version 1, now superseded
28
GET results/{unknown}/
404
not_found
29
GET changes/?cursor=not-a-cursor
400
invalid_request
30
GET results/?limit=500
400
invalid_request
31
GET /api/v1/streams/{unknown}/
404
not_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:
openapi.json: OpenAPI 3.1.1 with all seven operations, the webhook event, schemas and error responses.
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 تأكيد نقل فقط، لا إقرار ولا نشر.
الخيار ج — الواجهة الكاملة
إيصالات وسجل تغييرات ومؤشرات وإقرارات كما في المرجع أعلاه، لمن يريد التحكم الكامل والتدقيق من جهته.
قواعد مشتركة
الرمز السري يبقى في خادمكم فقط؛ لا يوضع في المتصفح أو التطبيق أو الروابط أو السجلات. تدويره يُلغي القديم فورًا.
لا تُرسل أسماء الطلاب أو هواتفهم أو بريدهم أو أرقام أولياء الأمور؛ المعرفات مبهمة والربط بالطالب يبقى عندكم. فتح موقع آخر ببيانات الطالب في الرابط ليس مسارًا للدرجات.
الحالات: processing انتظار، ready درجة مطبقة، unavailable لا نتيجة الآن، action_required تدخل تشغيلي من Gradally، stale نتيجة سابقة لم تعد صالحة، superseded توجد نسخة أحدث. null ليس صفرًا.
كل نتيجة تحمل publishable: false؛ لا تُعرض كدرجة نهائية للطالب قبل اتفاق نشر منفصل، وتبقى درجتكم الحالية كما هي.
الحصص: 600 طلب لكل ربط و1200 لكل عنوان IP في كل دقيقة للاستقبال، ومثلها منفصلة للقراءة والإقرارات. عند 429 انتظروا مدة Retry-After ثم أعيدوا الطلب نفسه.
قبل الإنتاج
بيئة اختبار HTTPS مع امتحان مجهز وربط وبيانات دخول، ثم خريطة الأسئلة، ثم اختبار القبول على إجابات مصطنعة، ثم فترة ظل على إجابات حقيقية دون عرضها للطلاب، ثم اتفاق النشر.
كل الأمثلة في هذه الصفحة مسجلة من تشغيل حقيقي للنسخة الحالية على بيانات مصطنعة، مع حجب الأسرار والرموز والمؤشرات.