BigChalkBox IconBigChalkBox/Developers
v1.3
Download OpenAPI

BigChalkBox Partner API — Integration Guide

For the engineers building against the API. The Reference is the contract (exact routes, payloads, errors) — this guide is the how and why: how the platform works behind the endpoint, how to integrate it well, and what to check before you go live.

Synced to Partner API v1.3 (2026-09-23): the ops surface — a test cycle now runs end-to-end server-side: batch upload (POST /assignments/{id}/submissions/bulk, 1–50 PDFs with a per-file report), cohort analytics (GET /assignments/{id}/analytics), server-built exports (GET /assignments/{id}/export?format=xlsx|zip), fast re-grade modes (POST /submissions/{id}/grade?mode=existing-pages|overrides) and page rearrangement (…/page-layout, …/page-overrides).

v1.2 (2026-09-23): the content door — tests can be created without hand-built JSON: POST /papers/extract (paper PDF → questions), POST /assignments/from-file (Markdown template), and POST /assignments/{id}/bulk-generate (AI fills the missing answer key). Webhooks remain the primary completion path, and results come back as files too (v1.1).

Also available: partner-api-openapi.json — a machine-readable spec generated from the deployed service. Import it into Postman or Insomnia to explore every endpoint interactively (§9).


1. How the platform works

graph TD
    %% Styling
    classDef your_sys fill:#f4f4f5,stroke:#a1a1aa,stroke-width:2px,color:#09090b,rx:5px,ry:5px;
    classDef bcb_api fill:#ecfdf5,stroke:#10b981,stroke-width:2px,color:#064e3b,rx:5px,ry:5px;
    classDef bcb_worker fill:#f8fafc,stroke:#cbd5e1,stroke-width:2px,color:#0f172a,rx:5px,ry:5px;
    classDef bcb_db fill:#eff6ff,stroke:#3b82f6,stroke-width:2px,color:#1e3a8a;
    classDef bcb_gateway fill:#f8fafc,stroke:#94a3b8,stroke-width:2px,color:#0f172a,rx:5px,ry:5px;

    backend[Your Servers <br/><br/> integration backend <br/> X-API-Key]:::your_sys

    subgraph BigChalkBox [BigChalkBox Platform]
        nginx[nginx <br/> TLS + per-key rate limit 20 r/s]:::bcb_gateway
        api[partner-api <br/> FastAPI :8004 <br/> webhook outbox]:::bcb_api
        db[(partner-db <br/> your tests, submissions, students)]:::bcb_db
        worker["<b>evaluator&nbsp;worker</b> <hr/> <div style='text-align:left'>➤&nbsp;segmentation <br/> ➤&nbsp;per-question&nbsp;AI&nbsp;grading <br/> ➤&nbsp;report&nbsp;generation</div>"]:::bcb_worker
        
        nginx --> api
        api <--> db
        worker -- "polls the queue, claims QUEUED rows" --> db
    end

    backend -- "HTTPS POST" --> nginx
    api -. "signed webhooks <br/> (received, completed, failed)" .-> backend

Key facts that shape everything else:

  • Grading is asynchronous and queue-driven. "Trigger grading" just marks a row QUEUED; a worker claims it within seconds.
  • Completion is push-first (v1.1). Register one webhook endpoint (§3, step 2) and the platform POSTs you grading.completed / grading.failed as runs finish — HMAC-signed, retried for ~2.5 h, and inspectable in the delivery log. Polling GET /submissions/{id} remains fully supported as a fallback and for reconciliation, but it is no longer the primary path.
  • Results come back as files too (v1.1, cohort-level in v1.3). Beyond the JSON report: the original sheet (GET …/pdf, 302 → presigned URL), the per-question page images (GET …/pages), and — for OSM tests — the marked answer sheet (GET …/marked-pdf, one annotated PDF). In v1.3 the platform builds the cohort files for you: GET /assignments/{id}/export?format=xlsx (the full results roster) and ?format=zip (all marked PDFs in one archive) — no client-side file assembly.
  • A test cycle runs entirely server-side (v1.3 — the ops surface). Upload a whole scanned class in one batch (POST …/submissions/bulk — explicit per-file student map or [ID]_[First]_[Last].pdf + your email domain), read the cohort numbers (GET …/analytics: score bands, problem areas, counts), and re-grade without re-running the pipeline (grade?mode=existing-pages, or rearrange pages first and grade?mode=overrides).
  • Tests can be created without hand-built JSON (v1.2 — the content door). Three paths (Reference §4.6): extract questions from your paper PDF (POST /papers/extract), create a test from a Markdown template (POST /assignments/from-file), and let the platform's AI fill any missing model answers / rubrics / MCQ answers (POST /assignments/{id}/bulk-generate, 202 + poll). Key-less tests are legal — the grading gate simply refuses to queue an uncovered question, so nothing grades silently. Which AI reads your papers and grades your tests is managed by BigChalkBox server-side; no request exposes an engine or provider parameter.
  • Two databases, strictly separated. Your tests/submissions/students live in a dedicated partner database. The only cross-touchpoints are key authentication and the GET /usage metering read. Nothing you do can affect the consumer product's data.
  • Your key is your tenancy. Everything your key creates belongs to your service account (api-client-<your-name>@api.internal). Other keys' objects answer 404 to you — existence is never leaked across tenants.
  • Grading itself is AI vision. The worker renders each PDF page to an image, segments answers per question, and grades each question against your rubric (SUBJECTIVE) or correct_answers (MCQ). Rubric quality directly drives grade quality — write rubrics like you'd brief a human examiner. Tests created with osm_enabled: true additionally get the student's own pages annotated (ticks/crosses + examiner remark) — that is what /marked-pdf hands back.

2. The end-to-end flow

sequenceDiagram
    autonumber
    participant You as Your backend
    participant API as api.bigchalkbox.com
    participant W as Grading worker

    Note over You,W: === 1. SETUP & REGISTRATION ===
    You->>API: POST /webhooks (register endpoint + secret, once)
    API-->>You: 201 — secret shown exactly once
    
    Note over You,W: === 2. CREATING THE TEST ===
    You->>API: create the test<br/>(POST /assignments JSON, /papers/extract + create,<br/>or /assignments/from-file, v1.2)
    opt key incomplete
        You->>API: POST /assignments/{id}/bulk-generate<br/>(AI fills the key)
        API-->>You: 202 — poll until bulk_gen_status = DONE
    end
    API-->>You: 201 {id}
    
    Note over You,W: === 3. SUBMISSION & GRADING PIPELINE ===
    loop each student (or one POST …/submissions/bulk per class — v1.3)
        You->>API: POST /assignments/{id}/submissions (PDF, auto_grade=true)
        API-->>You: 201 {submission_id, status: QUEUED}
        API-->>You: webhook: submission.received (HMAC-signed)
    end
    
    W--)API: polls for QUEUED rows
    API--)W: returns QUEUED submissions
    Note over W: AI Vision Grading Pipeline
    W--)API: saves results & triggers outbox
    
    alt webhook (recommended)
        API-->>You: webhook: grading.completed / grading.failed<br/>(HMAC-signed)
    else polling (fallback / reconciliation)
        loop until terminal (every 2–5 s + jitter, with a deadline)
            You->>API: GET /submissions/{submission_id}
            API-->>You: 200 {status: QUEUED|PROCESSING|EVALUATED}
        end
    end
    
    Note over You,W: === 4. RESULTS & ANALYTICS ===
    You->>API: GET /submissions/{submission_id}/results
    API-->>You: 200 {report: per-question detail}
    
    opt OSM test
        You->>API: GET /submissions/{submission_id}/marked-pdf
        API-->>You: 200 — annotated sheet as one PDF
    end
    opt whole gradebook
        You->>API: GET /assignments/{id}/submissions?limit=100&cursor=...
        API-->>You: 200 {items, next_cursor}
    end
    opt cohort numbers + files (v1.3)
        You->>API: GET /assignments/{id}/analytics
        API-->>You: 200 {score_distribution, problem_areas, average_score, ...}
        You->>API: GET /assignments/{id}/export?format=xlsx<br/>(or zip, OSM tests)
        API-->>You: 200 — the file, server-built
    end
    opt a page landed under the wrong question (v1.3)
        You->>API: GET /submissions/{id}/page-layout
        You->>API: PUT /submissions/{id}/page-overrides {enabled, questions}
        You->>API: POST /submissions/{id}/grade?mode=overrides
        API-->>You: 202 — re-grades only the moved questions,<br/>then bakes the layout
    end

Typical wall-clock for one submission: tens of seconds to a few minutes (the 7-page scanned sheet in our end-to-end test took ~55 s). Duration grows with page count and question count.

3. First integration: the first hour

Step 0 — get your key, store it properly

Ask your BigChalkBox contact to issue a key (admin UI: Admin → API Clients). The raw key (bcbk_…) is shown exactly once. Put it straight into your secrets manager — never in code, config files, tickets, or chat (§5).

Step 1 — smoke test (2 minutes)

If this fails, stop — nothing else will work. 401 means missing/invalid/inactive key; 429 means you're already rate-limited (slow down).

bash
curl -s -H "X-API-Key: $BCB_API_KEY" https://api.bigchalkbox.com/partner/v1/health
# {"status": "ok", "client": {"id": 1, "name": "YourOrg"}}

Step 2 — register your webhook (once, ~5 minutes)

The platform pushes grading events to your endpoint instead of making you poll. Register exactly one URL for your integration (the full contract — events, signature scheme, retries, management endpoints — is Reference §6):

  • The response contains a webhook secret — shown exactly once. Store it in your secrets manager immediately (same discipline as the API key).
  • Every delivery is signed: X-BCB-Signature: t=<unix-ts>,v1=HMAC-SHA256(secret, t + "." + body). Verify the signature before trusting the payload — Reference §6.3 has drop-in snippets (Python / bash / Node).
  • Deliveries are at-least-once: dedupe on the X-BCB-Event-Id header.
  • Three events: submission.received (upload accepted), grading.completed, grading.failed. Pass events to subscribe to a subset; omit it for all three.
bash
curl -s -X POST $BASE/webhooks -H "X-API-Key: $BCB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://your-platform.example/hooks/bigchalkbox"}'

Step 3 — create a throwaway test assignment

Use the create payload from Reference §4.2. If you'd rather not hand-build the JSON, the content door (below, and Reference §4.6) gets you the same place from a paper PDF or a Markdown template. Rules that bite people, in order of frequency:

  1. Every question needs text + positive marks; every MCQ needs options (top-level or in sub_questions).
  2. The answer key — rubrics for SUBJECTIVE, correct_answers for MCQ — is checked at grade time (v1.2): a key-less test creates and uploads fine, then grading 422s naming the uncovered question. Fill the key before first grade: PUT …/rubrics + PUT …/questions, a filled template, or POST …/bulk-generate (AI).
  3. question_number is your label — the report keys on it. Use the same numbering as the physical paper.
  4. If you want the marked answer sheets back, add "osm_enabled": true to the create payload (v1.1) — it gates GET …/marked-pdf (step 5 below).

Keep this assignment — the rest of the walkthrough uses it. It's fully reversible (create → close → delete), so integrating against a throwaway is low-risk.

Step 4 — upload one real answer sheet and grade it

submission.received hits your webhook the moment the upload is accepted. Grading runs in the background; when it finishes, grading.completed (or grading.failed) arrives at the same URL, signed. If you'd rather not wait on the webhook for this step, a production-shaped poller (deadline, backoff, no tight loop) works equally well:

Fetch results when EVALUATED; read error_log when ERROR (usually a rubric coverage or a bad-PDF problem — fix, then POST /submissions/{id}/grade again).

bash
curl -s -X POST $BASE/assignments/$AID/submissions \
  -H "X-API-Key: $BCB_API_KEY" \
  -F file=@one_student.pdf -F student_email=test.student@yourdomain.edu
python
import time, requests

def wait_terminal(sid: str, timeout_s: int = 600) -> dict:
    """Poll a submission until EVALUATED/ERROR or the deadline. Backs off on
    429/5xx, fails loudly on anything unexpected."""
    deadline = time.monotonic() + timeout_s
    delay = 3.0
    while time.monotonic() < deadline:
        r = requests.get(f"{BASE}/submissions/{sid}",
                         headers={"X-API-Key": KEY}, timeout=30)
        if r.status_code == 429 or r.status_code >= 500:
            time.sleep(min(delay, 30)); delay *= 2
            continue
        r.raise_for_status()
        st = r.json()
        if st["status"] in ("EVALUATED", "ERROR"):
            return st
        time.sleep(3 + (time.time_ns() % 2000) / 1000)   # 3-5s with jitter
    raise TimeoutError(f"{sid} not graded within {timeout_s}s")

Step 5 — fetch the result (and the marked sheet, if OSM)

/marked-pdf streams one PDF of the student's own pages with the grader's ticks/crosses and examiner remark, with the total-score badge on page 1 — the visual "returned answer sheet" your UI can hand back. /pages returns presigned URLs grouped by question (q1, q2a, qMCQs, …) for page-level browsing. Presigned URLs expire after one hour — fetch them when you need them, don't cache the URL past that. The response gates are spelled out in Reference §4.5: 409 = not evaluated yet, 400 = test not OSM-enabled, 404 = no annotated pages.

bash
# structured, per-question report
curl -s -H "X-API-Key: $BCB_API_KEY" $BASE/submissions/$SID/results

# the returned sheet — OSM tests only (created with osm_enabled: true)
curl -sL -H "X-API-Key: $BCB_API_KEY" $BASE/submissions/$SID/marked-pdf -o marked.pdf

# the original sheet (302 → 1-h presigned URL) and the per-question page images
curl -sL -H "X-API-Key: $BCB_API_KEY" $BASE/submissions/$SID/pdf -o original.pdf
curl -s -H "X-API-Key: $BCB_API_KEY" $BASE/submissions/$SID/pages

Step 6 — walk the roster

GET /assignments/{id}/submissions?limit=100 + follow next_cursor until null (the loop is in Reference §7.2). This is your gradebook view; per-question detail is one /results call per submission, and the step-5 files one call further. When the cohort is graded, GET /assignments/{id}/analytics gives you the headline numbers (score bands, problem areas, average) and GET /assignments/{id}/export?format=xlsx the whole roster as a file — see "The ops surface" below.

The content door (v1.2) — creating tests without hand-built JSON

