Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The API

The JSON API sits next to the pages and answers the same questions with the same rules: every lookup goes through the same scoped querysets in portal/access.py, and every write goes through the same database triggers. Anything a page can tell you, the API can too, with one exception: the organizer’s tools (settings, invitations, handing out reviews, calibration, duplicates) are pages only.

Every example below is a real request against the demo stack on http://localhost:8080, with the demo tokens from .dogfood.toml. The read-only ones you can paste as they are. The ones that write change the demo data, so run them on a stack you’re happy to reset with docker compose down -v.

Authentication

Bearer tokens

Scripts authenticate with a token in the Authorization header:

curl -H "Authorization: Bearer bb_demo_judge_a_8d24f1" http://localhost:8080/api/judge/scores

A token is bb_ followed by 43 random characters. The portal stores only its SHA-256, so a token is shown once, when it’s made, and a leaked database doesn’t leak working tokens. A token acts as its user, with all of that user’s roles; there are no scopes. A revoked token, or one whose user has been deactivated, is refused.

The demo seed creates four tokens with fixed values, so the checker and this book can use them. They’re public, which is why a real deployment turns them off (see Running it for real).

WhoTokenRoles
organizerbb_demo_organizer_5c1e0aorganizer of both seeded events
judge_abb_demo_judge_a_8d24f1jdg_24 in the fixture event, judge of the open demo event
judge_bbb_demo_judge_b_3a9e77jdg_29 in the fixture event
participantbb_demo_participant_61b0c4team NorthKiln in the fixture event, participant in the demo event

judge_a and judge_b share no project, so each has scores the other must not see. Anyone signed in can issue and revoke their own tokens at /me/tokens (linked from My events); a token is shown once and only its hash is stored.

Browser sessions

A browser that’s signed in can call the API with its session cookie. Then Django’s CSRF protection applies: a POST or PATCH needs the X-CSRFToken header with the value of the csrftoken cookie, or it’s refused with 403. Bearer requests need no CSRF token, because a browser never attaches an Authorization header on its own, so a forged cross-site request can’t carry one.

Pages accept tokens too

The HTML pages accept the same bearer tokens, for reading only. A GET with a token sees exactly what that user would see in a browser, and no session is created; this is what lets the isolation probe test the pages with curl. Anything that would change something through a page (a POST) is refused with 403 when it comes with a token, and so are /me/tokens and the admin: a leaked token can’t mint more tokens. Scripts that write use the API. A bad token on a page gets a plain-text 401.

Status codes

The same contract holds on the pages and in the API.

CodeWhenExample
200, 201it worked; 201 when a project was created
400the request body is malformed: a missing required field, a wrong type, a track from another event{"title": ["This field is required."]}
401no credentials, or a bad token, on a route that needs them (API responses carry WWW-Authenticate: Bearer; pages send a browser to the login page instead){"detail": "invalid or revoked token"}
403you’re signed in but your role can’t do this, or you named someone else’s data{"detail": "judges can only read their own scores"}
404the thing doesn’t exist, or it exists but isn’t yours to see: another judge’s assignment, another team’s draft, unpublished results{"detail": "Not found."}
409the window for this is closed, or a conflict of interest{"detail": "Submissions for Sample Hack 2026 closed at 2026-03-01 18:00 UTC."}
422the values are wrong: a score out of range, an unknown criterion, submitting a review with a criterion missing{"detail": "Quality must be between 1 and 5"}

The line between 403 and 404 is deliberate. If you may know a thing exists but can’t touch it (another team’s submitted project, which is in the public gallery), you get 403. If you may not even know it exists (another judge’s assignment, another team’s draft), you get 404, since a 403 would confirm it’s there.

A 409 or 422 can come from the app’s own check or from a database trigger. The app checks first so it can give a clear message; the trigger is the backstop, and access.guarded() turns its refusal into the same status code. Errors are always JSON: {"detail": "..."}, or a field-by-field object for a 400.

Endpoints

