Version 0.1 (draft). Companion to the specification.
Base URL: https://qna.dtcdev.click/api/v1
| Caller | Header | Notes |
|---|---|---|
| Script / automation | Authorization: Bearer dq_... |
Created in the admin UI, shown once |
| Admin browser | dq_session cookie |
Set by the Cognito login flow |
| Participant | none | The dq_p cookie is set automatically and identifies the voter, not a user |
Admin and API-key callers reach the same endpoints. A key never grants more than its creating admin holds at request time; revoking the admin revokes the key.
Keys may be scoped to a single room at creation, in which case every request
outside that room returns 403.
Requests and responses are JSON with content-type: application/json. Timestamps
are RFC 3339 UTC. Room-scoped paths accept either the room_id or the slug.
Errors:
{ "error": { "code": "room_closed", "message": "This room is not accepting questions." } }| Status | When |
|---|---|
400 |
Malformed request or failed validation |
401 |
Missing or invalid credentials |
403 |
Authenticated but not permitted, including presentation mode for non-admins |
404 |
No such room or question, or a draft room requested publicly |
409 |
Slug already taken, or idempotency key reused with a different body |
410 |
Room is archived |
429 |
Rate limited; see Retry-After |
Collection responses paginate with an opaque cursor:
{ "items": [], "next_cursor": "eyJzayI6IlEjMDF..." , "etag": "W/\"a3f1\"" }Creates a room. Requires an unscoped key.
{
"title": "Podcast #142 — live Q&A",
"description": "Ask anything about the episode.",
"slug": "podcast-142",
"state": "open",
"expires_at": "2026-08-13T19:00:00Z",
"retention_days": 365,
"admins": ["co-host@datatalks.club"],
"settings": {
"listed": true,
"allow_names": true,
"require_names": false,
"answered_placement": "separate",
"default_sort": "popular",
"max_question_length": 500
},
"idempotency_key": "podcast-142"
}Only title is required. slug is generated from the title when omitted; state
defaults to draft. The caller is the owner and does not need to be listed in
admins.
201:
{
"room_id": "01K3QJ7X8N2M4P6R8T0V2W4Y6A",
"slug": "podcast-142",
"title": "Podcast #142 — live Q&A",
"state": "open",
"url": "https://qna.dtcdev.click/r/podcast-142",
"qr_url": "https://qna.dtcdev.click/r/podcast-142/qr.svg",
"present_url": "https://qna.dtcdev.click/admin/rooms/01K3QJ7X8N2M4P6R8T0V2W4Y6A/present",
"expires_at": "2026-08-13T19:00:00Z",
"created_at": "2026-08-06T10:15:00Z",
"owner": "alexey@datatalks.club",
"admins": ["co-host@datatalks.club"],
"settings": { "...": "..." },
"counts": { "questions": 0, "answered": 0 }
}Repeating the request with the same idempotency_key and an identical body returns
the same room and 200. A different body returns 409.
Rooms where the caller is owner or admin.
Query: state (repeatable), q (title substring), limit (default 50, max 200),
cursor.
The room as returned by create. Admin credentials return the full object;
unauthenticated callers get a public subset — no owner, no counts — and only
for open or closed rooms.
Partial update of title, description, slug, state, expires_at,
retention_days, and any key under settings.
{ "state": "closed" }Changing slug keeps the previous one as a permanent redirect. state accepts the
transitions in specification section 3.2; anything else returns 400.
Owner only. Archives the room: the public URL returns 410, data remains until
retention expires. ?purge=true deletes immediately and irreversibly.
The public room URL as a QR code. Public, cacheable. ?size= in pixels for PNG
(default 512, max 2048), ?margin= in modules (default 4).
{ "owner": "alexey@datatalks.club", "admins": ["co-host@datatalks.club"] }Grants admin rights. The address must be a @datatalks.club Google identity in
the shared Cognito pool. To give someone outside the organization moderation
rights, issue a co-host code instead — see below.
Revokes. Takes effect on the next request; existing sessions are not trusted between requests.
{ "email": "..." } — owner only. Transfers ownership; the previous owner stays as
an admin.
A co-host invite grants moderation and presentation rights for one room, to someone with no account. It is a name, which goes in the link, plus a passcode, which does not. Only room admins can create, read, or revoke them — a co-host cannot reach any of these endpoints.
{ "name": "ivan", "passcode": "open-sesame-42" }Both optional: an omitted name gets a readable two-word one, an omitted
passcode gets a generated one. A passcode must be at least 6 characters; a name
must be 3-48 characters of lowercase letters, digits, and hyphens, and unique
within this room — 409 name_taken if it is already used here. 201:
{
"invite_id": "01K3QJA...",
"name": "ivan",
"passcode": "Q7K2-M9XR-T8VB",
"join_url": "https://qna.dtcdev.click/r/tonight/cohost/ivan",
"created_at": "2026-08-06T10:15:00Z"
}Send the recipient the join_url and the passcode, by different routes if it
matters: the link alone opens nothing. Passcodes are matched without regard to
case, dashes, or surrounding spaces. An invite has no expiry — it lasts as long
as the room, and revoking is how it ends.
Lists the room's invites in full, passcodes included, so a host can re-share one they have mislaid.
Revokes. Effective on the co-host's next request, not at the end of their session.
Not part of the JSON API. Renders a form asking for the passcode; POST to the
same URL with passcode validates the pair, sets a room-scoped cookie, and
redirects to that room's moderation view.
A wrong name and a wrong passcode fail identically, with 403, the form, and no
cookie — the page cannot be used to find out which invites exist.
Public for open and closed rooms. This is the endpoint the room page polls.
| Query | Default | Meaning |
|---|---|---|
sort |
room's default_sort |
popular or recent |
status |
visible,answered |
Repeatable. deleted requires admin |
limit |
100 | Max 500 |
cursor |
— | From next_cursor |
Send If-None-Match with the previous etag to get 304 when nothing changed.
{
"items": [
{
"question_id": "01K3QJ8A1B2C3D4E5F6G7H8J9K",
"text": "How do you decide what to cover in a course?",
"author_name": "Maria",
"anonymous": false,
"status": "visible",
"score": 14,
"pinned": false,
"voted": true,
"own": false,
"editable_until": null,
"created_at": "2026-08-06T18:22:11Z",
"answered_at": null
}
],
"next_cursor": null,
"etag": "W/\"a3f1c2\"",
"counts": { "visible": 23, "answered": 7 }
}voted, own, and editable_until are relative to the calling participant's
dq_p cookie and are absent for key-authenticated callers.
Public. Requires state open.
{ "text": "How do you decide what to cover in a course?", "author_name": "Maria" }author_name is rejected when allow_names is false and required when
require_names is true. 201 returns the question, visible to everyone at
once. Starts at score 1 — the author's own.
Admins set status (visible, answered, deleted), pinned, or
text — the last for fixing a typo before it goes on screen, not for changing what
someone asked.
Authors may set text, or status to deleted, within their edit window.
{ "status": "answered" }Public. Idempotent: voting twice leaves the score unchanged. 409 when the
room is not open.
200: { "score": 15, "voted": true }
Withdraws the vote. 200: { "score": 14, "voted": false }
Admin. One call for end-of-session cleanup.
{ "question_ids": ["01K3...", "01K4..."], "action": "answer" }Actions: answer, delete, pin, unpin. Max 100 ids.
Returns per-id results; a failure on one does not roll back the rest.
Browser session only — keys cannot mint keys.
{ "name": "podcast automation", "room_id": null, "expires_at": null }201 returns key in full. It is not retrievable afterwards.
{
"key_id": "01K3QJ9...",
"key": "dq_7Fk2Lm9Qx4Rt8Vb1Nz6Cw3Ey5Hj0Pd2Sa4Gf7Kl9Mn",
"name": "podcast automation",
"room_id": null,
"created_at": "2026-08-06T10:15:00Z",
"expires_at": null
}Metadata only: key_id, name, room_id, created_at, expires_at,
last_used_at.
Immediate revocation.
Unauthenticated. { "status": "ok", "version": "..." }
set -euo pipefail
KEY=$DATAQNA_KEY
API=https://qna.dtcdev.click/api/v1
# Create the room ahead of the recording, still hidden.
ROOM=$(curl -sS -X POST "$API/rooms" \
-H "authorization: Bearer $KEY" -H 'content-type: application/json' \
-d '{"title":"Podcast #142","slug":"podcast-142","state":"draft",
"admins":["co-host@datatalks.club"],"idempotency_key":"podcast-142"}' \
| jq -r .room_id)
# Grab the QR code for the slide deck.
curl -sS "$API/rooms/$ROOM/qr.svg" -o podcast-142-qr.svg
# Open it when the stream starts, with a two-hour fuse.
curl -sS -X PATCH "$API/rooms/$ROOM" \
-H "authorization: Bearer $KEY" -H 'content-type: application/json' \
-d '{"state":"open","expires_at":"2026-08-13T21:00:00Z"}'