The three creation paths, in order of how little work they save you:

  1. POST /assignments/from-file — your paper as a Markdown template (FORMAT SPEC v1 in Reference §4.6). Deterministic, instant, line-numbered errors. The template can carry the whole answer key (MODEL ANSWER / RUBRIC / MCQ ANSWERS sections) or just the paper.
  2. POST /papers/extract — your paper as a PDF. The platform's AI reads it and returns structured questions; you review, then create. Long-running (tens of minutes for large papers) — client timeout ≥ 30 min, and treat the output as a draft (extraction_confidence below 1.0 is normal).
  3. POST /assignments/{id}/bulk-generate — whichever path left the answer key incomplete: this fills missing model answers, rubrics and MCQ answers with AI, in the background (202, poll GET /assignments/{id} — bulk_gen_status → DONE). Already-complete questions are skipped, so it's safe to re-run.

Which AI reads papers and grades tests is managed by BigChalkBox server-side — you don't send (and can't send) engine or provider parameters.

The ops surface (v1.3) — running the rest of the cycle server-side

Once the content door creates the test, v1.3 covers everything that used to happen in a teacher's browser:

  • Batch upload — POST /assignments/{id}/submissions/bulk (Reference §4.3). Up to 50 PDFs per request; identify students with the students JSON map (filename → email/name) or by the [ID]_[First]_[Last]_[extra].pdf naming convention plus your email_domain. Always 200 with a per-file report — check results[].status per file; a grade-ready test auto-queues each success, a key-less one stores it SUBMITTED (the message says why).
  • Cohort numbers — GET /assignments/{id}/analytics (Reference §4.2): status counts, 5 score bands, average, and problem_areas (the 3 worst distinct average-score question tiers). Feed your results page from this — no client-side aggregation.
  • Exports — GET /assignments/{id}/export?format=xlsx (results roster, column-compatible with the teacher UI's Excel export) or ?format=zip (every marked PDF, OSM tests only, ≤ 100 submissions). Plain authenticated downloads; the server builds the file (Reference §4.2).
  • Fast re-grading — POST /submissions/{id}/grade?mode=existing-pages re-grades from the pages the last run already produced (no re-render / re-classify / re-segment — the go-to after a rubric tweak or a provider switch), and ?mode=overrides re-grades just the questions you rearranged. The repair flow for a mis-scanned sheet: GET …/page-layout → PUT …/page-overrides → grade?mode=overrides (Reference §3 + §4.4). Default (full) is unchanged from v1.2.
  • Page visibility — GET …/page-layout lists what the grader bifurcated (with 1-hour presigned URLs for each page) and any stored arrangement; /pages (v1.1) remains the report-view grouping.

4. Production-grade patterns

Webhooks (the primary completion path)

  • Register once, verify always. One URL per integration; the secret is shown once at registration/rotation. Your handler must verify X-BCB-Signature against the raw request bytes — any JSON re-serialization (pretty-print, key re-ordering) breaks the MAC — and reject timestamps more than 5 minutes old (replay guard). Verification snippets: Reference §6.3.
  • Respond fast, work later. Success is any 2xx within 10 seconds. If your processing takes longer, return 200 immediately and do the work on a background queue — a slow handler looks like a dead endpoint to the retry scheduler.
  • Be idempotent. Delivery is at-least-once: the same event can arrive twice (especially after a timeout on your side). Dedupe on X-BCB-Event-Id.
  • Expect out-of-order. No ordering guarantee across events — key your state on submission_id, not on event sequence.
  • Down time is recoverable, up to ~2.5 h. Failed deliveries retry at 1 m → 5 m → 30 m → 2 h, then go dead (not discarded). Watch GET /webhook-deliveries?status=dead, fix your endpoint, and re-queue with POST /webhook-deliveries/{id}/retry; reconcile anything older from the roster.
  • Keep polling as the safety net. A slow reconciliation sweep (walk roster rows that are QUEUED/PROCESSING/EVALUATED and have no matching webhook record of yours) catches a wedged endpoint without an incident.

Polling (fallback / reconciliation)

  • Poll per submission every 2–5 s with jitter. A class of 60 students uploading in 5 minutes ≈ steady-state ~0.5 r/s of polling — noise against the 20 r/s budget.
  • Always set a deadline (we suggest 10 min/submission) and treat exceeding it as an incident, not a hang — check the roster; if it's still PROCESSING after 10 minutes, keep a slower background poller or contact support.
  • On 429/5xx: exponential backoff (1 s → 2 s → 4 s …, cap ~30 s). There is no Retry-After header today.
  • Don't re-queue on a hunch: POST /grade?mode=full on a QUEUED/PROCESSING row is 409 (the v1.3 fast modes existing-pages/overrides are instead idempotent 202 no-ops while in flight). Re-queue only from terminal states (SUBMITTED/EVALUATED/ERROR).

Retry taxonomy

ResponseClassAction
429, 5xx, network errorRetryableExponential backoff, then give up with alert
409 on uploadState, not errorThe student's submission already exists in flight/graded — fetch the roster, find the submission_id, poll it
409/400/404 on /marked-pdfState, not errornot evaluated yet / test not OSM-enabled / no annotated pages — see the gate table in Reference §4.5
409 on bulk-generateState, not errora job is already running — poll GET /assignments/{id} instead
422 "no rubric" / "no complete correct answers" on grade (any mode)State, not errorthe answer key is incomplete (v1.2) — fill via PUT …/rubrics/…/questions or POST …/bulk-generate, then retry
502 on /papers/extractRetryable (later)our AI side unavailable/exhausted — the paper wasn't the problem; retry with backoff
bulk upload 200 with results[].status: "error" (v1.3)Per-file state, not request failurethe other files DID land — handle each item on its own (409-style items point at the existing submission_id; gate-refusals are stored SUBMITTED and queue later via POST /submissions/{id}/grade)
400/409 on a fast re-grade mode (v1.3)State, not errornot EVALUATED yet, or overrides without a stored arrangement — poll, or run PUT …/page-overrides first
400 on zip export (v1.3)Fix the requestnon-OSM test (no marked sheets exist) or > 100 evaluated submissions (fetch per-submission /marked-pdf)
400/422/404Fix your requestDon't retry the same bytes; fix and resend
401StopKey missing/rotated/deactivated — fail fast, alert a human
413Fixbody over the ~100 MB proxy cap (25 MB per PDF still applies) — split the batch

Idempotency & safe retries

There are no idempotency keys yet. Your protection is the (assignment, student) uniqueness: re-sending an upload either creates the submission (first attempt really did fail) or answers 409 pointing at existing work. So "timeout — did my upload land?" is always safe to resolve by retrying the upload, never by blindly creating a second student row. Correlate attempts with your own X-Request-Id header (echoed on every response).

Upload concurrency

Two shapes for a class upload (v1.3): the server-side batch endpoint (POST …/submissions/bulk, up to 50 PDFs per request — chunk larger classes into a few of these) or parallel single uploads at 4–8 concurrent (not 60 — that plus polling can brush the rate limit; not 1 — a 60-student class takes too long). On 429, back the whole batch off, not just one sender. With the bulk endpoint, the per-file report is your reconciliation source — don't re-send files whose item said success.

HTTP client settings

  • Uploads: timeout ≥ 60 s (a 25 MB scan on a slow uplink can take a while); bulk uploads (up to ~100 MB bodies) ≥ 120 s.
  • Poll/reads: 10–30 s timeouts are plenty.
  • File downloads: /marked-pdf streams a generated PDF (a few MB) — timeout ≥ 60 s and write to disk as it arrives; /pdf answers 302 to a presigned storage URL, so your client must follow redirects.
  • Exports (v1.3): GET …/export?format=xlsx is fast (seconds for a class); ?format=zip packs up to 100 generated marked PDFs server-side — timeout ≥ 5 min and stream to disk.
  • POST /papers/extract (v1.2): one synchronous call that can run tens of minutes on large papers — read timeout ≥ 30 min (the platform's proxy allows 50), no retry on 400 (the paper was rejected), retry with backoff on 502.
  • Webhook receiver (us → you): the platform treats any 2xx within 10 s as delivered — keep the request handler in front of slow work.
  • Follow redirects on the bare-host probe only; /partner/v1 never redirects.

5. Security practices

  • Treat the key as a bearer credential. Anyone holding it is you. Store it in a secrets manager; inject via env/secret store at runtime. Never: in git, logs, client-side code, URLs, screenshots, or support tickets (share the key_prefix bcbk_XXXXXXXXXXXX instead — it's the first 12 chars and cannot authenticate).
  • Server-to-server only. The API is not CORS-enabled by design; never call it from a browser or mobile app where the key would ship to users.
  • Rotation is immediate and destructive. Rotating invalidates the old key on the next request — coordinate the switch with your deploy, and handle a burst of 401s during rollout gracefully (reload key from secrets, don't crash-loop).
  • Webhook secrets follow the same show-once discipline. Returned only by the POST/PATCH call that set or rotated them; never readable afterwards. Store in your secrets manager, verify signatures with them, and if one leaks, PATCH /webhooks with a new secret — old signatures stop validating immediately.
  • Student data is PII. You choose what you send us; emails are the identity key. Deleting an assignment deletes its PDFs from storage — use it when cleaning up test data (and remember: close before delete).
  • Send your own X-Request-Id (a UUID per logical operation). It's echoed on every response and is the join key when you need us to trace a request.

6. Data lifecycle & the state you own

  • Status machine — see Reference §3. You control SUBMITTED → QUEUED (grade call) and nothing else; the worker owns QUEUED → PROCESSING → EVALUATED/ERROR. Terminal states are re-enterable: EVALUATED and ERROR submissions can be re-graded after rubric improvements.
  • Graded submissions are frozen — with one v1.3 escape hatch. Once EVALUATED (or in flight), the PDF cannot be replaced and the submission cannot be deleted via the API. What can change after grading is the page→question mapping: page-layout → page-overrides → grade?mode=overrides re-grades the affected questions from your arrangement (a mis-scanned sheet fixed without admin help). Get student_email right before uploading; a graded wrong file still needs BigChalkBox admin help.
  • Students are upserted by email across all your tests — same email = same student record; a later upload with a different name updates the name.
  • Assignments: only CLOSED blocks uploads (DRAFT and ACTIVE both accept them). Delete requires CLOSED first, and removes every submission and PDF.
  • deadline is metadata — the API never auto-closes at the deadline. Close assignments yourself.
  • osm_enabled is per-test, not per-submission. Set it at create or via PATCH; it only affects grading runs after it is set. Flip it on and re-queue a submission to get its marked sheet — existing reports are untouched until re-graded. Non-OSM tests get no /marked-pdf (400).

7. Testing strategy

  1. Throwaway assignment, fake students (qa.student1@yourdomain.edu…). Full lifecycle: create → upload → grade → results → close → delete. Cheap and complete.
  2. Error paths on purpose: upload a .txt (expect 400), a >25 MB PDF (expect 400/413), a duplicate upload while queued (expect 409), results before evaluated (expect 409). Assert the error envelope shape — your handler depends on it.
  3. Webhook path, end to end: point a throwaway receiver at your test integration, run the step-1 flow, and assert you get submission.received and grading.completed with valid signatures. Then make the receiver fail once (5xx) and confirm the retry lands ~1 minute later with the same X-BCB-Event-Id — that exercises both the retry schedule and your dedupe. Check GET /webhook-deliveries while you're at it; it should show the attempts.
  4. Rubric regression: keep one assignment whose rubric you never change; after any change on your side (new PDF pipeline, new scan settings), re-upload the same sheet and compare scores for drift.
  5. Don't test with real student PII until the throwaway flow is green end-to-end.

8. Go-live checklist

  • Key lives in a secrets manager; nothing in code/logs/repos
  • Webhook registered; its secret in the same secrets manager (§3 step 2)
  • Webhook handler verifies X-BCB-Signature against raw body bytes and rejects stale timestamps (§4)
  • Webhook handler idempotent (dedupe on X-BCB-Event-Id) and answers 2xx in < 10 s (slow work goes to a queue) (§4)
  • /health smoke test in your own deploy pipeline
  • 409-on-upload handled as "poll existing", not "fail" (§4)
  • 429/5xx exponential backoff implemented and tested
  • Polling kept as fallback + a slow reconciliation sweep, with a deadline and alert on timeout (§4)
  • dead webhook deliveries monitored and re-queued (GET /webhook-deliveries, POST …/retry) (§4)
  • ERROR submissions surface to your ops (they need a human: read error_log)
  • X-Request-Id generated per operation and logged
  • Batch upload concurrency within the 20 r/s budget (§4)
  • If you want marked answer sheets: osm_enabled: true at create and the /marked-pdf fetch wired into your "returned sheet" flow (§3 step 5)
  • If you use /papers/extract: client read timeout ≥ 30 min on that call, and the extracted questions get a human review before create (v1.2)
  • If you create key-less tests: the answer-key fill path is wired (bulk- generate or PUT replaces) and ops knows the grade-time 422 means "incomplete key", not "broken API" (v1.2)
  • If you batch-upload: the per-file report is parsed (a 200 with failed > 0 still needs per-item handling), and the identity source is decided — students map (preferred) vs [ID]_[First]_[Last].pdf + email_domain (v1.3)
  • If you run a results page: it's fed from GET …/analytics (bands, problem areas) and/or GET …/export?format=xlsx, not from client-side aggregation over the roster (v1.3)
  • If you re-grade: the right mode is picked — existing-pages for rubric/provider changes, overrides after a page rearrangement, full only when the PDF itself changed (v1.3)
  • auto_grade decision made: true (default, simplest) or false + explicit grade calls if you want a human gate before grading
  • Support path agreed with BigChalkBox (who you email, and they know your key_prefix for lookup)
  • Real student data only after §7's throwaway flow is green

9. Exploring with Postman / Insomnia

  1. Download partner-api-openapi.json (generated from the deployed service — routes, schemas, and error shapes match production).
  2. Postman: Import → file → select the JSON. Insomnia: Create → Import From → File.
  3. Set the apiKey collection variable to your key (the spec wires it as an X-API-Key header security scheme) and point requests at https://api.bigchalkbox.com.
  4. First call: GET /partner/v1/health. Then GET /me to see your identity.

10. Troubleshooting quick table

SymptomLikely causeFix
401 after a deploy that used to workKey was rotated/deactivated; or the key isn't in this environment's secretsPull fresh key; check the prefix in Admin → API Clients matches yours
422 "Question N has no rubric" on grade/uploadRubric set doesn't cover every SUBJECTIVE questionPUT /assignments/{id}/rubrics with full coverage, or embed rubrics in questions
422 "MCQ with no complete correct answers" on grade (v1.2)MCQ created key-less (content door) and never completedPUT /assignments/{id}/questions with the answers, or POST …/bulk-generate
/papers/extract takes forever / your client 504s (v1.2)Large papers run in 15-page batches — the call legitimately takes tens of minutesClient (and any proxy of yours) read timeout ≥ 30 min; the platform's allows 50
502 from /papers/extract (v1.2)Our AI side unavailable or exhausted retriesRetry later with backoff — the paper itself was accepted
400 "Template errors:\nLine N: …" from /assignments/from-file (v1.2)The Markdown violates FORMAT SPEC v1 (missing marks, duplicate id, bad rubric row, …)Fix the named lines and resend — nothing was created; spec is in Reference §4.6
bulk-generate FAILED (v1.2)The job's worker died mid-run (reconciled after 3 quiet minutes)Just start it again — already-filled questions are skipped
Upload 409 you didn't expectRetried a timed-out upload; student already queued/gradedRoster → find submission_id → poll it
Bulk upload 200 but a file shows status: "error" (v1.3)Per-file problem (not a PDF, >25 MB, duplicate filename in the batch, unresolvable identity, or the student already queued/graded)Read the item's message — the graded/in-flight case names the existing submission_id; fix that one file and re-send just it
Bulk item says "auto-grade refused: …" (v1.3)The answer key wasn't complete when the batch landedThe file IS stored (SUBMITTED) — fill the key, then POST /submissions/{id}/grade
400 "no stored page arrangement" on grade?mode=overrides (v1.3)No active arrangement on that submissionPUT /submissions/{id}/page-overrides first (or use existing-pages)
400/404 on GET …/export?format=zip (v1.3)400: test isn't OSM-enabled (no marked sheets exist) or > 100 evaluated submissions; 404: evaluated, but no annotated pages400-non-OSM: fetch /pdf instead; 400-cap: per-submission /marked-pdf; 404: check report.question_results[].annotated_pages
Results 409Status isn't EVALUATED yetPoll status first (§4)
413 on uploadBody over the ~100 MB proxy cap (25 MB per PDF still applies)Compress below 25 MB / split the batch
Scores look wrong / all zeroRubric doesn't match what the student actually wrote, or wrong paper scannedCheck report.question_results[].criteria_results[].feedback; fix rubrics; re-grade
Stuck PROCESSING > 10 minRare — worker hiccupKeep a slow poller; if >30 min, contact support with X-Request-Id
Webhook never arrivesWrong URL, event not subscribed, or your endpoint failed 5 attempts (1 m → 5 m → 30 m → 2 h)GET /webhooks (url + events), GET /webhook-deliveries?status=dead (last_http_status, last_error), fix, then POST …/retry
"Signature verification failed" on a webhookVerifying against re-serialized JSON, stale secret after a rotation, or clock skew > 5 minVerify the raw body bytes with t + "." + body (Reference §6.3); reload the current secret; check NTP
400 on /marked-pdfTest wasn't created with osm_enabledPATCH /assignments/{id} with {"osm_enabled": true}, then re-queue the submission
404 on /marked-pdfEvaluated OSM test, but the report has no annotated pagesCheck report.question_results[].annotated_pages; if expected, contact support with X-Request-Id
/pdf answers 302 and your client can't follow itClient disabled redirect-following/pdf (and every URL in /pages) points at 1-hour presigned storage URLs — follow the redirect

Reference doc: partner-api-reference.md — the normative contract. If this guide and the reference ever disagree, the reference wins; tell us.


BigChalkBox Partner API — Reference (v1.3)

The public, server-to-server surface for platforms that resell BigChalkBox: create tests (by JSON, by paper PDF, or by Markdown template), upload student answer sheets, and get AI-graded results back — as structured JSON, as the original files, and as marked answer sheets.

  • Base URL: https://api.bigchalkbox.com/partner/v1
  • Auth: API key (§1)
  • Interaction style: synchronous REST; grading completes asynchronously — track it by signed webhooks (§6) or by polling
  • Interactive docs: the Swagger UI is not exposed publicly — this document is the contract. Companion docs: Integration Guide (architecture, go-live checklist) and partner-api-openapi.json (machine-readable spec).
  • Service: BigChalkBox Partner API — support details in §11

v1.3 (2026-09-23) adds the ops surface (§4.2, §4.3, §4.4): GET /assignments/{id}/analytics (score bands, problem areas, dashboard counts), GET /assignments/{id}/export?format=xlsx|zip (server-built results roster / marked-PDF bundle), POST /assignments/{id}/submissions/ bulk (batch upload with per-file report), the page-rearrangement pair (GET /submissions/{id}/page-layout, PUT /submissions/{id}/page-overrides), and re-grade modes on POST /submissions/{id}/grade?mode= (full | existing-pages | overrides).

v1.2 (2026-09-23) adds the content door (§4.6): POST /papers/extract (question-paper PDF → structured questions), POST /assignments/from-file (Markdown paper template — FORMAT SPEC v1), and POST /assignments/{id}/bulk-generate (202 + poll — AI fills missing model answers, rubrics and MCQ answers). The create contract is split: the PAPER (text, marks, MCQ options) is required at create time; the ANSWER KEY is required at grade time (§3). AI configuration (extraction engine, grading provider) stays server-managed — there is no client-facing engine or provider parameter.

v1.1 (2026-09-23) adds: webhooks (5 endpoints, §6), results & files (/pdf, /pages, /marked-pdf, §4.5), and the osm_enabled test flag (§4.2). Everything in v1 is unchanged unless marked below. Example payloads in this document were captured from the live API on 2026-09-23.


Endpoint index (31 routes)

GroupMethod & pathPurpose
AccountGET /healthAuthenticated smoke check — prove the key works
GET /meClient identity + service account + capabilities
GET /usageMetering snapshot (plan + quota counters)
TestsPOST /assignmentsCreate a test (questions + rubrics, optional osm_enabled) → 201
GET /assignmentsList my tests with live submission counts
GET /assignments/{id}Full test detail (questions, rubrics)
PATCH /assignments/{id}Partial update (title, subject, instructions, deadline, status, osm_enabled)
DELETE /assignments/{id}Delete a test + its stored PDFs → 204
PUT /assignments/{id}/questionsFull replace of the question set
PUT /assignments/{id}/rubricsFull replace of the rubric set
GET /assignments/{id}/analyticsv1.3 — score bands, problem areas, dashboard counts
GET /assignments/{id}/exportv1.3 — server-built ?format=xlsx (results roster) or zip (marked PDFs)
SubmissionsPOST /assignments/{id}/submissionsUpload one student's answer-sheet PDF, optionally auto-grade → 201
POST /assignments/{id}/submissions/bulkv1.3 — batch upload (up to 50 PDFs) with a per-file report → 200
GET /assignments/{id}/submissionsRoster / gradebook for one test (paginated)
GradingPOST /submissions/{id}/gradeQueue (or re-queue) grading, ?mode=full (default) | existing-pages | overrides (v1.3) → 202
GET /submissions/{id}Submission status — the poll endpoint
GET /submissions/{id}/page-layoutv1.3 — the pages a re-grade can rearrange (presigned URLs)
PUT /submissions/{id}/page-overridesv1.3 — store/reset a manual page arrangement
Results & filesGET /submissions/{id}/resultsFull graded result (per-question report)
GET /submissions/{id}/pdfThe submitted sheet — 302 to a 1-hour presigned URL
GET /submissions/{id}/pagesGraded page images grouped by question, presigned URLs
GET /submissions/{id}/marked-pdfv1.1 — the OSM-marked answer sheet as one PDF (requires osm_enabled)
Content door (v1.2)POST /papers/extractQuestion-paper PDF → structured questions (AI; long-running call)
POST /assignments/from-fileCreate a test from a Markdown paper template (deterministic, zero-LLM)
POST /assignments/{id}/bulk-generateAI fills missing model answers / rubrics / MCQ answers → 202 + poll
Webhooks (v1.1)POST /webhooksRegister/replace the webhook subscription (secret shown once)
PATCH /webhooksPartial update (url / events / rotate secret)
GET /webhooksCurrent subscription (never returns the secret)
GET /webhook-deliveriesDelivery log — statuses, attempts, last error
POST /webhook-deliveries/{id}/retryRe-queue a dead/pending delivery immediately

Plus an unauthenticated GET https://api.bigchalkbox.com/health load-balancer probe (§4.1).


Quick start


bash
export BCB_API_KEY="bcbk_..."   # your key, issued by a BigChalkBox admin

BASE=https://api.bigchalkbox.com/partner/v1

# 1. Prove the key works
curl -s -H "X-API-Key: $BCB_API_KEY" $BASE/health
# {"status": "ok", "client": {"id": 1, "name": "DoonTeddies"}}

# 2. (v1.1) Subscribe to webhooks — the secret is shown exactly once
curl -s -X POST $BASE/webhooks -H "X-API-Key: $BCB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://your-platform.example/hooks/bigchalkbox"}'
# {"url": "...", "events": ["submission.received", "grading.completed", "grading.failed"],
#  "secret": "bcbwh_...", "updated_at": "..."}   ← store the secret in your secrets manager

# 3. Create a test — hand-built JSON (below), or via the content door (§4.6):
#    POST /papers/extract (paper PDF → questions JSON) then this, or
#    POST /assignments/from-file (Markdown paper template, one call).
#    Add "osm_enabled": true if you want the marked answer sheets back.
curl -s -X POST $BASE/assignments -H "X-API-Key: $BCB_API_KEY" \
  -H "Content-Type: application/json" -d '{ ...questions... }'        # → {"id": 42, ...}

# 4. Upload answer sheets — one by one, or a whole batch (§4.3):
curl -s -X POST $BASE/assignments/42/submissions -H "X-API-Key: $BCB_API_KEY" \
  -F file=@student.pdf -F student_email=riya@example.edu              # → {"submission_id": "...", "status": "QUEUED"}
curl -s -X POST $BASE/assignments/42/submissions/bulk -H "X-API-Key: $BCB_API_KEY" \
  -F files=@a.pdf -F files=@b.pdf -F students='{"a.pdf": {"email": "a@x.edu"}, "b.pdf": {"email": "b@x.edu"}}'
# → {"total": 2, "successful": 2, "failed": 0, "results": [{"filename": "a.pdf", "status": "success", ...}]}

# 5. When your webhook fires grading.completed (or you poll /submissions/$SID):
curl -s -H "X-API-Key: $BCB_API_KEY" $BASE/submissions/$SID/results   # structured report
curl -sL -H "X-API-Key: $BCB_API_KEY" $BASE/submissions/$SID/marked-pdf -o marked.pdf   # visual sheet (osm tests)

# 6. When the cohort is graded — dashboard numbers and files (§4.2):
curl -s -H "X-API-Key: $BCB_API_KEY" $BASE/assignments/42/analytics   # bands, problem areas, counts
curl -sL -H "X-API-Key: $BCB_API_KEY" "$BASE/assignments/42/export?format=xlsx" -o results.xlsx
curl -sL -H "X-API-Key: $BCB_API_KEY" "$BASE/assignments/42/export?format=zip"  -o marked.zip   # osm tests

1. Authentication

Every /partner/v1 request needs a key, sent either way (send only one; X-API-Key wins if both are present):

X-API-Key: bcbk_Xk9fT2mQ...
Authorization: Bearer bcbk_Xk9fT2mQ...

Key facts:

  • Keys look like bcbk_ followed by ~32 URL-safe characters. Only the bcbk_ prefix (first 12 chars) is ever displayed after issuance — the full raw key is shown exactly once at creation/rotation; the platform stores only its SHA-256 hash. If a key is lost, the only option is rotation, which immediately invalidates the old one.
  • Keys are issued and managed by BigChalkBox admins in the teacher-web admin UI (Admin → API Clients) — there is no self-service signup yet.
  • A key is tied to one service account (api-client-<name-slug>@api.internal); everything the key creates (assignments, submissions, students) belongs to that account and is invisible to every other key. Object references from another tenant answer 404, never 403, so existence is not leaked across tenants.
  • Rotating or disabling a key is an admin action in the same admin UI. Deactivation takes effect immediately on the next request.
  • Webhook secrets (§6) follow the same show-once discipline: returned only by the POST/PATCH call that set or rotated them, never by reads.

Auth failures are always:

  • Missing key → 401 "Missing API key."
  • Unknown key or deactivated client → 401 "Invalid or inactive API key."

Sending an Authorization header with any non-Bearer scheme (e.g. Basic) is treated as no key at all.


json
{"error": {"code": "unauthorized", "message": "Invalid or inactive API key."}}

2. Conventions

Error envelope

Every /partner/* error is a single JSON shape — FastAPI's {"detail": ...} default is replaced on this surface:

details is present on 422 responses only (the pydantic field errors).

HTTPcodeTypical cause
400bad_requestSemantic problem with an otherwise well-formed request (empty title, PDF limit, closed-assignment upload, delete of an ACTIVE test, invalid webhook URL, non-PDF paper, template parse errors — line-numbered)
401unauthorizedMissing / invalid / inactive API key
403forbiddenReserved (not currently returned by any route)
404not_foundAssignment/submission/delivery doesn't exist or belongs to another key; webhook read before POST /webhooks
409conflictDuplicate in-flight/graded submission; grading while QUEUED/PROCESSING; results before EVALUATED; retrying an already-delivered webhook; starting bulk-generate while a job is running
413—Request body over ~100 MB, rejected by the proxy (large-file version of the 25 MB per-PDF app limit; raised from ~30 MB in v1.3 for bulk uploads)
422validation_failedBody failed schema validation (details lists the field errors), or a grading precondition failed (SUBJECTIVE without rubric, MCQ without complete correct answers)
429rate_limitedRate limit exceeded (see below)
5xxinternal_errorUnexpected server failure — retry with backoff; if it persists, quote the X-Request-Id
json
{
  "error": {
    "code": "conflict",
    "message": "A submission for this student already exists and is QUEUED; ...",
    "details": { }
  }
}

Request ID

Every response carries X-Request-Id — the value you sent, or a generated one. Send your own (e.g. a UUID per logical operation) to correlate a request across retries, and quote it in support requests: it joins your logs to ours.

Rate limiting

nginx enforces 20 requests/second sustained per key, with a burst of 40 (burst=40 nodelay; keyless requests are limited per-IP instead). Exceeding it returns 429 with the same error envelope above, generated at the proxy — the request never reaches the application, and no Retry-After header is sent today, so back off (e.g. 1 s, 2 s, 4 s…) on 429. Webhook delivery from us to you is not part of this limit (it originates from our servers, not your key).

Timestamps

All timestamps are ISO 8601 in UTC. Two serializations appear depending on the endpoint — 2026-09-05T12:11:43.864167Z (most endpoints) and 2026-09-05T12:09:15.333561+00:00 (GET /me). They are equivalent; parse with a timezone-aware ISO-8601 parser and never assume a wall-clock local time.

Pagination

The only paginated endpoint is the roster, GET /assignments/{id}/submissions:

  • limit — page size, default 50, max 100; outside 1..100 → 400.
  • cursor — opaque token from the previous page's next_cursor. Do not parse or construct cursors; a corrupt one → 400.
  • Ordering is newest first, by (submitted_at, id) descending — stable under concurrent inserts (keyset pagination; page boundaries never shift while you walk).
  • End of data: next_cursor is null. An empty page returns {"items": [], "next_cursor": null}.

Every other list endpoint (GET /assignments, GET /webhook-deliveries) is unpaginated (webhook-deliveries takes a limit up to 100, newest first).


3. Grading lifecycle

flowchart TD
    classDef pdf fill:#f8fafc,stroke:#94a3b8,stroke-width:2px,color:#0f172a,rx:5px,ry:5px;
    classDef submitted fill:#e2e8f0,stroke:#94a3b8,stroke-width:2px,color:#0f172a,rx:5px,ry:5px;
    classDef queued fill:#e0f2fe,stroke:#38bdf8,stroke-width:2px,color:#0369a1,rx:5px,ry:5px;
    classDef proc fill:#fef3c7,stroke:#fbbf24,stroke-width:2px,color:#b45309,rx:5px,ry:5px;
    classDef eval fill:#ecfdf5,stroke:#34d399,stroke-width:2px,color:#047857,rx:5px,ry:5px;
    classDef err fill:#ffe4e6,stroke:#fb7185,stroke-width:2px,color:#be123c,rx:5px,ry:5px;
    classDef done fill:#f1f5f9,stroke:#64748b,stroke-width:2px,color:#0f172a;

    sub[📄 Answer Sheet PDF]:::pdf -- "POST /assignments/{id}/submissions <br/> (auto_grade=true)" --> queued[QUEUED]:::queued
    sub -- "POST /assignments/{id}/submissions <br/> (auto_grade=false)" --> submitted[SUBMITTED]:::submitted
    
    queued -- "submission.received webhook" --> queued
    queued -- "grading worker claims it" --> proc[PROCESSING]:::proc
    
    submitted -- "POST /submissions/{id}/grade" --> queued
    
    proc --> eval[EVALUATED]:::eval
    proc --> err[ERROR]:::err
    
    eval -- "grading.completed webhook <br/> (or poll GET /submissions/{id})" --> done((Done)):::done
    err -- "grading.failed webhook <br/> (or poll GET /submissions/{id})" --> done

Status values

StatusMeaningtotal_score/max_scoreevaluated_aterror_log
SUBMITTEDPDF stored, grading not requested yetnullnullnull
QUEUEDWaiting for the grading workernullnullnull
PROCESSINGWorker is grading right nownullnullnull
EVALUATEDDone — fetch /results, /pages, /marked-pdfsetsetnull (usually)
ERRORGrading failednullnullreason

(NOT_SUBMITTED exists internally for UI-only flows; it is never produced through this API.)

Lifecycle rules:

  • QUEUED → PROCESSING → EVALUATED | ERROR is driven entirely by the grading worker polling the queue; there is nothing to call to advance it.
  • Completion signal: you get grading.completed / grading.failed webhooks if you've subscribed (§6) — that is the intended primary signal. Polling GET /submissions/{id} every 2–5 s remains fully supported as a fallback (it is far below the rate limit). A typical test grades in tens of seconds to a few minutes depending on page count and question count.
  • EVALUATED → total_score / max_score / evaluated_at are set; fetch the per-question detail via GET /submissions/{id}/results and the files via §4.5.
  • ERROR → error_log carries the reason (also surfaced in the roster and on the status endpoint). Fix the cause (usually a rubric/coverage problem) and re-queue with POST /submissions/{id}/grade — regrading clears error_log.
  • Regrading an EVALUATED submission (e.g. after the rubrics were improved, or after flipping osm_enabled) resets report, total_score, max_score, evaluated_at, and error_log, then re-queues. Each completed grading run fires its own grading.completed webhook. Grading is never in-place while QUEUED/PROCESSING — a second grade call while in flight is 409.

Grading preconditions

A submission can only be queued when every question is covered (v1.2 — the answer-key requirement moved here, out of create-time):

  • every SUBJECTIVE question has a rubric (via PUT /assignments/{id}/rubrics, embedded in the question set, or filled by POST /assignments/{id}/bulk-generate), and
  • every MCQ has complete correct_answers — top-level, or on every option-bearing sub_question.

Violating either is 422, e.g.:

MCQ-only assignments need no rubrics at all — MCQs are matched against correct_answers. This split is what lets the content door (§4.6) create key-less tests: they are legal to create and upload to, and the gate simply refuses to queue grading until the key exists.

json
{"error": {"code": "validation_failed",
           "message": "Question 3 has no rubric; grading it would score against empty criteria. Set rubrics via PUT /assignments/{id}/rubrics first."}}
json
{"error": {"code": "validation_failed",
           "message": "Question 2 is an MCQ with no complete correct answers; grading it would score against nothing. Complete them via PUT /assignments/{id}/questions or POST /assignments/{id}/bulk-generate first."}}

OSM (on-screen marking) — new in v1.1

Create the test with "osm_enabled": true and the grader additionally annotates the student's own pages — ticks/crosses per rubric criterion and a handwritten-style examiner remark — while it grades. The annotated sheets are packed into GET /submissions/{id}/marked-pdf (one PDF, score badge on page 1). Default is false (plain JSON scoring — cheaper, and what most integrations want). Toggling the flag after grading only affects future grading runs: re-queue a submission to re-grade it with the new setting.

Re-grade modes (v1.3) — POST /submissions/{id}/grade?mode=

Re-grading an EVALUATED submission comes in three modes:

modeWhat runsWhen to use
full (default)The whole pipeline again: PDF → page images → classify → segment → grade. Resets the current report/scores.After changing the assignment (questions, rubrics, osm_enabled), or after an ERROR.
existing-pagesRe-grades every question from the page images the last run already produced — no PDF re-render, no re-classification, no re-segmentation. The report is kept until the new run replaces it.The fast option: e.g. after a rubric or model-answer tweak where the pages are already correct.
overridesRe-grades only the questions whose pages you rearranged via PUT /submissions/{id}/page-overrides, then bakes the arrangement into storage (the pages become the arrangement; the stored override is cleared and page-layout reports override: null afterwards).After fixing a mis-scanned sheet — move a page to the right question and re-grade just the affected ones.

Semantics:

  • Fast modes (existing-pages, overrides) require the submission to be EVALUATED with a report — 400 otherwise. overrides additionally requires a stored, active page arrangement — 400 if there is none.
  • While a run is in flight (QUEUED/PROCESSING) the fast modes are idempotent 202 (no-op); full keeps the v1.2 behavior of 409.
  • The §3 grading preconditions still apply to every mode (422 if the answer key was removed since the last run) — a re-grade can never silently score against nothing.
  • Unchanged questions in a fast mode keep their existing results; only what the run re-grades is replaced. Page-usage metering is not repeated in fast modes (pages were metered at first grading).
  • Every completed re-grade fires its own grading.completed webhook, and /analytics + /export reflect the new scores as soon as it lands.

4. Endpoints

All routes are relative to https://api.bigchalkbox.com/partner/v1. Every example below assumes the key in an env var:

bash
export BCB_API_KEY="bcbk_..."

4.1 Health & account

GET /health — authenticated smoke check

Proves the key works. Use this (not the bare probe below) in integration tests.

GET /me

Client identity plus the service account it acts as. capabilities documents the surface — v1.1 keys are unscoped (every key can do everything).

capabilities is a description of the surface, not a gate — v1.1 keys are unscoped and can also use the results/file endpoints (§4.5) and webhooks (§6); the list will be extended in a future version.

GET /usage

Metering snapshot for the client's service account (plan + usage counters). Read-only today — enforcement on partner routes lands in a later phase; this lets platforms meter what they resell before that. limit: null means unlimited (subscribed accounts); limits can also be per-account admin overrides, so treat them as authoritative rather than assuming any default:

GET /health on the bare host (no auth, outside /partner/v1)

GET https://api.bigchalkbox.com/health → {"status": "healthy", "service": "partner-api"} for load balancers / uptime monitors. It uses FastAPI's default response shape and requires no key.

bash
curl -s -H "X-API-Key: $BCB_API_KEY" https://api.bigchalkbox.com/partner/v1/health
json
{"status": "ok", "client": {"id": 1, "name": "Example Partner"}}
json
{
  "client": {"id": 1, "name": "Example Partner", "key_prefix": "bcbk_Qk3xR7vNp2", "created_at": "2026-09-01T08:00:00+00:00"},
  "service_account": {"user_id": 41, "email": "api-client-example-partner@api.internal"},
  "capabilities": ["assignments:read", "assignments:write", "submissions:read",
                   "submissions:write", "grading", "roster:read", "usage:read"]
}
json
{
  "plan": "FREE",
  "quotas": {
    "tests": {"used": 1, "limit": 2},
    "moderations": {"used": 0, "limit": 1},
    "major_paper_generations": {"used": 0, "limit": 1}
  }
}

4.2 Assignments

A partner assignment is a test: questions + (for SUBJECTIVE) rubrics. API-created assignments are born ACTIVE and immediately accept submissions — there is no UI activation step.

POST /assignments → 201

Top-level body fields:

FieldTypeNotes
titlestringRequired, non-blank
subjectstring?
instructionsstring?Shown to students in your own UI; not rendered by us
questionsobject[]Required, ≥1 — schema below
osm_enabledbool?v1.1. Default false. true → the grader annotates the student's pages; GET /submissions/{id}/marked-pdf then returns the marked sheet (§4.5)

Errors: empty/whitespace title or no questions → 400; malformed body → 422 (envelope details lists field errors); question-level validation failures → 400 naming the offending question_number (see the rules below). Answer-key gaps (missing rubrics / MCQ answers) are not create errors anymore (v1.2) — they surface at grade time (§3).

Question schema (all questions):

FieldTypeNotes
question_numberstringRequired. Any label ("1", "2b", "Q3")
question_textstringRequired, non-blank. LaTeX is allowed and rendered in reports
max_marksnumberRequired, > 0
question_type"MCQ" | "SUBJECTIVE"Default "SUBJECTIVE"
section_labelstring?Optional grouping
or_groupstring?Questions sharing an or_group are alternatives — the student answers one of them; the grader picks the credited attempt and marks the others skipped_or_alternative in the report
options / correct_answersstring[]?MCQ only — options required at create (v1.2); correct_answers may be absent (fill via bulk-generate §4.6 or a later PUT)
sub_questionsobject[]?Raw passthrough for MCQ option sets (canonical storage shape)
model_answerstring?SUBJECTIVE reference answer
model_answers{text, rubric?}[]SUBJECTIVE, multiple accepted variants
rubric{criteria, max_marks}[]?SUBJECTIVE rubric, question-level

Validation rules (enforced at create and at PUT .../questions; split in v1.2 — the create-time rules check the PAPER, the grade-time gate (§3) checks the ANSWER KEY):

  • Every question needs question_text + positive max_marks (400).
  • MCQ needs options — top-level or inside sub_questions (400). correct_answers is not required here anymore.
  • SUBJECTIVE may be created with no model answer and no rubric (the content door's key-less papers are legal). When a rubric is present — question-level or embedded on model answers — it is materialized into rubrics_json.

Grading an uncovered question is refused with 422 naming it (§3) — nothing can ever grade silently against empty content.

GET /assignments — list mine

Newest first, with live submission counts. Unpaginated (returns all your tests):

GET /assignments/{id} — full detail

Adds instructions, questions_json (canonical stored shape), rubrics_json and — v1.2 — the bulk-generate job state (bulk_gen_status / bulk_gen_progress, null until POST …/bulk-generate is called; §4.6) to the summary fields:

While a job runs: "bulk_gen_status": "RUNNING", "bulk_gen_progress": {"current": 3, "total": 7, "label": "Q3", "mode": "all"}. This is the poll endpoint for POST …/bulk-generate. Unknown id or another key's test → 404.

PATCH /assignments/{id} — partial update

Send only the fields you're changing:

FieldNotes
titleempty/whitespace → 400
subject
instructions
deadlineISO 8601; a timezone-naive value is interpreted as IST (UTC+5:30). Metadata only — the API does not auto-close at the deadline
status"DRAFT" | "ACTIVE" | "CLOSED" — any transition is accepted; CLOSED stops new uploads (§4.3)
osm_enabledv1.1. Toggles on-screen marking for future grading runs; existing graded reports are untouched until re-queued

Unknown fields are ignored; UI-only toggles (require_secure_login, submission_mode) are not part of the external contract. Response is the full detail object (same shape as GET /assignments/{id}).

DELETE /assignments/{id} → 204

Deletes the test, its submissions, and the stored answer-sheet PDFs. An ACTIVE assignment is refused with 400 ("Close the assignment before deleting it.") so a stray call can't destroy a live collection target. CLOSED/DRAFT assignments delete fine. Repeating a successful delete → 404. Response body is empty.

PUT /assignments/{id}/questions — full replace

Same payload and validation as create. This replaces the entire question set, and rubrics are re-materialized together with it (question-level rubrics move with their questions — an explicit rubric update would otherwise be silently stale). Response is the full detail object. Use this to fix typos or restructure a test before submissions exist; changing questions after grading does not automatically re-grade — re-queue each submission with POST /submissions/{id}/grade.

PUT /assignments/{id}/rubrics — full replace

Stored as given. Coverage is not checked here — a partial set can be staged across calls; the check happens at grading time (§3). Response is the full detail object.

GET /assignments/{id}/analytics — v1.3

The headline numbers for a test's results page: cohort status counts, the score distribution, and the questions students struggled with most. No parameters.

Field notes:

  • Counts span all submissions of the test; pending is the residual (NOT_SUBMITTED + QUEUED).
  • score_distribution bands are integer-inclusive over EVALUATED rows (max_score > 0): a student at exactly 20.5% falls in no band, so the band counts can sum to less than evaluated_count.
  • average_score = mean percentage over EVALUATED rows, 1 decimal, or null when nothing is evaluated yet (a genuine 0.0 average is reported as 0.0, not null).
  • problem_areas = the questions in the 3 worst distinct average-percentage tiers (a tie at the cutoff keeps every tied question, capped at 10). OR-group alternatives the student did not attempt are excluded — they can't be misread as "everyone failed Q5b". Empty list when nothing is evaluated (or the reports carry no per-question rows).
  • Per-student detail is not here — that's the roster (GET /assignments/{id}/submissions) plus GET /submissions/{id}/results.

GET /assignments/{id}/export?format= — v1.3 (S1=B)

The server builds the file; you download it. Content-Disposition: attachment on both. Unknown ids / another key's test → 404; a test with no submissions (or only ungraded ones) → 400.

formatFileContents
xlsx (default)<Test Title>_Results.xlsxThe full results roster, one row per student who submitted: Student Name, Student ID (email local-part, falling back to enrollment number), one Q<n> column per question seen across the cohort (natural order — Q2 before Q10) carrying marks obtained (0 when the question graded to a null score, the string NA when the student has no result row for it), and Total Score (NA while ungraded). Rows are newest-submission-first, sheet name Results. This matches the teacher UI's Excel export column-for-column.
zip<Test Title>_Marked_PDFs.zipOne marked PDF per EVALUATED submission (members named report_<Student_Name>_<id-prefix>.pdf), the same files as GET /submissions/{id}/marked-pdf. OSM tests only — 400 on a non-OSM test (there are no marked sheets to pack). Submissions without annotated pages are skipped; if none have them → 404. Hard cap: 100 evaluated submissions per zip (400 above it — fetch per-submission instead).

File-name titles are sanitized (whitespace runs → _, filename-hostile characters → _), so Midterm — Physics 101 becomes Midterm_—_Physics_101_Results.xlsx.

bash
curl -s -X POST https://api.bigchalkbox.com/partner/v1/assignments \
  -H "X-API-Key: $BCB_API_KEY" -H "Content-Type: application/json" \
  -d '{
    "title": "Midterm — Physics 101",
    "subject": "Physics",
    "instructions": "Answer all questions.",
    "osm_enabled": true,
    "questions": [
      {
        "question_number": "1",
        "question_text": "State Newton'\''s second law.",
        "max_marks": 5,
        "question_type": "SUBJECTIVE",
        "model_answer": "F = ma, force equals mass times acceleration.",
        "rubric": [{"criteria": "States F = ma", "max_marks": 5}]
      },
      {
        "question_number": "2",
        "question_text": "The SI unit of force is:",
        "max_marks": 1,
        "question_type": "MCQ",
        "options": ["newton", "joule", "watt", "pascal"],
        "correct_answers": ["newton"]
      }
    ]
  }'
json
{"id": 42, "title": "Midterm — Physics 101", "status": "ACTIVE", "num_questions": 2, "created_at": "2026-09-22T20:01:28.158941Z"}
json
[{"id": 42, "title": "Midterm — Physics 101", "subject": "Physics", "status": "ACTIVE", "num_submissions": 7, "created_at": "2026-09-05T10:00:00+00:00"}]
json
{
  "id": 42, "title": "Midterm — Physics 101", "subject": "Physics", "status": "ACTIVE",
  "num_submissions": 7, "created_at": "2026-09-05T10:00:00+00:00",
  "instructions": "Answer all questions.",
  "questions_json": {"questions": [ ...as stored... ]},
  "rubrics_json": [{"question_number": "1", "criteria": [{"criteria": "States F = ma", "max_marks": 5.0}]}],
  "bulk_gen_status": null,
  "bulk_gen_progress": null
}
json
{"rubrics": [{"question_number": "1", "criteria": [{"criteria": "States F = ma", "max_marks": 5}]}]}
json
{
  "total_submissions": 24,
  "submitted_count": 2,
  "evaluated_count": 20,
  "pending_count": 1,
  "processing_count": 1,
  "error_count": 0,
  "score_distribution": [
    {"label": "0-20%", "count": 1}, {"label": "21-40%", "count": 4},
    {"label": "41-60%", "count": 7}, {"label": "61-80%", "count": 5},
    {"label": "81-100%", "count": 3}
  ],
  "problem_areas": [
    {"question_number": "3", "question_text": "Derive the lens formula…", "average_score_percentage": 31.5},
    {"question_number": "7", "question_text": "Explain total internal reflection…", "average_score_percentage": 44.0}
  ],
  "average_score": 58.2
}
bash
curl -sL -H "X-API-Key: $BCB_API_KEY" \
  "https://api.bigchalkbox.com/partner/v1/assignments/42/export?format=xlsx" -o results.xlsx
curl -sL -H "X-API-Key: $BCB_API_KEY" \
  "https://api.bigchalkbox.com/partner/v1/assignments/42/export?format=zip"  -o marked_sheets.zip

4.3 Submissions

POST /assignments/{id}/submissions → 201

Upload one student's answer sheet as a single PDF (≤ 25 MB, filename must end .pdf) and optionally queue it for grading in the same call. Content-Type must be multipart/form-data.

Form fieldRequiredNotes
fileyesPDF only; > 25 MB → 400 (a body over ~100 MB is cut off earlier by the proxy with 413)
student_emailyesMust contain @; lowercased on store. The student record is upserted by this email (name/enrollment updated on re-upload)
student_namenoDefaults to enrollment number, then the email local-part
enrollment_numbernoStored on the student record
auto_gradenoDefault true — queue for grading immediately. Send the strings "true"/"false"

Behavior details:

  • auto_grade=true validates rubric coverage first: a missing rubric → 422. Nothing is committed — no submission row, no student record is created — so fixing the rubrics and re-sending the same upload is clean.
  • auto_grade=false stores as SUBMITTED; grade later via §3 / §4.4.
  • Closed assignments reject uploads outright — 400 ("This assignment is CLOSED and no longer accepts submissions."). Closing (PATCH → status: "CLOSED") is how you stop a collection target; reopening (PATCH back to ACTIVE) re-opens it. Regrading submissions that already exist stays allowed after a close.
  • The student record is created/updated on every accepted upload — a later upload for the same email with a different name updates the name.
  • v1.1: an accepted upload fires a submission.received webhook (§6) in the same transaction as the store — it cannot be lost.

Duplicate handling — one submission row per (assignment, student):

  • Existing row is QUEUED/PROCESSING/EVALUATED → 409, and the PDF is not uploaded (retry storms don't orphan files in storage). Poll the existing submission_id instead of re-uploading.
  • Existing row is SUBMITTED/ERROR (or has no file) → the row is reused: same submission_id, new PDF replaces the old one, status resets to SUBMITTED (or QUEUED with auto_grade=true). The response still says 201.

Use the 409 contract as your de-facto idempotency mechanism: if you aren't sure an upload landed (e.g. a timeout), re-send it — either you get a fresh 201, or a 409 pointing at work already in flight.

POST /assignments/{id}/submissions/bulk — v1.3 (batch upload)

Upload a whole batch of answer sheets in one request — the scanned-photos workflow. Content-Type: multipart/form-data with a repeated files field (1–50 PDFs, ≤ 25 MB each; the request body cap is ~100 MB, so batch full-size scans into a few requests).

Per-file identity — two ways, the explicit map wins:

Form fieldRequiredNotes
studentsone of these twoJSON object mapping each uploaded filename (exact match, all files) to {"email": "...", "name"?: "...", "enrollment_number"?: "..."}. Emails are lowercased; unknown/mismatched keys → 400 naming the missing/unknown filenames
email_domainone of these twoYour students' email domain, e.g. students.acme.edu. Filenames are then parsed as [ID]_[First]_[Last]_[extra].pdf: ID becomes ID@your_domain, the non-numeric name parts are joined as the student's name (J123_John_Doe_15.pdf → j123@students.acme.edu, "John Doe" — trailing counters like _15 are dropped). An ID that already contains @ (a full email in the filename) is used as-is, no domain glued on
auto_gradenoDefault true (strings "true"/"false") — same semantics as the single upload

Either field missing → 400. More than 50 files → 400. A CLOSED assignment → 400 for the whole request.

Response is always 200 with a per-file report — individual failures never fail the request:

Per-file outcomes:

  • success + submitted_status: "QUEUED" — stored and auto-graded (the normal case).
  • success + submitted_status: "SUBMITTED" — stored, but auto_grade was off or the answer key isn't grade-ready yet (§3 gate). The file is safe; queue it later with POST /submissions/{id}/grade. (Unlike the single upload's 422, a batch gate-refusal does not discard the upload.)
  • error — not a PDF; over 25 MB; duplicate filename inside this batch (both copies are flagged — a JSON map can't carry two identities per filename); unresolvable identity; or an existing submission that is QUEUED/ PROCESSING/EVALUATED (the submission_id in the item points at it).
  • An existing SUBMITTED/ERROR row is replaced — same submission_id, the new PDF overwrites the old one in place.

Each accepted file stores its student record (upsert by email), fires its own submission.received webhook in the same transaction as the store (§6), and — when auto-queued — flows through the ordinary grading lifecycle (§3).

GET /assignments/{id}/submissions — roster / gradebook

Every student's status and score for one test. Newest first (§2 Pagination). The per-question report is deliberately not included — fetch it per submission.

A null next_cursor ends the walk. page_count is present once the grader has inspected the PDF.

bash
curl -s -X POST https://api.bigchalkbox.com/partner/v1/assignments/42/submissions \
  -H "X-API-Key: $BCB_API_KEY" \
  -F "file=@student_answer_sheet.pdf" \
  -F "student_email=riya.kapoor@example.edu" \
  -F "student_name=Riya Kapoor" \
  -F "enrollment_number=ENR-2026-114" \
  -F "auto_grade=true"
json
{"submission_id": "6f0c9a2e-3b1d-4c8a-9e2f-7a1b2c3d4e5f", "status": "QUEUED", "student_email": "riya.kapoor@example.edu", "auto_grade": true}
json
{
  "total": 3, "successful": 2, "failed": 1,
  "results": [
    {"filename": "J123_John_Doe_15.pdf", "status": "success", "message": "Uploaded successfully.",
     "student_email": "j123@students.acme.edu", "submission_id": "6f0c9a2e-...", "submitted_status": "QUEUED"},
    {"filename": "scan_0042.pdf", "status": "success",
     "message": "Uploaded as SUBMITTED; auto-grade refused: Question 1 has no rubric; …",
     "student_email": "scan.0042@students.acme.edu", "submission_id": "ab1d-...", "submitted_status": "SUBMITTED"},
    {"filename": "J456_Any_One.pdf", "status": "error",
     "message": "Existing submission is EVALUATED and cannot be replaced while in flight or graded; re-grade via POST /submissions/{id}/grade instead.",
     "student_email": "j456@students.acme.edu", "submission_id": "cd2e-...", "submitted_status": null}
  ]
}
bash
# explicit map
curl -s -X POST $BASE/assignments/42/submissions/bulk -H "X-API-Key: $BCB_API_KEY" \
  -F "files=@a.pdf" -F "files=@b.pdf" \
  -F 'students={"a.pdf": {"email": "a@example.edu", "name": "Asha A"}, "b.pdf": {"email": "b@example.edu"}}'

# naming convention
curl -s -X POST $BASE/assignments/42/submissions/bulk -H "X-API-Key: $BCB_API_KEY" \
  -F "files=@J123_John_Doe_15.pdf" -F "files=@J456_Any_One_2.pdf" \
  -F "email_domain=students.acme.edu" -F "auto_grade=true"
bash
curl -s -H "X-API-Key: $BCB_API_KEY" \
  "https://api.bigchalkbox.com/partner/v1/assignments/42/submissions?limit=2"
json
{
  "items": [
    {"submission_id": "6f0c9a2e-...", "status": "EVALUATED", "student_email": "riya.kapoor@example.edu",
     "student_name": "Riya Kapoor", "student_enrollment_number": "ENR-2026-114",
     "total_score": 4.5, "max_score": 6.0, "page_count": 2, "error_log": null,
     "submitted_at": "2026-09-05T10:02:00+00:00", "evaluated_at": "2026-09-05T10:03:41+00:00"},
    {"submission_id": "ab1d...", "status": "ERROR", "student_email": "arjun@example.edu",
     "student_name": "Arjun", "student_enrollment_number": null,
     "total_score": null, "max_score": null, "page_count": 1,
     "error_log": "segmentation failed: ...", "submitted_at": "2026-09-05T10:01:00+00:00", "evaluated_at": null}
  ],
  "next_cursor": "WyIyMDI2LTA5LTA1VDEwOjAxOjAwKzAwOjAwIiwiYWIxZC4uLiJd"
}

4.4 Grading

POST /submissions/{submission_id}/grade → 202

Queue (or re-queue) for grading. Response is the current status object (status: "QUEUED", all result fields reset to null).

?mode= (v1.3) — full (default, backward-compatible) | existing-pages | overrides. Full semantics in §3 "Re-grade modes"; in short:

  • full — the whole pipeline again; resets report/scores.
    • QUEUED/PROCESSING → 409 ("Submission is already QUEUED.")
    • Never uploaded a file → 400
    • Rubric coverage missing → 422 (names the offending question — §3)
    • Otherwise resets all result fields and queues: works from SUBMITTED, EVALUATED (re-grade after rubric improvements), or ERROR.
  • existing-pages / overrides — the fast modes; require EVALUATED with a report (400 otherwise) and keep the current report until the new run replaces it. overrides also requires a stored page arrangement (PUT …/page-overrides, below). While in flight they are idempotent 202 (a full call stays 409). The §3 answer-key gate still applies (422).

GET /submissions/{submission_id} — status (the poll endpoint)

Poll until EVALUATED or ERROR — or wait for the grading.completed / grading.failed webhooks instead (§6).

GET /submissions/{submission_id}/page-layout — v1.3

The page-rearrangement payload: which page images exist for a graded submission, and any stored manual arrangement. Useful after a messy scan — see if a page landed under the wrong question before re-grading.

  • originals — full pages that ended up in no question directory (the un-bifurcated remainder).
  • by_question — the grader's per-question page crops, grouped by directory (q1, q2a, qMCQs, …); annotated renders are excluded (they are display outputs, never grading inputs).
  • Every url is a 1-hour presigned GET — fetch the image directly, no second API call.
  • override — the stored arrangement (below) when one is active, else null.
  • Before any grading run the lists are empty (pages only exist once the grader has bifurcated the sheet).

PUT /submissions/{submission_id}/page-overrides — v1.3

Store (or reset) a manual page arrangement: which pages each question should be graded from. This is a view — nothing is moved until you re-grade with ?mode=overrides, which re-grades the affected questions from your order and then bakes the arrangement into storage (after which override reads null again and the pages simply are the arrangement).

Validation (all 400, every problem in one message): a question number must belong to the assignment or to a directory the grader produced; every path must exist in this submission's live page listing (no traversal, no foreign keys); ≤ 20 images per question; enabled: true needs at least one entry. Response is {"override": {...}} or {"override": null} on reset.

The typical repair flow: GET …/page-layout → see page X sits under the wrong question → PUT …/page-overrides with X moved → POST …/grade?mode= overrides → poll → done. Only the moved questions are re-graded (and re-metered for AI, not for pages); everyone else's results are untouched.

bash
curl -s -X POST -H "X-API-Key: $BCB_API_KEY" \
  https://api.bigchalkbox.com/partner/v1/submissions/6f0c9a2e-.../grade
curl -s -X POST -H "X-API-Key: $BCB_API_KEY" \
  "https://api.bigchalkbox.com/partner/v1/submissions/6f0c9a2e-.../grade?mode=existing-pages"
json
{"submission_id": "6f0c9a2e-...", "status": "PROCESSING", "error_log": null,
 "total_score": null, "max_score": null,
 "submitted_at": "2026-09-05T10:02:00+00:00", "evaluated_at": null}
json
{
  "originals": [{"path": "page_001.png", "url": "https://...presigned..."}],
  "by_question": {
    "q1":  [{"path": "q1/page_001.png", "url": "https://...presigned..."}],
    "q2":  [{"path": "q2/page_001.png", "url": "https://...presigned..."}],
    "qMCQs": [{"path": "qMCQs/page_001.png", "url": "https://...presigned..."}]
  },
  "override": null
}
json
// body — send only the questions you changed; an empty list = "not attempted"
{"enabled": true,
 "questions": {
   "2": ["q2/page_001.png", "page_001.png"],
   "3": []
 }}

// reset (discards the stored arrangement)
{"enabled": false}
json
{"override": {"enabled": true, "updated_at": "2026-09-23T10:00:00+00:00", "questions": {"2": ["q2/page_001.png", "page_001.png"], "3": []}}}

4.5 Results & files

GET /submissions/{submission_id}/results — full result

409 unless status is EVALUATED ("Submission is not evaluated yet (status: QUEUED).").

GET /submissions/{submission_id}/pdf (v1.1)

The student's submitted sheet as stored. 302 to a 1-hour presigned URL on our object storage — follow the redirect (e.g. curl -L). There is nothing to save from the response body itself.

  • No stored file (e.g. the upload row exists but carries no PDF) → 404
  • Another key's submission → 404 (cross-tenant rule, §1)

GET /submissions/{submission_id}/pages (v1.1)

The graded page images, grouped by question directory (q1, q2a, qMCQs, …) — the same grouping the grader established, so your UI can show "page images for question N". Every entry carries its own 1-hour presigned URL (images are PNG/JPEG). Page order within a group is by page number (deterministic).

  • Ungraded submissions have no pages yet → {} (empty object), not an error.
  • For OSM tests these are the original crops; the annotated variants are what /marked-pdf packs (they are not listed separately).

GET /submissions/{submission_id}/marked-pdf (v1.1 — the returned sheet)

For tests created with osm_enabled: true: streams one PDF of the student's own pages with the grader's marks on them — per-criterion ticks/crosses and the examiner's remark, in the order the report presents the questions (the shared MCQ block first, then questions in report order), with the total-score badge stamped on page 1. Response headers:

The filename is derived from the student's stored name (alphanumerics only) + an id prefix — safe to use directly for downloads.

ConditionResponse
Not EVALUATED yet409 "Submission has not been evaluated yet"
Test was created without osm_enabled400 "This export is only available for tests created with osm_enabled=true"
Evaluated + OSM, but no annotated pages in the report404 "No annotated pages found for this submission"
Another key's submission404 (cross-tenant rule)

A single missing/corrupt annotated page does not sink the export — the remaining pages are packed and the PDF is returned.

json
{
  "submission_id": "6f0c9a2e-...", "status": "EVALUATED", "error_log": null,
  "total_score": 4.5, "max_score": 6.0,
  "submitted_at": "2026-09-05T10:02:00+00:00", "evaluated_at": "2026-09-05T10:03:41+00:00",
  "assignment_title": "Midterm — Physics 101",
  "student_email": "riya.kapoor@example.edu",
  "report": { ...see §5... }
}
bash
curl -sL -H "X-API-Key: $BCB_API_KEY" $BASE/submissions/$SID/pdf -o original.pdf
json
{
  "q1":  [{"id": 12, "page_number": 1, "url": "https://<bucket>.r2.cloudflarestorage.com/submissions/42/6f0c9a2e-.../pages/q1/page_001.png?X-Amz-...=..."}],
  "qMCQs": [{"id": 14, "page_number": 1, "url": "https://.../pages/qMCQs/page_001.png?X-Amz-...=..."}]
}
json
HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="report_Riya_Kapoor_6f0c9a2e.pdf"
bash
curl -sL -H "X-API-Key: $BCB_API_KEY" $BASE/submissions/$SID/marked-pdf -o marked_sheet.pdf

4.6 Content door (v1.2) — creating tests without hand-built JSON

Three endpoints replace manual question entry: extract questions from a scanned paper PDF, create a test from a Markdown template, and let AI fill in whatever the answer key is missing.

The recommended loop:

POST /papers/extract            paper PDF  → questions JSON (review it)
        or
POST /assignments/from-file     markdown template → test (one call)
                ↓
POST /assignments               create the test (extracted or adjusted questions)
                ↓
POST /assignments/{id}/bulk-generate   AI fills missing model answers / rubrics / MCQ answers
                ↓
upload + grade (the §3 gate refuses until every question is covered)

AI configuration for all three (extraction engine, generation provider) is managed by BigChalkBox server-side — there is no engine or provider parameter anywhere in these requests. What your tests are graded with, and how papers are read, can change under you at any time by design.

POST /papers/extract → 200 — paper PDF to structured questions

Send a question-paper PDF (≤ 25 MB, filename must end .pdf); the platform reads it with its AI pipeline and returns the structured questions. The response is the extraction payload passed through unchanged:

FieldNotes
questions[].q_numberint or string ("3a" for OR-sub-questions). Map it to question_number when you create the test
questions[].question_textLaTeX passes through as written
questions[].max_marksas printed on the paper
questions[].question_typeSUBJECTIVE | MCQ (extraction never emits the internal FILL_BLANKS)
questions[].sub_questionsMCQ sub-question groups: {sub_q_number, text, options, correct_answers} — correct_answers comes back null: extraction reads the paper, it doesn't know the key
questions[].options / correct_answersflat MCQs
page_found_onglobal 1-based page in the PDF
or_group, section_label, division_label, bloom_level, difficulty_level, co_mappingset when the paper implies them, else null
extraction_confidence0–1, the minimum across the paper's reading batches — below 1.0 is normal. Treat the output as a draft: review before creating the test

This is a long-running synchronous call — a large paper can take tens of minutes (reading happens in 15-page batches). Use a client read timeout of at least 30 minutes; the platform's proxy allows up to 50. There is no polling variant; the request simply stays open until the paper is read.

ConditionResponse
Not a PDF / > 25 MB / empty400
Upstream rejected the paper (unreadable, no pages)400 "Extraction failed: …"
Our AI side unavailable / exhausted retries / non-JSON upstream502 — retry later

POST /assignments/from-file → 201 — create a test from a Markdown template

Deterministic (zero-LLM) creation from a Markdown file. The template carries the paper (question text, marks, MCQ options) and, optionally, the answer key (model answers, rubrics, MCQ correct answers) in the same file. Send either a .md/.markdown file or the md_text form field — not both. Optional form fields title, subject, instructions override the file's # <title> / Subject: / Instructions: lines.

FORMAT SPEC v1 (paper template):

# Midterm — Physics 101            <- optional H1: the test title
Subject: Physics                    <- optional 'Subject: …' line
Instructions: Answer all questions. <- optional 'Instructions: …' line
   (only blanks, the H1 and those two keys may precede the first '##')

## Q1 [5 marks]                     <- question entry: id + marks (REQUIRED;
State Newton's second law.          [N] or (N marks), N a number or a/b fraction)
#### MODEL ANSWER                   <- optional (subjective); free text, LaTeX ok;
F = ma, force equals mass times     a bare '---' line separates OR variants;
acceleration.                       a body of exactly 'NONE' means no model answer
#### RUBRIC                         <- optional (subjective); pipe table;
| Criteria | Marks |                header + separator rows are skipped;
| --- | --- |                       marks: a number or an 'a/b' fraction
| States F = ma | 4 |

## Q2 [1 mark]                      <- flat MCQ: (a)–(f) option lines
The SI unit of force is:            <- stem text
(a) newton                          <- options: in order, starting (a), no gaps
(b) joule
(c) watt
(d) pascal
#### MCQ ANSWERS                    <- optional; exactly one line: a letter
a                                     (a–f) or the exact option text

## Q3 [4 marks]                     <- sub-question MCQ (case study)
M/s Charvi and Associates carried   <- shared case text
out the audit …
i) Which assertion applies?         <- sub line: roman or arabic 'n) '
   (a) occurrence                   <- options of the current sub (any indent)
   (b) cutoff
ii) Whether the disclosure is right?
   (a) yes
   (b) no
#### MCQ ANSWERS
i) b                                <- '<sub-number>) <letter|text>'; the sub
ii) a                               number must match the file's own numbering
                                      verbatim (roman stays roman)

Rules:

  • A question with option lines is an MCQ; without them, SUBJECTIVE.
  • Key-less templates are valid. A subjective question with no MODEL ANSWER / RUBRIC, or an MCQ with no MCQ ANSWERS, still creates the test — it just can't be graded until completed (§3 gate). The 201 body lists those as warnings.
  • MODEL ANSWER / RUBRIC sections on an MCQ are ignored (warning); MCQ ANSWERS on a non-MCQ is ignored (warning).
  • Structural problems are line-numbered errors in the 400 message — the rest of the file is never partially created:

Response — the full detail object plus creation extras:

ConditionResponse
Template parse errors (line-numbered)400
No title (no # <title> line and no title form field)400
Both file and md_text / neither / non-Markdown extension / empty400
Template over 200,000 characters400

POST /assignments/{id}/bulk-generate → 202 — AI fills the missing key

Fills, one question at a time in the background: SUBJECTIVE model answers (and a rubric per answer) and MCQ correct answers (flat or per sub-question). Answers 202 immediately; poll GET /assignments/{id} (bulk_gen_status + bulk_gen_progress) until DONE or FAILED.

Body fields (all optional):

FieldNotes
mode"all" (default) = model answers + rubrics + MCQ answers · "answers" = only the answers · "rubrics" = only rubrics, for questions that already have a model answer (MCQs are never eligible in this mode)
answer_instructionsFree text appended to the model-answer generation prompt
rubric_instructionsFree text appended to the rubric generation prompt

Semantics:

  • Already-complete questions are skipped — safe to re-run after a partial fill; a run with nothing to do is 422 ("All eligible questions already have … generated.").
  • One job per assignment: starting while one runs is 409. A job whose worker died is reconciled to FAILED after 3 quiet minutes (bulk_gen_progress.error says so) — just start again.
  • A failed AI call skips that unit — the run still ends DONE; check what is still missing (or re-run) before grading. The §3 gate is the backstop: grading an uncovered question is always 422.
  • Progress: {"current": n, "total": N, "label": "Q3", "mode": "all"} — current/total advance per question; checkpoints land mid-question for large MCQ groups.
  • Generated MCQ answers are stored as the exact option strings (the grading contract), generated rubrics as {criteria, max_marks} rows summing toward the question's marks.

Typical wall-clock: seconds per question (one AI call each), so a 10-question paper fills in roughly a minute or two.


bash
curl -s -X POST $BASE/papers/extract -H "X-API-Key: $BCB_API_KEY" \
  -F file=@question_paper.pdf -o extracted.json
json
{
  "questions": [
    {"q_number": 1, "question_text": "State Newton's second law of motion and write its mathematical form.",
     "max_marks": 2.0, "question_type": "SUBJECTIVE", "page_found_on": 1,
     "or_group": null, "sub_questions": null, "options": null, "correct_answers": null},
    {"q_number": 2, "question_text": "The SI unit of force is: (a) joule (b) newton (c) watt (d) pascal",
     "max_marks": 1.0, "question_type": "MCQ", "page_found_on": 1,
     "sub_questions": null, "options": ["joule", "newton", "watt", "pascal"], "correct_answers": null}
  ],
  "total_marks": 3.0,
  "extraction_confidence": 0.9,
  "sections_meta": []
}
bash
curl -s -X POST $BASE/assignments/from-file -H "X-API-Key: $BCB_API_KEY" \
  -F file=@paper_template.md                      # or: -F md_text="@paper_template.md"
json
{"error": {"code": "bad_request",
           "message": "Template errors:\nLine 4: duplicate entry for question '1' — each question may appear only once (first at line 1)\nLine 7: header '## Q2' declares no marks — use e.g. '## 2 [5 marks]'"}}
json
{"id": 43, "title": "Midterm — Physics 101", "status": "ACTIVE", "subject": "Physics",
 "num_submissions": 0, "created_at": "2026-09-23T10:40:12.551204Z",
 "instructions": "Answer all questions.",
 "questions_json": {"questions": [ ...as stored... ]},
 "rubrics_json": [{"question_number": "1", "criteria": [{"criteria": "States F = ma", "max_marks": 4.0}]}],
 "bulk_gen_status": null, "bulk_gen_progress": null,
 "num_questions": 3,
 "warnings": ["Q3: no MCQ ANSWERS line — the question will not be gradeable until completed (bulk-generate can fill it)"]}
bash
curl -s -X POST $BASE/assignments/43/bulk-generate -H "X-API-Key: $BCB_API_KEY" \
  -H "Content-Type: application/json" -d '{"mode": "all"}'
# 202 {"status": "RUNNING", "progress": {"current": 0, "total": 3, "label": "", "mode": "all"}}

5. The report object

report is the grader's full per-question breakdown (returned by /results). Its shape is stable, but individual fields may gain detail as the grading engine evolves — treat anything not listed here as opaque display data, and pin anything contractual to the submission-level fields (total_score, max_score, status) instead.

Top level:

FieldTypeNotes
student_name, student_emailstringAs stored on the student record
student_enrollment_numberstring | null
student_ms_oidstring | nullAlways null for partner students (UI-tenant field)
submitted_at, evaluated_atstringISO 8601 timestamps
total_score, max_scorenumberRounded to 2 decimals
percentagenumber1 decimal
total_questionsintQuestions counted toward the score (excludes unchosen OR-group alternatives)
question_resultsarrayOne entry per question — see below
grading_failed_questionsstring[]Question numbers that exhausted AI retries and scored 0 — normally []; non-empty means "needs manual review"
report_level_feedbackstringHuman-readable summary paragraph

Each question_results entry:

FieldTypeNotes
q_numberstringMatches the question_number you submitted
question_textstring
question_type"SUBJECTIVE" | "MCQ"
max_score, total_scorenumberMarks for this question
criteria_resultsarrayPer-rubric-criterion: {criterion, marks_available, marks_awarded, was_met, feedback}
question_feedbackstringHuman-readable feedback for the student
matched_model_answer_indexintWhich model_answers variant matched best (0 when a single model_answer)
or_groupstring | nullSet when the question belongs to an OR group
skipped_or_alternativebooltrue for the unchosen alternative(s) of an OR group — excluded from totals
grading_failedboolPresent-and-true only when this question needs manual review (mirrored in grading_failed_questions)
annotated_pagesstring[]?v1.1: internal storage keys of the OSM-annotated pages — these are what GET …/marked-pdf packs. Don't fetch them directly; use the §4.5 file endpoints

Example fragment:


json
{
  "question_results": [
    {
      "q_number": "1",
      "question_text": "State Newton's second law.",
      "question_type": "SUBJECTIVE",
      "max_score": 5.0,
      "total_score": 4.5,
      "criteria_results": [
        {"criterion": "States F = ma", "marks_available": 5.0, "marks_awarded": 4.5,
         "was_met": true, "feedback": "Correct relation stated; minor notation slip."}
      ],
      "question_feedback": "Good answer — watch the units notation.",
      "matched_model_answer_index": 0,
      "or_group": null,
      "skipped_or_alternative": false
    }
  ]
}

6. Webhooks (v1.1)

BigChalkBox pushes three events to your HTTPS endpoint as they happen, so you no longer need to poll for grading completion. Delivery is signed, retried, and inspectable — this section is the full contract.

6.1 Events

EventFired when
submission.receivedA student's upload is accepted (same transaction as the store — never lost)
grading.completedA grading run reaches EVALUATED. Each re-grade completion is a new event
grading.failedA grading run ends in ERROR (payload carries an error excerpt)

Example payloads (captured live 2026-09-23; keys are sorted):

submission.received:

grading.completed:

grading.failed:

json
{"assignment_id": 42, "auto_grade": true, "status": "QUEUED",
 "student_email": "riya.kapoor@example.edu", "student_name": "Riya Kapoor",
 "submission_id": "6f0c9a2e-3b1d-4c8a-9e2f-7a1b2c3d4e5f",
 "submitted_at": "2026-09-22T18:56:38.265438+00:00"}
json
{"assignment_id": 42, "evaluated_at": "2026-09-22T18:56:56.939920+00:00",
 "max_score": 5.0, "percentage": 40.0, "status": "EVALUATED",
 "submission_id": "6f0c9a2e-3b1d-4c8a-9e2f-7a1b2c3d4e5f", "total_score": 2.0}
json
{"assignment_id": 42, "evaluated_at": null, "status": "ERROR",
 "submission_id": "6f0c9a2e-3b1d-4c8a-9e2f-7a1b2c3d4e5f",
 "error_excerpt": "Traceback (most recent call last): … (first 500 chars)"}

6.2 Delivery

We POST the payload (compact JSON) to your configured URL. Request headers:

HeaderMeaning
Content-Typeapplication/json
X-BCB-EventThe event type
X-BCB-Event-IdUnique id for this event — your dedupe key
X-BCB-DeliveryInternal delivery id (matches the log in §6.4)
X-BCB-Signaturet=<unix-ts>,v1=<hmac> — see §6.3
User-AgentBigChalkBox-Partner-Webhooks/1.0

Delivery semantics:

  • At-least-once, not exactly-once. Your handler must be idempotent: dedupe on X-BCB-Event-Id (and treat a repeated event with the same id as already-processed).
  • Success = any 2xx within 10 seconds. Anything else (5xx, 4xx, timeout, connection failure, invalid TLS) is a failed attempt.
  • Retries: 1 min → 5 min → 30 min → 2 h after the failed attempt; after the 5th total attempt the delivery is marked dead. A dead delivery can be re-queued on demand (§6.4) — the event is not discarded.
  • Reliability comes from the outbox: events are written to a durable queue in the same database transaction as the thing that caused them, then delivered by a background worker — an app restart never loses an event.
  • One URL per client (v1.1). To feed two systems, fan out inside your endpoint.

6.3 Signature verification

The X-BCB-Signature header is t=<unix-timestamp>,v1=<hex>, where

v1 = HMAC-SHA256( key = your webhook secret,
                  msg = "<t>" + "." + <raw request body bytes> )

Verify both the freshness of t (reject if |now − t| > 300 s — guards against replay) and the MAC (constant-time compare).

python
import hashlib, hmac, time

def verify_bcb_signature(body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    try:
        t_part, v1_part = header.split(",")
        t, v1 = t_part[2:], v1_part[3:]
    except (ValueError, KeyError):
        return False
    if abs(int(t) - time.time()) > tolerance:
        return False
    expected = hmac.new(secret.encode(), t.encode() + b"." + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(v1, expected)
bash
# sig_file contains the X-BCB-Signature header, body_file the raw body
T=$(echo "$SIG" | cut -d, -f1 | cut -d= -f2)
V1=$(echo "$SIG" | cut -d, -f2 | cut -d= -f2)
EXPECTED=$(printf '%s.' "$T" | cat - body_file | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
[ "$V1" = "$EXPECTED" ] && echo VALID || echo INVALID
js
import crypto from "node:crypto";
function verify(body, header, secret, tolerance = 300) {
  const [, t, v1] = header.match(/^t=(\d+),v1=([0-9a-f]+)$/)?.slice(1) ?? [];
  if (!t || !v1 || Math.abs(Date.now() / 1000 - Number(t)) > tolerance) return false;
  const expected = crypto.createHmac("sha256", secret).update(t + "." + body).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}

6.4 Managing your subscription

POST /webhooks — register (or fully replace)

Body fieldNotes
urlRequired. Must be https:// — the one exception is http://localhost… for your dev environment. ≤1024 chars
eventsOptional on create — omitted means all three. Unknown name → 400
secretOptional — omit to have us generate one (bcbwh_…). If you supply your own: 16–128 chars

Response — the secret appears only here:

Store the secret in your secrets manager immediately. Re-POSTing replaces the whole subscription (url + events + secret).

PATCH /webhooks — partial update

Send any of url, events, secret. The secret is returned only when it changed or was generated. PATCH before any POST → 404 ("No webhook configured. POST /webhooks first."). Setting "events": [] subscribes to nothing (pending deliveries then dead-letter; use this as a kill switch).

GET /webhooks — current subscription

Never returns the secret. 404 before the first POST.

GET /webhook-deliveries — the delivery log

Newest first; ?status=pending|delivered|dead and ?limit (1–100, default 50). This is your redrive/debug view — it shows what we attempted, what your endpoint answered, and when:

POST /webhook-deliveries/{id}/retry — re-queue on demand

Use after fixing your endpoint: a dead (or still-pending) delivery goes back to due-immediately. 409 for already-delivered rows (immutable history), 404 for unknown ids or another key's deliveries.

bash
curl -s -X POST $BASE/webhooks -H "X-API-Key: $BCB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://your-platform.example/hooks/bigchalkbox",
       "events": ["submission.received", "grading.completed", "grading.failed"]}'
json
{"url": "https://your-platform.example/hooks/bigchalkbox",
 "events": ["submission.received", "grading.completed", "grading.failed"],
 "secret": "bcbwh_9Xk2...", "updated_at": "2026-09-22T18:56:36.088917Z"}
json
[{
  "id": 2, "event_type": "grading.completed", "event_id": "7ce0abfc-…",
  "status": "delivered", "attempts": 1,
  "next_attempt_at": null, "last_http_status": 200, "last_error": null,
  "created_at": "2026-09-22T18:56:57Z", "delivered_at": "2026-09-22T18:56:58Z",
  "payload": { "assignment_id": 42, "…": "…" }
}]

6.5 Minimal webhook handler (Python, framework-agnostic core)


python
# Handle: respond 200 fast; do the real work in a queue if it's slow.
def handle_bcb_webhook(body: bytes, headers: dict, secret: str):
    if not verify_bcb_signature(body, headers["X-BCB-Signature"], secret):
        return 401
    event_id = headers["X-BCB-Event-Id"]
    if already_processed(event_id):          # your dedupe store
        return 200
    payload = json.loads(body)
    if headers["X-BCB-Event"] == "grading.completed":
        fetch_results_and_update_your_lms(payload)
    mark_processed(event_id)
    return 200

7. Typical integrations

7.1 The core loop (any language)

0. create the test — any of (§4.6):
   a. POST /assignments                                (hand-built JSON)
   b. POST /papers/extract → POST /assignments         (scanned paper PDF → questions)
   c. POST /assignments/from-file                      (Markdown paper template)
   and optionally POST /assignments/{id}/bulk-generate (AI fills the missing key)
1. POST /assignments                     → assignment_id
2. upload answer sheets, one by one or in batches (fires submission.received):
      POST /assignments/{id}/submissions            → submission_id
      POST /assignments/{id}/submissions/bulk       → per-file report (v1.3)
3. wait for grading.completed webhook    (or poll GET /submissions/{id})
4. GET  /submissions/{id}/results        → structured report
5. GET  /submissions/{id}/marked-pdf     → visual marked sheet   (osm tests)
6. cohort-level (v1.3):
   GET  /assignments/{id}/submissions    → walk the roster for the gradebook
   GET  /assignments/{id}/analytics      → bands, problem areas, dashboard counts
   GET  /assignments/{id}/export?format= → xlsx roster / zip of marked PDFs
   repairs: page-layout → page-overrides → grade?mode=overrides (§4.4)

7.2 Bash (polling variant, whole flow)

bash
BASE=https://api.bigchalkbox.com/partner/v1
KEY="X-API-Key: $BCB_API_KEY"

AID=$(curl -s -X POST $BASE/assignments -H "$KEY" -H "Content-Type: application/json" \
  -d @test_definition.json | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')

SID=$(curl -s -X POST $BASE/assignments/$AID/submissions -H "$KEY" \
  -F file=@riya.pdf -F student_email=riya@example.edu | \
  python3 -c 'import json,sys; print(json.load(sys.stdin)["submission_id"])')

# Poll until terminal (webhook subscribers can skip straight to step 4)
while :; do
  ST=$(curl -s -H "$KEY" $BASE/submissions/$SID | python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])')
  [ "$ST" = "EVALUATED" ] || [ "$ST" = "ERROR" ] && break
  sleep 3
done

curl -s -H "$KEY" $BASE/submissions/$SID/results > riya_results.json
curl -sL -H "$KEY" $BASE/submissions/$SID/marked-pdf -o riya_marked.pdf   # osm tests

# Walk the roster for the whole gradebook
CURSOR=""
while :; do
  PAGE=$(curl -s -H "$KEY" "$BASE/assignments/$AID/submissions?limit=100${CURSOR:+&cursor=$CURSOR}")
  echo "$PAGE" | python3 -c 'import json,sys; [print(i["student_email"], i["status"], i["total_score"]) for i in json.load(sys.stdin)["items"]]'
  CURSOR=$(echo "$PAGE" | python3 -c 'import json,sys; print(json.load(sys.stdin)["next_cursor"] or "")')
  [ -z "$CURSOR" ] && break
done

7.3 Python (requests, webhook-driven)

python
import time, json, hashlib, hmac
import requests

BASE = "https://api.bigchalkbox.com/partner/v1"
S = requests.Session()
S.headers["X-API-Key"] = "bcbk_..."          # your key

def submit_and_track(assignment: dict, pdf_path: str, email: str, name: str) -> str:
    a = S.post(f"{BASE}/assignments", json=assignment).raise_for_status().json()
    with open(pdf_path, "rb") as f:
        r = S.post(
            f"{BASE}/assignments/{a['id']}/submissions",
            files={"file": (pdf_path, f, "application/pdf")},
            data={"student_email": email, "student_name": name},
        )
    if r.status_code == 409:                  # already in flight — see FAQ
        raise RuntimeError(r.json()["error"]["message"])
    r.raise_for_status()
    return r.json()["submission_id"]          # results arrive via your webhook

# --- your webhook endpoint -------------------------------------------------
def webhook_handler(body: bytes, headers: dict, secret: str):
    t, v1 = headers["X-BCB-Signature"].split(",")
    t, v1 = t[2:], v1[3:]
    if abs(int(t) - time.time()) > 300:
        return 401
    expected = hmac.new(secret.encode(), t.encode() + b"." + body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(v1, expected):
        return 401
    payload = json.loads(body)
    if headers["X-BCB-Event"] == "grading.completed":
        sid = payload["submission_id"]
        results = S.get(f"{BASE}/submissions/{sid}/results").raise_for_status().json()
        update_your_lms(payload, results)
    return 200

7.4 Node.js (fetch, Node 18+)


js
const BASE = "https://api.bigchalkbox.com/partner/v1";
const auth = { "X-API-Key": "bcbk_..." };

async function submit(assignment, pdfBytes, email, name) {
  let r = await fetch(`${BASE}/assignments`, {
    method: "POST", headers: { ...auth, "Content-Type": "application/json" },
    body: JSON.stringify(assignment),
  });
  if (!r.ok) throw new Error((await r.json()).error.message);
  const { id } = await r.json();

  const form = new FormData();
  form.append("file", new Blob([pdfBytes], { type: "application/pdf" }), "answers.pdf");
  form.append("student_email", email);
  form.append("student_name", name);
  r = await fetch(`${BASE}/assignments/${id}/submissions`, { method: "POST", headers: auth, body: form });
  if (r.status === 409) throw new Error((await r.json()).error.message);
  if (!r.ok) throw new Error((await r.json()).error.message);
  return r.json().submission_id;
}

8. Error-handling playbook

ResponseCauseWhat to do
400 bad_requestEmpty title, closed-assignment upload, delete of ACTIVE test, >25 MB PDF, grade of a file-less submission, marked-pdf/zip-export on a non-OSM test, invalid webhook URL / secret length / unknown event, non-PDF paper, from-file template parse errors (line-numbered); v1.3: bulk upload missing both students and email_domain, students keys ≠ filenames, >50 files, export with nothing to export, page-overrides validation (unknown question/path, traversal, >20 images, enabled-but-empty), fast re-grade modes on a non-EVALUATED submission or overrides without a stored arrangementRead error.message; fix the request/template. Bulk per-file problems are NOT 400 — they come back inside the 200 report
401 unauthorizedKey missing/invalid/deactivatedStop and re-issue the key via the BigChalkBox admin; do not retry
404 not_foundWrong id, or the object belongs to another key, or already deleted; webhook read/retry before POST /webhooks; v1.3: zip export with no marked PDFs available (evaluated but no annotated pages)Fix the id; don't retry blindly
409 conflictDuplicate upload while QUEUED/PROCESSING/EVALUATED; mode=full grade while in flight; results before EVALUATED; retrying a delivered webhook; bulk-generate started while a job runsExpected under retries — poll the existing object instead of re-sending (the fast re-grade modes are idempotent 202 instead — §3)
413Body over ~100 MB at the proxy (v1.3: raised from 30 MB for bulk uploads)Compress/split the PDFs under 25 MB; keep batches under the body cap
422 validation_failedSchema errors (details lists field paths) or a grading-precondition failure on any re-grade mode (SUBJECTIVE without rubric, MCQ without complete correct answers); v1.3: unknown grade?mode= valueFix the named fields; for coverage, fill via PUT …/questions/…/rubrics or POST …/bulk-generate, then resend
429 rate_limitedOver 20 r/s sustained (burst 40)Exponential backoff; no Retry-After today
502 on /papers/extractOur AI side unavailable, exhausted its retries, or returned garbageRetry later with backoff — the paper itself was not the problem
5xxUnexpected server errorRetry the same request with backoff (uploads are safe to retry — see 409 contract); quote X-Request-Id if it persists

9. Limits & guarantees at a glance

LimitValue
Upload size25 MB per PDF (app); proxy cuts bodies over ~100 MB with 413
FormatPDF only (filename must end .pdf), one file per submission
Bulk upload1–50 PDFs per request (v1.3); per-file report, request itself stays 200
Submissions per student per assignment1 (re-upload replaces only when not in flight/graded — same rule inside bulk uploads)
Rate limit20 r/s per key, burst 40
Page sizedefault 50, max 100 (roster); webhook-deliveries max 100
Completion signalwebhooks (primary) or polling (fallback) — v1.1
Webhook retries1 m → 5 m → 30 m → 2 h, then dead (re-queueable); at-least-once
Presigned file URLsvalid 1 hour (/pdf, /pages)
Result retentionuntil the assignment is deleted (delete removes the PDFs and pages too)
Cross-tenant accessalways 404, never 403
Idempotency keysnot supported on requests — dedupe via the (assignment, student) uniqueness + 409 contract; dedupe webhook events via X-BCB-Event-Id
Marked sheetsGET …/marked-pdf (OSM tests); page images via GET …/pages — v1.1
Paper extractionPOST /papers/extract — 25 MB PDF; synchronous call that can run tens of minutes on large papers; client read timeout ≥ 30 min (proxy allows 50) — v1.2
Paper templatesPOST /assignments/from-file — .md/.markdown file or md_text; ≤ 200,000 characters; deterministic (zero-LLM) — v1.2
Bulk generationPOST /assignments/{id}/bulk-generate — one job per assignment at a time; dead jobs reconcile to FAILED after 3 quiet minutes; seconds per question — v1.2
Exportxlsx: unbounded cohort (built server-side, seconds); zip: OSM tests only, ≤ 100 evaluated submissions per archive — v1.3
Page arrangements≤ 20 images per question per arrangement; paths must exist in the live page listing; the overrides re-grade bakes the arrangement (it clears itself) — v1.3
Re-grade modesfull resets + re-meters pages; existing-pages / overrides keep the report until replaced and do not re-meter pages — v1.3
Webhook URLshttps only (localhost http allowed for dev); one URL per client
Fixing a wrong uploadThe PDF itself is frozen once the submission is QUEUED/PROCESSING/EVALUATED and there is no per-submission delete (assignment-level delete only). v1.3: a mis-graded page (scanner/segmentation put it under the wrong question) is fixable without a new upload — page-layout → page-overrides → grade?mode=overrides. A wrong file still means deleting the assignment or contacting support

10. FAQ & gotchas

How do I create a test from my own question paper?

Two paths (§4.6): POST /papers/extract with the paper PDF — the AI returns the structured questions, you review them, then POST /assignments with them (map q_number → question_number; extra fields are ignored) — or, if your paper exists as text, POST /assignments/from-file with a Markdown template (FORMAT SPEC v1 is in §4.6; deterministic, no AI). Either way, missing model answers/rubrics/MCQ answers are filled by POST /assignments/{id}/bulk-generate.

Can I create a test without an answer key?

Yes (v1.2). Key-less tests are legal to create and to upload to; grading is simply refused with 422 naming the uncovered question until the key exists — via PUT …/questions + PUT …/rubrics, a filled from-file template, or bulk-generate. Nothing can grade silently against empty content.

Why did my grade call 422 on an MCQ?

(v1.2) The MCQ has no complete correct_answers — top-level, or on every option-bearing sub-question. Complete them via PUT …/questions (or POST …/bulk-generate to let AI propose them), then grade.

How long can `/papers/extract` take, and what if it 504s?

Large papers are read in 15-page batches and can take tens of minutes; the call stays open the whole time (no polling variant). Set a client read timeout of at least 30 minutes — the platform's proxy allows up to 50, so a 504 means your client (or an intermediate proxy of yours) timed out first. A 502 means our AI side is degraded — retry later. extraction_confidence below 1.0 is normal; review the questions before creating the test.

Which AI reads my paper / grades my tests — can I choose?

No, and that's by design: BigChalkBox manages the extraction engine and grading provider server-side and can switch them at any time (e.g. when a provider is degraded). No request in this API exposes an engine or provider parameter.

How do I know grading is done?

Subscribe to webhooks (§6) — grading.completed / grading.failed fire as the run finishes. Polling GET /submissions/{id} every 2–5 s still works and stays well within the rate limit. A submission typically finishes in tens of seconds to a few minutes.

My webhook endpoint was down for a few hours — did I miss events?

Delivery is retried for ~2.5 h total (1 m → 5 m → 30 m → 2 h), then the event is dead — not deleted. Check GET /webhook-deliveries?status=dead and POST …/retry the ones you need after fixing your endpoint. Anything older, reconcile from the roster (GET /assignments/{id}/submissions) — it is always the source of truth.

I lost my webhook secret.

PATCH /webhooks with a new secret — the old one stops working immediately (pending deliveries will be signed with the new one). There is no way to read it back by design.

Can I subscribe to only some events?

Yes — pass the events array you want to POST/PATCH /webhooks. [] subscribes to nothing (a clean kill switch).

Can I have two webhook URLs (e.g. prod + a mirror)?

Not in v1.1 — one URL per client. Fan out inside your endpoint if needed.

What does the marked PDF contain, and why is my marked-pdf a 400?

For tests created with osm_enabled: true, it's the student's own pages with the grader's ticks/crosses and remark, one PDF, score badge on page 1. A 400 means the test wasn't created with OSM — the flag only affects grading runs after it is set, so flip it and re-queue (POST …/grade) to regenerate.

Can I improve rubrics after students are already graded?

Yes — PUT /assignments/{id}/rubrics (or /questions), then POST /submissions/{id}/grade on each affected submission to re-grade. Regrading resets and recomputes everything for that submission (and fires a fresh grading.completed).

How do I stop collecting submissions for a test?

PATCH /assignments/{id} with {"status": "CLOSED"}. Uploads then fail with 400. Reopen by PATCHing back to ACTIVE. Deleting requires closing first.

Do deadlines enforce anything?

No — deadline is metadata the API stores and returns; the API does not auto-close at the deadline. Enforce it on your side by closing the assignment.

Two students, same PDF?

Fine — one submission per (assignment, student-email). Students are upserted by email, so the same email across tests is the same student record.

What does a 409 on upload mean?

The student already has a submission that is queued, being graded, or already graded. It is protective, not an error in your request — capture the submission_id you already have (from the roster if needed) and poll that instead.

I uploaded the wrong PDF or used the wrong student's email — how do I fix it?

While the submission is still SUBMITTED or ERROR, re-upload — the file is replaced in place (same submission_id). Once it's QUEUED/PROCESSING/EVALUATED the PDF is frozen: re-uploads answer 409 and there is no per-submission delete. Double-check student_email before sending; a graded wrong upload needs BigChalkBox admin intervention. If the file is right but the grader put a page under the wrong question, v1.3 fixes that without a new upload: page-layout → page-overrides → grade?mode=overrides.

How do I upload a whole class of scanned sheets at once? (v1.3)

POST /assignments/{id}/submissions/bulk — up to 50 PDFs per request. Identify students either with the students JSON map (filename → email/name, exact filename match) or by naming the files [ID]_[First]_[Last]_[extra].pdf and sending your email_domain. The response is always 200 with a per-file report; grade-readiness problems store the file as SUBMITTED (message says why) instead of failing the batch. Each accepted file fires its own submission.received webhook.

How do I re-grade without re-running the whole pipeline? (v1.3)

POST /submissions/{id}/grade?mode=existing-pages re-grades every question from the pages the last run produced (no re-render/re-classify/re-segment — seconds, not minutes; the usual choice after a rubric or provider change). ?mode=overrides re-grades only the questions you rearranged via PUT …/page-overrides, then bakes the arrangement. Both keep the current report until the new run lands, fire grading.completed like any grading run, and still honor the §3 answer-key gate.

How do I get a spreadsheet / a bundle of marked sheets? (v1.3)

GET /assignments/{id}/export?format=xlsx — the full results roster (Student Name, Student ID, per-question marks, Total Score), column-compatible with the teacher UI's Excel export. ?format=zip — one marked PDF per graded student (OSM tests only; 100-submission cap). No client-side file building needed; both are plain authenticated downloads.

What timezone is `deadline` in?

ISO 8601 with offset is safest (2026-09-10T23:59:59+05:30). A naive value is interpreted as IST (UTC+5:30). All returned timestamps are UTC.

Is there a sandbox?

Not yet — the test lifecycle is fully reversible (create → close → delete), so integrating against a throwaway assignment on the live API is low-risk. Don't upload real student data until you're ready to keep it.

Where do keys come from?

BigChalkBox admins issue them (Admin → API Clients). The raw key is shown once — store it in your secrets manager; if lost, rotate.


11. Support

Include the X-Request-Id response header (or send your own) when reporting issues — it correlates your request in BigChalkBox's logs. For webhook problems, also quote the X-BCB-Delivery id and X-BCB-Event-Id: the GET /webhook-deliveries log on your side and our logs meet there.


12. Changelog

VersionDateChanges
v1.32026-09-23Added the ops surface: GET /assignments/{id}/analytics (score bands, problem areas, dashboard counts — problem areas now exclude un-attempted OR-alternatives and a 0.0 average is reported as 0.0, not null), GET /assignments/{id}/export?format=xlsx|zip (server-built results roster / marked-PDF bundle, S1=B), POST /assignments/{id}/submissions/bulk (1–50 PDFs, explicit students map or [ID]_[First]_[Last].pdf + email_domain, always-200 per-file report, per-file webhooks), GET /submissions/{id}/page-layout + PUT /submissions/{id}/page-overrides (page rearrangement, presigned URLs), and re-grade modes on POST /submissions/{id}/grade?mode=full|existing-pages|overrides (fast modes are idempotent 202 in flight, keep the report, and the overrides run bakes the arrangement). Proxy body cap raised 30 MB → 100 MB for bulk uploads. 26 → 31 routes.
v1.22026-09-23Added the content door (§4.6): POST /papers/extract (question-paper PDF → structured questions, server-managed AI, long-running call), POST /assignments/from-file (Markdown paper template, FORMAT SPEC v1, deterministic, line-numbered errors), POST /assignments/{id}/bulk-generate (202 + poll; AI fills missing model answers / rubrics / MCQ correct answers; bulk_gen_status + bulk_gen_progress now on GET /assignments/{id}). Contract split: create requires the PAPER (text, marks, MCQ options); the ANSWER KEY is enforced at grade time (§3) — new 422 for MCQs without complete correct_answers. AI configuration remains server-managed (no client-facing engine/provider params).
v1.12026-09-23Added webhooks (5 endpoints, §6: submission.received / grading.completed / grading.failed, HMAC-signed, outbox + retries + delivery log). Added results & files: GET /submissions/{id}/pdf, /pages, /marked-pdf (§4.5). Added osm_enabled to create/patch (§4.2). Report object: student_ms_oid noted, annotated_pages now explained via the file endpoints. Polling remains a supported fallback.
v12026-09-05Initial public contract: 15 endpoints — account, assignments (incl. question/rubric replaces), submissions (upload/roster), grading (queue/status/results).