MethodPathWho may call it
GET/api/eventsanyone
GET/api/events/<slug>anyone
GET/api/events/<slug>/projectsanyone; drafts only for their team and organizers
POST/api/events/<slug>/projectsa member of a team in the event
GET/api/projects/<id>anyone who can see the project
PATCH/api/projects/<id>the project’s team
GET/api/judge/assignmentsjudges
POST/api/judge/assignments/<id>/reviewthe judge the assignment belongs to
GET/api/judge/scoresjudges, for themselves; organizers, for their events’ judges
GET/api/events/<slug>/resultsanyone once published; before that, the event’s organizers
GET/api/events/<slug>/export/<kind>.csvthe event’s organizers
GET/api/events/<slug>/results/signedanyone once published: the ranking signed with the portal’s Ed25519 key
GET, POST/api/events/<slug>/ballotvoters, in events that vote by signed-in account (confirmed address)
GET, POST/api/projects/<id>/commentsanyone reads; signed-in users post
GET/api/judge/record?event=<slug>a judge, for their own signed record; organizers may name a judge of their event
GET/api/participant/record?event=<slug>a participant, for their own signed record
POST/api/verifyanyone: checks a signed record or signed results
GET/.well-known/ballotbench-signing-keyanyone: the public key records and results are signed with
GET/api/events/<slug>/export/bundle.jsonthe event’s organizers: the whole event as one file
POST/api/events/import?slug=<new>site admins: a bundle back in as a new event
GET/api/schemaanyone
GET/api/docsanyone

Site admins (is_staff) count as organizers of every event.

Events

List events

GET /api/events: every event, the latest deadline first, with its windows, tracks and rubric. No authentication.

curl http://localhost:8080/api/events
[
  {
    "slug": "demo-open",
    "name": "Demo Hack (open)",
    "...": "..."
  },
  {
    "slug": "sample-hack-2026",
    "name": "Sample Hack 2026",
    "description": "",
    "submissions_open": "2026-02-26T00:00:00Z",
    "submissions_close": "2026-03-01T18:00:00Z",
    "judging_open": "2026-03-01T18:00:00Z",
    "judging_close": null,
    "voting_open": null,
    "voting_close": null,
    "results_published_at": null,
    "reviews_per_project": 3,
    "max_team_size": 4,
    "tracks": [{"id": 1, "name": "Developer tools"}, {"id": 2, "name": "Data and analytics"}, "..."],
    "criteria": [
      {"key": "functionality", "name": "Functionality", "weight": "1.000", "min_value": 1, "max_value": 5},
      {"key": "quality", "name": "Quality", "weight": "1.000", "min_value": 1, "max_value": 5},
      {"key": "innovation", "name": "Innovation", "weight": "1.000", "min_value": 1, "max_value": 5}
    ]
  }
]

All times are UTC. A null close means the window never closes. Weights are decimals, sent as strings so they don’t lose precision.

One event

GET /api/events/<slug>: the same object for one event, or 404.

curl http://localhost:8080/api/events/sample-hack-2026

Projects

A project looks like this in every response:

{
  "id": 1,
  "event": "sample-hack-2026",
  "team": "NorthKiln",
  "track": 4,
  "title": "Glass Signal",
  "tagline": "",
  "summary": "One line of what it does.",
  "description": "",
  "repo_url": "https://example.org/repo/01",
  "demo_url": "",
  "tags": [],
  "status": "submitted",
  "submitted_at": "2026-02-27T04:08:00Z",
  "duplicate_of": null
}

track is a track id from the event. status is draft or submitted. duplicate_of is the id of the earlier project this one repeats, if it’s been flagged; flagged projects stay listed but aren’t ranked.

List an event’s projects

GET /api/events/<slug>/projects: the event’s submitted projects, in id order, for anyone. Signed in, you also see your own team’s drafts, and an organizer sees every draft in the event.

curl http://localhost:8080/api/events/sample-hack-2026/projects

On the fixture that’s 41 projects, including the duplicate prj_41 (id 41, "duplicate_of": 7).

One project

GET /api/projects/<id>: one project, if you can see it. Another team’s draft is a 404, not a 403.

curl http://localhost:8080/api/projects/1

Create a project

POST /api/events/<slug>/projects: creates a project for your team in that event. You need to be on a team in the event; teams are made on the event’s page, since the API has no team routes.

FieldTypeNotes
titlestringrequired
tagline, summary, descriptionstringoptional
repo_url, demo_urlURLoptional
tagslist of stringstrimmed, lowercased and de-duplicated; at most 10
tracktrack idmust be a track of this event
submitbooleantrue submits it; otherwise it’s saved as a draft
OutcomeCode
created201, with the project
not signed in401
no team in this event403 you need a team in this event to submit a project
before the window opens or after it closes409, with the time
a bad field400

Each call makes a new project; a team can hold more than one (the fixture’s team CopperLedger has two). When a project is submitted, the portal checks it against the event’s earlier projects and flags it if it repeats one.

This is the request the acceptance checker makes. The fixture event closed on 1 March 2026, so it’s refused:

curl -X POST http://localhost:8080/api/events/sample-hack-2026/projects \
  -H "Authorization: Bearer bb_demo_participant_61b0c4" \
  -H "Content-Type: application/json" \
  -d '{"title": "late", "summary": "x"}'
{"detail": "Submissions for Sample Hack 2026 closed at 2026-03-01 18:00 UTC."}

with status 409. On the open demo event, once the participant has started a team on the event’s page, the same call creates a draft:

curl -X POST http://localhost:8080/api/events/demo-open/projects \
  -H "Authorization: Bearer bb_demo_participant_61b0c4" \
  -H "Content-Type: application/json" \
  -d '{"title": "Night Owl Radio", "tagline": "Offline-first radio for field teams",
       "summary": "Mesh radio notes.", "repo_url": "https://example.org/night-owl",
       "tags": ["radio", "mesh"]}'
{
  "id": 42,
  "event": "demo-open",
  "team": "Night Owls",
  "track": null,
  "title": "Night Owl Radio",
  "tagline": "Offline-first radio for field teams",
  "summary": "Mesh radio notes.",
  "description": "",
  "repo_url": "https://example.org/night-owl",
  "demo_url": "",
  "tags": ["radio", "mesh"],
  "status": "draft",
  "submitted_at": null,
  "duplicate_of": null
}

Edit or submit a project

PATCH /api/projects/<id>: changes the fields you send and leaves the rest. Only members of the project’s team may; send "submit": true to submit it. submitted_at is set from the database’s clock, and there’s no way to unsubmit through the API.

OutcomeCode
saved200, with the project
you can’t see the project404
not signed in401
you can see it but it isn’t your team’s403 only the project's team can edit it
the submission window is closed409
a bad field400
curl -X PATCH http://localhost:8080/api/projects/42 \
  -H "Authorization: Bearer bb_demo_participant_61b0c4" \
  -H "Content-Type: application/json" \
  -d '{"demo_url": "https://example.org/night-owl/demo", "submit": true}'
{"id": 42, "...": "...", "demo_url": "https://example.org/night-owl/demo",
 "status": "submitted", "submitted_at": "2026-09-28T05:13:51.891600Z", "duplicate_of": null}

After the deadline, editing your own project is a 409, and editing another team’s is a 403 (prj_02 is another team’s):

curl -X PATCH http://localhost:8080/api/projects/1 \
  -H "Authorization: Bearer bb_demo_participant_61b0c4" \
  -H "Content-Type: application/json" -d '{"title": "late"}'
# 409 {"detail": "Submissions for Sample Hack 2026 closed at 2026-03-01 18:00 UTC."}

curl -X PATCH http://localhost:8080/api/projects/2 \
  -H "Authorization: Bearer bb_demo_participant_61b0c4" \
  -H "Content-Type: application/json" -d '{"title": "mine"}'
# 403 {"detail": "only the project's team can edit it"}

Judging

Your assignments

GET /api/judge/assignments: your own assignments in every event you judge, and nobody else’s. 403 judges only if you judge no event.

curl -H "Authorization: Bearer bb_demo_judge_a_8d24f1" http://localhost:8080/api/judge/assignments
[
  {"id": 16, "event": "sample-hack-2026", "project": 6, "project_title": "Dry Compass", "status": "done"},
  {"id": 38, "event": "sample-hack-2026", "project": 12, "project_title": "Open Beacon", "status": "done"},
  "..."
]

status is pending, done or recused. Stepping aside from a project (recusal) is on the scoresheet page, not in the API.

Score an assignment

POST /api/judge/assignments/<id>/review: saves your scores for one of your own assignments.

FieldTypeNotes
scoresobjectcriterion key to an integer in that criterion’s range
commentstringoptional, up to 5000 characters; organizers see it, other judges never do
submitbooleantrue submits; otherwise it’s a draft. Submitting needs every criterion.
OutcomeCode
saved200
not your assignment, whatever the id404
judging hasn’t opened, or has closed409, with the time
you stepped aside from this project409
a score out of range, an unknown criterion, or submitting with one missing422
a score that isn’t an integer400

You can save and resubmit as often as you like while judging is open. Every save is in the audit log with the scores before and after.

curl -X POST http://localhost:8080/api/judge/assignments/16/review \
  -H "Authorization: Bearer bb_demo_judge_a_8d24f1" \
  -H "Content-Type: application/json" \
  -d '{"scores": {"functionality": 3, "quality": 4, "innovation": 5}, "comment": "Clear demo.", "submit": true}'
{"assignment": 16, "submitted_at": "2026-09-28T05:13:51.974376Z", "weighted_total": 0.75}

weighted_total is the review’s score on the 0 to 1 scale (see the method). The fixture event’s judging window has no close, so this really does change the fixture’s scores, and with them the calibration fingerprint.

The same assignment, as another judge, doesn’t exist:

curl -X POST http://localhost:8080/api/judge/assignments/16/review \
  -H "Authorization: Bearer bb_demo_judge_b_3a9e77" \
  -H "Content-Type: application/json" -d '{"scores": {"quality": 5}}'
# 404 {"detail": "No JudgeAssignment matches the given query."}

Your scores

GET /api/judge/scores: your own reviews, drafts included, with every criterion’s score and the weighted total.

curl -H "Authorization: Bearer bb_demo_judge_a_8d24f1" http://localhost:8080/api/judge/scores
[
  {
    "assignment": 16,
    "event": "sample-hack-2026",
    "project": 6,
    "project_title": "Dry Compass",
    "judge": "diego.herrera@example.org",
    "status": "done",
    "submitted_at": "2026-03-01T18:00:00Z",
    "comment": "Runs clean.",
    "scores": {"functionality": 2, "quality": 3, "innovation": 5},
    "weighted_total": 0.5833333333333334
  },
  "..."
]

Two query parameters:

  • event=<slug> keeps one event’s reviews.
  • judge=<judge> names a judge, by fixture id (jdg_24), email or user id. A judge may name only themselves. Naming anyone else is a 403, never an empty list, so a refusal can’t be mistaken for “no scores”; and a name that matches nobody is a 403 too, so the answer doesn’t reveal which judges exist. An organizer may name any judge and gets that judge’s reviews in the events they organize (404 no such judge if the name matches nobody).
CallerResult
a judge, no judge=200, their own reviews
a judge naming another judge, or nobody403 judges can only read their own scores
someone who judges nothing, no judge=403 judges only
an organizer naming a judge200, that judge’s reviews in the organizer’s events
no credentials401

The acceptance checker’s peer probe:

curl -H "Authorization: Bearer bb_demo_judge_b_3a9e77" "http://localhost:8080/api/judge/scores?judge=jdg_24"
# 403 {"detail": "judges can only read their own scores"}

curl -H "Authorization: Bearer bb_demo_organizer_5c1e0a" \
  "http://localhost:8080/api/judge/scores?judge=jdg_24&event=sample-hack-2026"
# 200, jdg_24's eleven fixture reviews

Results

GET /api/events/<slug>/results: the ranked results. Until they’re published this is a 404 for everyone except the event’s organizers, who get the latest calibration run (with "published_at": null). After publishing, everyone gets the published run, and later runs change nothing here.

curl http://localhost:8080/api/events/sample-hack-2026/results
# 404 {"detail": "Not found."} until an organizer publishes

curl -H "Authorization: Bearer bb_demo_organizer_5c1e0a" http://localhost:8080/api/events/sample-hack-2026/results
{
  "event": "sample-hack-2026",
  "published_at": null,
  "run": 1,
  "input_digest": "397dd35f376eb052b0dfcd8fda8309f46bad4771e9caa4e78035175807178f4a",
  "method": "offset-scale-noise/v1",
  "projects": [
    {
      "rank": 1,
      "raw_rank": 31,
      "project": 7,
      "title": "Dry Harbour",
      "team": "CopperLedger",
      "calibrated": 0.8321,
      "se": 0.0304,
      "raw_mean": 0.5833,
      "reviews": 5,
      "rank_interval": [1, 39]
    },
    "..."
  ]
}

That’s the fixture after one calibration run and before publishing. Only ranked projects are listed; duplicates and projects with no usable reviews are left out. rank_interval is the 90% bootstrap range the page shows as “could be”; se is the model’s own standard error, which is narrower because it treats every judge’s habits as known. input_digest is the fingerprint of the scores the run read (how to check it).

CSV exports

GET /api/events/<slug>/export/<kind>.csv: one export per stage of the event, for its organizers. 401 without credentials, 403 for anyone else, 404 for a kind that doesn’t exist. Every download writes an export.csv row to the audit log.

curl -H "Authorization: Bearer bb_demo_organizer_5c1e0a" \
  http://localhost:8080/api/events/sample-hack-2026/export/scores.csv | head -3
review_id,judge_email,project_id,project,submitted_at,functionality,quality,innovation,weighted_0_1,comment
1,marek.nowak@example.org,1,Glass Signal,2026-03-01T18:00:00+00:00,2,4,2,0.4167,Runs clean.
3,pavel.ivanov@example.org,1,Glass Signal,2026-03-01T18:00:00+00:00,2,5,3,0.5833,Solid.
KindOne row perColumns
registrationsmembershipuser_email, name, role, tracks
teamsteam memberteam_id, external_id, team, member_email, joined_at
projectsproject, drafts includedproject_id, external_id, title, team, track, status, submitted_at, repo_url, demo_url, tags, duplicate_of
assignmentsassignmentassignment_id, judge_email, project_id, project, status, source, created_at
scoresreview, drafts includedreview_id, judge_email, project_id, project, submitted_at, one column per criterion, weighted_0_1, comment
resultsproject in the latest runrun, rank, rank_low, rank_high, raw_rank, project_id, project, reviews, raw_mean, calibrated, se, excluded, input_digest
judgesjudge in the latest runrun, judge_email, reviews, offset, scale, noise, flag
auditaudit row for the eventseq, ts, actor, action, object_type, object_id, ip, before, after, prev_hash, row_hash

The file is served as text/csv with a download name like sample-hack-2026-scores.csv. Any cell that starts with =, +, - or @ (and isn’t a number) gets a leading ', so a spreadsheet won’t run a project title as a formula. results and judges always describe the latest run, published or not; the run column says which.

The OpenAPI document and the reference page

GET /api/schema serves the OpenAPI 3.0 document, generated by drf-spectacular from the same views, so it can’t drift from the code. It’s YAML by default; ask for JSON with ?format=json or Accept: application/json.

curl http://localhost:8080/api/schema                 # YAML
curl "http://localhost:8080/api/schema?format=json"   # JSON

GET /api/docs is a reference page rendered on the server from that same document. It needs no JavaScript and no CDN, so it works on a stack with no network. Both are public.

The schema lists each route’s success response only. The error codes are in this chapter and in each route’s description.