{"openapi":"3.1.0","info":{"title":"KMS API (v1 preview)","version":"1.0.0-preview","summary":"Conference and CFP management: submissions, speakers, review scores, speaker tasks and the email log.","description":"Conference and call-for-papers management. Organisers collect talk proposals through forms, a committee scores them, decisions go out, and accepted speakers get tasks (slides, headshots, consent).\n\n## Scope model\n\nA bearer token belongs to one **organisation**; an organisation runs many **events**; every path below names its event explicitly. There is no server-side \"current event\" — start with `GET /events`, keep the id, put it in every subsequent path.\n\n## Quickstart\n\n```bash\ncurl -H \"Authorization: Bearer kms_…\" \"https://kms.r-s.workers.dev/api/v1/events\"\ncurl -H \"Authorization: Bearer kms_…\" \\\n  \"https://kms.r-s.workers.dev/api/v1/events/EVENT_ID/submissions?status=pending&sort=-created_at&limit=50\"\n```\n\nCreate a token under **Settings → API tokens** in the admin app.\n\n## Reading a whole resource\n\nSend `cursor=` (empty) on the first request, then pass the `next_cursor` from each response back as `cursor`, stopping when it is null. This is stable while other people are editing; offset paging (`limit`/`offset`, used when you omit `cursor` entirely) is not.\n\n## Conventions worth knowing before you parse anything\n\n- **Resources.** `contacts`, `submissions`, `tasks`, `messages`, `reviews`, `comments` all support list + export with the same grammar. Full detail endpoints exist for **submissions and contacts only**; the others are list-shaped or nothing.\n- **Envelope.** Lists return `{ data, total, limit, offset, has_more, next_cursor }`. Single records are returned bare, not wrapped.\n- **Errors.** `{ \"error\": { \"code\", \"message\", \"details\"? } }`. Branch on `code`; `message` is prose and may change.\n- **Unknown filters are ignored, never rejected.** A typo silently returns unfiltered rows, so compare `total` against expectations rather than trusting that a filter applied.\n- **JSON columns come back as strings.** `scores`, `links`, `options`, `routing_rules`, `rating_cache`, `reminder_offsets_days` and friends are stored as text and returned as text; parse them yourself.\n- **Booleans are 0/1 integers** in row data (they are real booleans in request bodies).\n- **Timestamps are UTC ISO 8601 strings**, except submission `starts_at`/`ends_at`, which are wall-clock in the event's timezone.\n- **Flags are not statuses.** A `revise` decision keeps a `declined` status; employer approval, accept conditions and materials progress are all separate columns. Filter on the flag.\n- **Retries.** POST accepts `Idempotency-Key`. PUT is naturally idempotent; DELETE returns 404 the second time.\n- **CORS** is open (`*`) — tokens are sent explicitly and no cookie is involved, so browser callers are fine.\n- **No rate limit** is enforced today. Do not take that as licence to hammer it.\n\n## What this API will not do\n\nKnowing the holes up front is cheaper than discovering them:\n\n- **It never sends email.** Changing a submission to `accepted` does not notify the speaker; decision batches are sent from the admin app deliberately, so an automated status change cannot mail hundreds of people by mistake.\n- **No webhooks or events stream.** Poll a list sorted by `-updated_at` if you need to notice changes.\n- **No writes** for reviews, comments, forms, form questions, participants, tags, answers, schedule/agenda, or task assignment. Those surfaces are read-only or admin-app-only.\n- **No sparse fieldsets, no multi-field sort, no `OR` across filters, no full-text search beyond each resource's `q`.**\n- **No bulk endpoints.** Write one record per request.\n\nSee docs/10-api.md §6 for the full backlog behind these gaps."},"servers":[{"url":"https://kms.r-s.workers.dev/api/v1","description":"This deployment."}],"security":[{"bearerAuth":[]}],"tags":[{"name":"Discovery","description":"Unauthenticated self-description."},{"name":"Events","description":"The scope of everything else. Get an event id here first."},{"name":"Submissions","description":"Talk proposals and their pipeline: list/filter, full detail with answers and participants, create, update, delete, and status changes (which never send email)."},{"name":"Contacts","description":"People on an event roster — speakers, reviewers, staff. Identity is organisation-wide, profile is per-event."},{"name":"Tasks","description":"Two different things share this tag, deliberately mirroring the data model: the LIST and EXPORT operations return task ASSIGNMENTS (one row per person per task, with `task_id` pointing at the definition), while POST/PUT/DELETE act on task DEFINITIONS. Assigning a person to a task is not exposed here."},{"name":"Reviews","description":"Committee scores, read-only. Scores are per round and on that round's own scale."},{"name":"Comments","description":"Committee discussion and reviewer rationales, read-only."},{"name":"Messages","description":"The outbound email log, read-only. Nothing on this surface sends mail."},{"name":"Forms","description":"CFP form definitions and their questions, read-only."}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"An API token from Settings → API tokens (`kms_…`), scoped to one organisation. First-party browser callers may use the admin session cookie instead, which is scoped to a single event."}},"parameters":{"EventId":{"name":"event_id","in":"path","required":true,"description":"The event to operate on, from `GET /events`. Tokens are organisation-scoped, so the event always travels in the path — there is no \"current event\" server-side state.","schema":{"type":"string"}},"Cursor":{"name":"cursor","in":"query","required":false,"description":"Opt into keyset pagination, which is stable under concurrent inserts and deletes and is what you want for any loop over a whole resource. Send it EMPTY for the first page, then pass the previous response's `next_cursor`; stop when `next_cursor` is null. Limit caps at 100 in this mode and `offset` comes back null. Omitting the parameter entirely selects legacy offset mode.","schema":{"type":"string"},"example":""},"Limit":{"name":"limit","in":"query","required":false,"description":"Page size. 1–200 in offset mode, 1–100 in cursor mode; out-of-range values clamp rather than error. Default 25.","schema":{"type":"integer","minimum":1,"maximum":200,"default":25}},"Offset":{"name":"offset","in":"query","required":false,"description":"Rows to skip. Offset mode only — ignored, and echoed back as null, when `cursor` is used. Default 0.","schema":{"type":"integer","minimum":0,"default":0}},"IdempotencyKey":{"name":"Idempotency-Key","in":"header","required":false,"description":"Replay-safe retries on POST (PUT and DELETE ignore it). A repeated key, scoped to this credential, returns the original response verbatim with `Idempotency-Replayed: true` instead of executing again; the same key with a different body is 422 `idempotency_mismatch`. Stored 24h, for 2xx and 4xx outcomes alike — so a retried validation error replays as that same error.","schema":{"type":"string"}}},"responses":{"Unauthorized":{"description":"Missing, malformed or revoked credentials (`unauthenticated`, `invalid_token`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Forbidden":{"description":"The credential is valid but cannot reach this event (`forbidden`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"EventNotFound":{"description":"No event with this id (`event_not_found`), or — on a list path — no such resource (`unknown_resource`; the message lists the valid names).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"NotFound":{"description":"No record with this id in this event (`not_found`). Note the scoping: a real id belonging to another event reads as not found, never as forbidden.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"InvalidCursor":{"description":"The `cursor` value is not a cursor this API issued (`invalid_cursor`). Restart the walk with an empty cursor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"ValidationError":{"description":"A field failed validation (`validation`). `error.details[]` names each offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"IdempotencyMismatch":{"description":"This Idempotency-Key was already used with a different request body (`idempotency_mismatch`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"schemas":{"Error":{"type":"object","description":"Every non-2xx response body on this surface.","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Stable machine code — branch on this, never on `message`.","enum":["unauthenticated","invalid_token","forbidden","event_not_found","not_found","unknown_resource","invalid_cursor","validation","invalid_status","email_exists","constraint","idempotency_mismatch","create_failed"]},"message":{"type":"string","description":"Human-readable, may change; not for branching."},"details":{"type":"array","description":"Per-field detail, present on `validation` (400) only.","items":{"type":"object","properties":{"field":{"type":"string"},"message":{"type":"string"}}}}}}}},"Event":{"type":"object","additionalProperties":true,"description":"A conference/event. The unit of scope: every other path names one.","properties":{"id":{"type":"string"},"org_id":{"type":"string","description":"Owning organisation. A bearer token can reach every event with its own org_id."},"name":{"type":"string"},"slug":{"type":"string"},"type":{"type":"string"},"location":{"type":["string","null"]},"timezone":{"type":"string","description":"IANA zone. Schedule columns elsewhere are wall-clock in THIS zone, not the caller's."},"starts_at":{"type":"string","description":"UTC ISO 8601 timestamp."},"ends_at":{"type":"string","description":"UTC ISO 8601 timestamp."},"created_at":{"type":"string","description":"UTC ISO 8601 timestamp."},"updated_at":{"type":"string","description":"UTC ISO 8601 timestamp."}}},"Contact":{"type":"object","additionalProperties":true,"description":"A person on this event's roster. Identity (email, name, pronouns, phone) is ORGANISATION-level and shared by every event the person appears on; profile (company, job_title, biography, notes, headshot) is per-event and lives on the roster row.","properties":{"id":{"type":"string"},"event_id":{"type":"string","description":"The event whose roster row this is."},"email":{"type":"string","format":"email","description":"Lowercased. Unique per organisation."},"first_name":{"type":["string","null"]},"last_name":{"type":["string","null"]},"salutation":{"type":["string","null"]},"honorific":{"type":["string","null"]},"pronouns":{"type":["string","null"]},"gender":{"type":["string","null"]},"mobile_phone":{"type":["string","null"]},"links":{"type":["string","null"],"description":"JSON encoded as a string ({ linkedin, twitter, facebook, website }) — parse it client-side. This surface does not expand JSON columns."},"company":{"type":["string","null"],"description":"Per-event."},"job_title":{"type":["string","null"],"description":"Per-event."},"biography":{"type":["string","null"],"description":"Per-event."},"notes":{"type":["string","null"],"description":"Per-event, internal."},"headshot_asset_id":{"type":["string","null"]},"source":{"type":"string","enum":["import","cfp","admin","migration"],"description":"How they joined this event's roster."},"added_at":{"type":"string","description":"UTC ISO 8601 timestamp."},"created_at":{"type":"string","description":"UTC ISO 8601 timestamp."},"updated_at":{"type":"string","description":"UTC ISO 8601 timestamp."}}},"ContactListRow":{"allOf":[{"$ref":"#/components/schemas/Contact"},{"type":"object","description":"List rows add derived columns the detail endpoint does not compute.","properties":{"event_name":{"type":"string"},"custom_fields_json":{"type":["string","null"],"description":"JSON encoded as a string ({ <custom field key>: value } for this event, null when none are set) — parse it client-side. This surface does not expand JSON columns."},"confirmation":{"type":["string","null"],"enum":["confirmed","awaiting",null],"description":"Derived from participation: confirmed = at least one confirmed participant row; awaiting = a participant, none confirmed; null = not a participant at all (distinct from awaiting)."},"speaker_status":{"type":["string","null"],"description":"The settable speaker workflow status; falls back to the confirmed/awaiting_reply derivation when nothing has been set by hand. Free vocabulary — not a closed enum."}}}]},"Submission":{"type":"object","additionalProperties":true,"description":"A talk/abstract proposal. `status` is the pipeline state; approval, condition, revise and materials are independent FLAGS alongside it — read them, do not infer them from status.","properties":{"id":{"type":"string"},"event_id":{"type":"string"},"code":{"type":"string","description":"Human-facing reference, e.g. SESS-12. Unique within the event."},"kind":{"type":"string","enum":["abstract","session"]},"title":{"type":"string"},"description":{"type":["string","null"]},"status":{"type":"string","enum":["draft","pending","accept_queue","accepted","decline_queue","declined","withdrawn"]},"track_id":{"type":["string","null"]},"format":{"type":["string","null"]},"level":{"type":["string","null"]},"language":{"type":["string","null"]},"capacity":{"type":["integer","null"]},"starts_at":{"type":["string","null"],"description":"Scheduled start, wall-clock in the EVENT timezone. null = unscheduled."},"ends_at":{"type":["string","null"],"description":"UTC ISO 8601 timestamp, or null."},"room_id":{"type":["string","null"]},"submitter_contact_id":{"type":["string","null"]},"notified_at":{"type":["string","null"],"description":"When the decision letter went out. null = the speaker has not been told."},"evaluation_plan_id":{"type":["string","null"]},"rating_cache":{"type":["string","null"],"description":"JSON encoded as a string ({ \"<evaluation_plan_id>\": mean } — raw per-round means on each round's own scale) — parse it client-side. This surface does not expand JSON columns."},"approval_state":{"type":["string","null"],"enum":["pending","granted","refused",null],"description":"Employer approval. null = never asked."},"approval_note":{"type":["string","null"]},"accept_condition":{"type":["string","null"],"description":"Free-text proviso on a conditional accept. Empty/null = unconditional."},"condition_met_at":{"type":["string","null"],"description":"null while the condition is outstanding — that pairing is the chase list."},"decision_outcome":{"type":["string","null"],"enum":["revise",null],"description":"The third outcome. A `revise` row KEEPS its declined/decline_queue status, so find it by this column, never by status."},"revise_guidance":{"type":["string","null"]},"resubmission_of":{"type":["string","null"]},"materials_state":{"type":["string","null"],"enum":["received","reviewed","revision_requested","final",null],"description":"Post-accept editorial state. null = nothing uploaded."},"materials_state_at":{"type":["string","null"],"description":"UTC ISO 8601 timestamp, or null."},"content_approved":{"type":"integer","enum":[0,1],"description":"SQLite boolean: 1 = approved, 0 = not."},"source":{"type":"string","enum":["form","manual","import"]},"notes":{"type":["string","null"]},"extra":{"type":["string","null"],"description":"JSON encoded as a string (importer/source passthrough) — parse it client-side. This surface does not expand JSON columns."},"created_at":{"type":"string","description":"UTC ISO 8601 timestamp."},"updated_at":{"type":"string","description":"UTC ISO 8601 timestamp."}}},"SubmissionListRow":{"allOf":[{"$ref":"#/components/schemas/Submission"},{"type":"object","properties":{"event_name":{"type":"string"},"event_timezone":{"type":"string","description":"Carried per row so a multi-event reader never has to look it up."},"track_name":{"type":["string","null"]},"room_name":{"type":["string","null"]},"plan_name":{"type":["string","null"]},"submitter_name":{"type":["string","null"]},"rating":{"type":["number","null"],"description":"Mean score normalised onto a common 1–5 display scale across every round the submission was scored in. null = never scored."},"review_count":{"type":"integer"}}}]},"SubmissionDetail":{"allOf":[{"$ref":"#/components/schemas/Submission"},{"type":"object","description":"The detail endpoint adds the joined children.","properties":{"track_name":{"type":["string","null"]},"plan_name":{"type":["string","null"]},"form_name":{"type":["string","null"]},"answers":{"type":"array","description":"Form answers in question order. `value` is already parsed (string, number, array or object depending on the field type).","items":{"type":"object","properties":{"label":{"type":"string"},"value":{}}}},"participants":{"type":"array","items":{"$ref":"#/components/schemas/Participant"}},"tags":{"type":"array","items":{"type":"string"},"description":"Tag names, not ids."},"rating":{"type":["number","null"],"description":"Mean weighted_total over this event's rounds, 2dp. null = never scored."},"review_count":{"type":"integer"}}}]},"Participant":{"type":"object","properties":{"contact_id":{"type":"string"},"first_name":{"type":["string","null"]},"last_name":{"type":["string","null"]},"email":{"type":"string"},"role":{"type":"string","enum":["speaker","co-speaker","moderator","panelist"]},"is_primary_contact":{"type":"integer","enum":[0,1],"description":"SQLite boolean: 1 = the primary contact for this submission, 0 = not."}}},"Task":{"type":"object","additionalProperties":true,"description":"A task DEFINITION — the thing speakers get asked to do. Its per-person assignments are a separate resource (see the Tasks tag).","properties":{"id":{"type":"string"},"event_id":{"type":"string"},"title":{"type":"string"},"description":{"type":["string","null"]},"target":{"type":"string","enum":["contact","group","submission"]},"assignment_mode":{"type":"string","enum":["manual","automatic"]},"trigger":{"type":"string","enum":["on_accept","on_schedule","none"]},"action_type":{"type":"string","enum":["file_upload","portal_form","acknowledge","external_link"]},"portal_form_id":{"type":["string","null"]},"file_request_id":{"type":["string","null"]},"due_at":{"type":["string","null"],"description":"UTC ISO 8601 timestamp, or null."},"reminder_offsets_days":{"type":["string","null"],"description":"JSON encoded as a string (int[] — days before due_at to remind) — parse it client-side. This surface does not expand JSON columns."},"required":{"type":"integer","enum":[0,1],"description":"SQLite boolean: 1 = required, 0 = not."},"created_at":{"type":"string","description":"UTC ISO 8601 timestamp."},"updated_at":{"type":["string","null"],"description":"UTC ISO 8601 timestamp, or null."}}},"TaskAssignment":{"type":"object","additionalProperties":true,"description":"One person's copy of a task. This is what `GET /events/{event_id}/tasks` returns — `id` here is the ASSIGNMENT id and cannot be passed to the task write endpoints; use `task_id` for those.","properties":{"id":{"type":"string","description":"Assignment id."},"task_id":{"type":"string","description":"The task definition — the id the PUT/DELETE task endpoints take."},"task_title":{"type":"string"},"status":{"type":"string","enum":["not_started","in_progress","complete"]},"completed_at":{"type":["string","null"],"description":"UTC ISO 8601 timestamp, or null."},"due_at":{"type":["string","null"],"description":"Inherited from the task definition."},"required":{"type":"integer","enum":[0,1],"description":"SQLite boolean: 1 = required, 0 = not."},"action_type":{"type":"string"},"contact_id":{"type":"string"},"assignee_name":{"type":["string","null"]},"assignee_email":{"type":["string","null"]},"submission_id":{"type":["string","null"]},"submission_code":{"type":["string","null"]},"submission_title":{"type":["string","null"]},"event_id":{"type":"string"},"event_name":{"type":"string"}}},"Message":{"type":"object","additionalProperties":true,"description":"One outbound email, as logged. Read-only: this API sends nothing.","properties":{"id":{"type":"string"},"template_key":{"type":["string","null"],"description":"e.g. submission_confirmation, magic_link, task_reminder."},"to_email":{"type":"string"},"contact_id":{"type":["string","null"]},"contact_name":{"type":["string","null"]},"subject":{"type":["string","null"]},"status":{"type":"string","enum":["queued","sent","failed","bounced"]},"body_html":{"type":["string","null"]},"body_text":{"type":["string","null"]},"error":{"type":["string","null"],"description":"Provider error, set when status is failed/bounced."},"created_at":{"type":"string","description":"UTC ISO 8601 timestamp."},"sent_at":{"type":["string","null"],"description":"UTC ISO 8601 timestamp, or null."},"event_id":{"type":"string"},"event_name":{"type":"string"}}},"Review":{"type":"object","additionalProperties":true,"description":"One reviewer's score for one submission in one round. Read-only.","properties":{"id":{"type":"string"},"submission_id":{"type":"string"},"submission_code":{"type":"string"},"submission_title":{"type":"string"},"reviewer_contact_id":{"type":["string","null"]},"reviewer_name":{"type":["string","null"]},"plan_id":{"type":"string","description":"The evaluation round."},"plan_name":{"type":["string","null"]},"weighted_total":{"type":["number","null"],"description":"On THIS round's own scale (scoring_scale_min..max), not normalised — do not average across rounds without rescaling."},"scores":{"type":["string","null"],"description":"JSON encoded as a string ({ \"<criterion_id>\": score }) — parse it client-side. This surface does not expand JSON columns."},"comment":{"type":["string","null"]},"conflict_of_interest":{"type":"integer","enum":[0,1],"description":"SQLite boolean: 1 = the reviewer declared a conflict and did not score, 0 = not."},"created_at":{"type":"string","description":"UTC ISO 8601 timestamp."},"event_id":{"type":"string"},"event_name":{"type":"string"}}},"Comment":{"type":"object","additionalProperties":true,"description":"A committee comment on a submission. Read-only.","properties":{"id":{"type":"string"},"submission_id":{"type":"string"},"submission_code":{"type":"string"},"submission_title":{"type":"string"},"plan_id":{"type":["string","null"]},"assignment_id":{"type":["string","null"]},"author_contact_id":{"type":["string","null"]},"author_role":{"type":["string","null"]},"author_name":{"type":["string","null"]},"kind":{"type":"string","enum":["rationale","discussion"],"description":"rationale = written when saving scores; discussion = a thread reply."},"body":{"type":"string"},"created_at":{"type":"string","description":"UTC ISO 8601 timestamp."},"event_id":{"type":"string"},"event_name":{"type":"string"}}},"FormSummary":{"type":"object","additionalProperties":true,"description":"A submission (CFP) form, list shape.","properties":{"id":{"type":"string"},"internal_name":{"type":"string","description":"Organiser-facing name."},"external_title":{"type":["string","null"]},"collection_type":{"type":"string","enum":["abstracts","sessions"]},"status":{"type":"string","enum":["open","closed"]},"close_at":{"type":["string","null"],"description":"UTC ISO 8601 timestamp, or null."},"question_count":{"type":"integer"},"submission_count":{"type":"integer"},"created_at":{"type":"string","description":"UTC ISO 8601 timestamp."},"updated_at":{"type":"string","description":"UTC ISO 8601 timestamp."}}},"FormDetail":{"allOf":[{"$ref":"#/components/schemas/FormSummary"},{"type":"object","properties":{"page_heading":{"type":["string","null"]},"welcome_message":{"type":["string","null"]},"success_message":{"type":["string","null"]},"submission_limit":{"type":["integer","null"],"description":"null = inherit the event default."},"collect_participants":{"type":"integer","enum":[0,1],"description":"SQLite boolean: 1 = the form collects co-speakers, 0 = not."},"routing_rules":{"type":["string","null"],"description":"JSON encoded as a string (RoutingRule[]) — parse it client-side. This surface does not expand JSON columns."},"cross_field_limits":{"type":["string","null"],"description":"JSON encoded as a string (cross-field character limits) — parse it client-side. This surface does not expand JSON columns."},"notify_admins_on_create":{"type":["string","null"],"description":"JSON encoded as a string (contact id[]) — parse it client-side. This surface does not expand JSON columns."},"notify_admins_on_update":{"type":["string","null"],"description":"JSON encoded as a string (contact id[]) — parse it client-side. This surface does not expand JSON columns."},"participant_roles":{"type":["string","null"],"description":"JSON encoded as a string (role[] offered by this form) — parse it client-side. This surface does not expand JSON columns."},"questions":{"type":"array","items":{"$ref":"#/components/schemas/FormQuestion"}}}}]},"FormQuestion":{"type":"object","additionalProperties":true,"properties":{"id":{"type":"string"},"form_id":{"type":"string"},"section":{"type":"string","enum":["abstract","participant"]},"field_id":{"type":"string","description":"The underlying field definition — the answer label a submission returns comes from here."},"label":{"type":["string","null"]},"help_text":{"type":["string","null"]},"position":{"type":"integer"},"required":{"type":"integer","enum":[0,1],"description":"SQLite boolean: 1 = required, 0 = not."},"locked":{"type":"integer","enum":[0,1],"description":"SQLite boolean: 1 = locked (cannot be removed from the form), 0 = not."},"options":{"type":["string","null"],"description":"JSON encoded as a string ([{ value, label, color? }]) — parse it client-side. This surface does not expand JSON columns."},"max_chars":{"type":["integer","null"]},"visibility":{"type":["string","null"],"description":"JSON encoded as a string (ConditionalRule — when this question is shown) — parse it client-side. This surface does not expand JSON columns."}}},"DeleteResult":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]}}},"StatusResult":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]},"status":{"type":"string","enum":["draft","pending","accept_queue","accepted","decline_queue","declined","withdrawn"]}}}}},"paths":{"/openapi.json":{"get":{"operationId":"getOpenApiDocument","summary":"This document","description":"The spec itself, generated live from the running registry. Public — no credential needed, so a client can discover the surface before it has a token.","tags":["Discovery"],"security":[],"responses":{"200":{"description":"The OpenAPI 3.1 document.","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/events":{"get":{"operationId":"listEvents","summary":"List accessible events","description":"Start here. Every event the credential can reach — the whole organisation for a bearer token, exactly one for a first-party session. Every other path needs an `event_id` from this list. Not paginated.","tags":["Events"],"responses":{"200":{"description":"Events, earliest start first.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Event"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/events/{event_id}":{"get":{"operationId":"getEvent","summary":"Get one event","description":"Chiefly useful for `timezone` — scheduled times on submissions are wall-clock in it.","tags":["Events"],"parameters":[{"$ref":"#/components/parameters/EventId"}],"responses":{"200":{"description":"The event.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/EventNotFound"}}}},"/events/{event_id}/contacts":{"get":{"operationId":"listContacts","summary":"List contacts","description":"One row per person per event roster. A person on three events has three rows, each with its own company/job_title/biography.\n\nFilterable, sortable, paginated. Filters combine with AND; an unknown filter name is ignored, never an error — check `total` if you are unsure a filter applied. The admin workspace runs this exact query, so what you read here is what an organiser sees.\n\nNot available over HTTP: `events` (applies only to the SPA's organisation-directory mode, which this surface does not expose); `contact_ids` (takes an array, which a query string cannot carry).","tags":["Contacts"],"parameters":[{"$ref":"#/components/parameters/EventId"},{"name":"q","in":"query","required":false,"description":"Free-text match over first name, last name, email and company.","schema":{"type":"string"}},{"name":"submission_id","in":"query","required":false,"description":"Contacts related to this submission: its participants (any role) or its submitter.","schema":{"type":"string"}},{"name":"contact_id","in":"query","required":false,"description":"Exactly this contact. The global anchor filter uses this.","schema":{"type":"string"}},{"name":"company","in":"query","required":false,"description":"Substring match on company — this event's answer normally, the most recent membership's under scope:'org'.","schema":{"type":"string"}},{"name":"job_title","in":"query","required":false,"description":"Substring match on job title — this event's answer normally, the most recent membership's under scope:'org'.","schema":{"type":"string"}},{"name":"confirmation","in":"query","required":false,"description":"confirmed → has a submission_participants row with confirmed_at set. awaiting → is a participant somewhere but none of those rows are confirmed. Not a participant at all is neither (its `confirmation` column reads null) and this filter never matches it.","schema":{"type":"string","enum":["confirmed","awaiting"]}},{"name":"missing_assets","in":"query","required":false,"description":"true → accepted speakers missing a biography or headshot (the programme-completeness list).","schema":{"type":"boolean"}},{"name":"segment_id","in":"query","required":false,"description":"Members of this saved segment (CRM-09), by id.","schema":{"type":"string"}},{"name":"speaker_status","in":"query","required":false,"description":"Exact speaker_status (SPK-04): a hand-set value on the roster row, or when unset the same confirmed/awaiting_reply derivation the `speaker_status` column reads. No vocabulary check — an unrecognised value just matches nothing.","schema":{"type":"string"}},{"name":"sort","in":"query","required":false,"description":"Sort field; prefix with \"-\" for descending. An unrecognised value is ignored (offset mode falls back to the resource default, cursor mode to id order) rather than erroring.","schema":{"type":"string","enum":["first_name","-first_name","last_name","-last_name","email","-email","company","-company","job_title","-job_title","created_at","-created_at"]},"example":"-first_name"},{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Offset"}],"responses":{"200":{"description":"Contacts rows.","content":{"application/json":{"schema":{"type":"object","required":["data","total","limit","has_more"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ContactListRow"}},"total":{"type":"integer","description":"Rows matching the filters, ignoring pagination."},"limit":{"type":"integer"},"offset":{"type":["integer","null"],"description":"null in cursor mode."},"has_more":{"type":"boolean"},"next_cursor":{"type":["string","null"],"description":"Pass back as ?cursor= for the next page; null on the last page. Always null in offset mode."}}}}}},"400":{"$ref":"#/components/responses/InvalidCursor"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/EventNotFound"}}},"post":{"operationId":"createContact","summary":"Create a contact, or add an existing one to this event","description":"Idempotent in spirit as well as by header: if the organisation already knows this email, the existing person is attached to this event rather than duplicated, their per-event profile seeded from their most recent other event, and supplied identity fields only fill blanks.","tags":["Contacts"],"parameters":[{"$ref":"#/components/parameters/EventId"},{"$ref":"#/components/parameters/IdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"description":"The writable fields. The body is flat, but the two halves land in different places: identity is shared across the organisation, profile is recorded against this event only.","properties":{"email":{"type":"string","format":"email","description":"Required on create; lowercased. Unique per ORGANISATION, not per event — one person, every event. Creating with an email already on THIS event is 409 `email_exists`; an email known to the org but not this event attaches that existing person to this event instead of creating a duplicate."},"first_name":{"type":["string","null"]},"last_name":{"type":["string","null"]},"mobile_phone":{"type":["string","null"]},"pronouns":{"type":["string","null"]},"company":{"type":["string","null"],"description":"Per-event."},"job_title":{"type":["string","null"],"description":"Per-event."},"biography":{"type":["string","null"],"description":"Per-event."}}},"example":{"email":"ada@example.com","first_name":"Ada","last_name":"Lovelace","company":"Analytical Engines"}}}},"responses":{"201":{"description":"Created, or an existing organisation contact added to this event.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Contact"}}},"headers":{"Idempotency-Replayed":{"description":"Present and `true` only when this response was replayed from a previous request with the same Idempotency-Key.","schema":{"type":"string","enum":["true"]}}}},"400":{"$ref":"#/components/responses/ValidationError"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/EventNotFound"},"409":{"description":"A contact with this email is already on this event (`email_exists`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"$ref":"#/components/responses/IdempotencyMismatch"}}}},"/events/{event_id}/contacts/export":{"get":{"operationId":"exportContacts","summary":"Export contacts (CSV/XLSX)","description":"Byte-for-byte the file the workspace export button produces, honouring the same filters — the right endpoint when a human wants the result, and a poor one when your code wants the data (use the list endpoint, which paginates and returns JSON). Up to 10,000 rows; no pagination. Submissions gain a derived `outcome` column and reviews readable criterion names, so the file is not simply the list rows as CSV.","tags":["Contacts"],"parameters":[{"$ref":"#/components/parameters/EventId"},{"name":"q","in":"query","required":false,"description":"Free-text match over first name, last name, email and company.","schema":{"type":"string"}},{"name":"submission_id","in":"query","required":false,"description":"Contacts related to this submission: its participants (any role) or its submitter.","schema":{"type":"string"}},{"name":"contact_id","in":"query","required":false,"description":"Exactly this contact. The global anchor filter uses this.","schema":{"type":"string"}},{"name":"company","in":"query","required":false,"description":"Substring match on company — this event's answer normally, the most recent membership's under scope:'org'.","schema":{"type":"string"}},{"name":"job_title","in":"query","required":false,"description":"Substring match on job title — this event's answer normally, the most recent membership's under scope:'org'.","schema":{"type":"string"}},{"name":"confirmation","in":"query","required":false,"description":"confirmed → has a submission_participants row with confirmed_at set. awaiting → is a participant somewhere but none of those rows are confirmed. Not a participant at all is neither (its `confirmation` column reads null) and this filter never matches it.","schema":{"type":"string","enum":["confirmed","awaiting"]}},{"name":"missing_assets","in":"query","required":false,"description":"true → accepted speakers missing a biography or headshot (the programme-completeness list).","schema":{"type":"boolean"}},{"name":"segment_id","in":"query","required":false,"description":"Members of this saved segment (CRM-09), by id.","schema":{"type":"string"}},{"name":"speaker_status","in":"query","required":false,"description":"Exact speaker_status (SPK-04): a hand-set value on the roster row, or when unset the same confirmed/awaiting_reply derivation the `speaker_status` column reads. No vocabulary check — an unrecognised value just matches nothing.","schema":{"type":"string"}},{"name":"format","in":"query","required":false,"description":"csv (default) or xlsx.","schema":{"type":"string","enum":["csv","xlsx"],"default":"csv"}},{"name":"sort","in":"query","required":false,"description":"Same vocabulary as the list endpoint.","schema":{"type":"string","enum":["first_name","-first_name","last_name","-last_name","email","-email","company","-company","job_title","-job_title","created_at","-created_at"]}},{"name":"limit","in":"query","required":false,"description":"Row cap, 1–10000. Default 10000.","schema":{"type":"integer","minimum":1,"maximum":10000}}],"responses":{"200":{"description":"The file, as an attachment (Content-Disposition names it `<event-slug>-<resource>-<date>.<ext>`).","content":{"text/csv":{"schema":{"type":"string"}},"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":{"schema":{"type":"string","format":"binary"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/EventNotFound"}}}},"/events/{event_id}/submissions":{"get":{"operationId":"listSubmissions","summary":"List submissions","description":"The CFP pipeline. Filter on the flag columns (approval_state, decision_outcome, materials_state, has_condition) rather than reading them out of `status`.\n\nFilterable, sortable, paginated. Filters combine with AND; an unknown filter name is ignored, never an error — check `total` if you are unsure a filter applied. The admin workspace runs this exact query, so what you read here is what an organiser sees.","tags":["Submissions"],"parameters":[{"$ref":"#/components/parameters/EventId"},{"name":"q","in":"query","required":false,"description":"Free-text match over title and code.","schema":{"type":"string"}},{"name":"status","in":"query","required":false,"description":"Exact status: draft | pending | accept_queue | accepted | decline_queue | declined | withdrawn.","schema":{"type":"string","enum":["draft","pending","accept_queue","accepted","decline_queue","declined","withdrawn"]}},{"name":"min_reviews","in":"query","required":false,"description":"Submissions with at least this many recorded reviews.","schema":{"type":"integer","minimum":0}},{"name":"max_reviews","in":"query","required":false,"description":"Submissions with at most this many recorded reviews — max_reviews=1 is the coverage worklist (\"everything with fewer than two reads\").","schema":{"type":"integer","minimum":0}},{"name":"approval_state","in":"query","required":false,"description":"Exact employer-approval flag: pending | granted | refused, or none for submissions where approval was never asked (NULL). Independent of status (D4).","schema":{"type":"string","enum":["pending","granted","refused","none"]}},{"name":"materials_state","in":"query","required":false,"description":"Exact post-accept materials flag: received | reviewed | revision_requested | final, or none for an accepted talk with no upload at all (NULL). Independent of status (D9).","schema":{"type":"string","enum":["received","reviewed","revision_requested","final","none"]}},{"name":"has_condition","in":"query","required":false,"description":"true → the accept carries a condition (\"accepted if you bring a business co-presenter\"); false → it does not. Independent of status (D4).","schema":{"type":"boolean"}},{"name":"condition_outstanding","in":"query","required":false,"description":"true → carries a condition nobody has marked met yet (condition_met_at IS NULL) — the chase list; false → carries one that has been met.","schema":{"type":"boolean"}},{"name":"decision_outcome","in":"query","required":false,"description":"Exact third-outcome flag: revise, or none for the ordinary accept/decline (NULL). A revise row keeps its declined/decline_queue status (D5), so filter on this rather than on status to find it.","schema":{"type":"string","enum":["revise","none"]}},{"name":"track_id","in":"query","required":false,"description":"Submissions on this track.","schema":{"type":"string"}},{"name":"tag_id","in":"query","required":false,"description":"Submissions carrying this tag.","schema":{"type":"string"}},{"name":"submitter_contact_id","in":"query","required":false,"description":"Submissions this contact submitted (the narrow relation).","schema":{"type":"string"}},{"name":"participant_contact_id","in":"query","required":false,"description":"Submissions this contact appears on as a participant, any role (the narrow relation).","schema":{"type":"string"}},{"name":"contact_id","in":"query","required":false,"description":"Submissions that are this contact's in the broad sense: submitted by them OR with them as a participant. The global anchor filter uses this.","schema":{"type":"string"}},{"name":"reviewed_by","in":"query","required":false,"description":"Submissions this contact has recorded a review on.","schema":{"type":"string"}},{"name":"decision_accepted","in":"query","required":false,"description":"true → submissions already decided as accepted, queued or sent (status accepted or accept_queue). The slot counter's count (workplan 15 D2).","schema":{"type":"boolean"}},{"name":"sort","in":"query","required":false,"description":"Sort field; prefix with \"-\" for descending. An unrecognised value is ignored (offset mode falls back to the resource default, cursor mode to id order) rather than erroring.","schema":{"type":"string","enum":["code","-code","title","-title","status","-status","format","-format","track_name","-track_name","submitter_name","-submitter_name","created_at","-created_at","notified_at","-notified_at","starts_at","-starts_at","room_name","-room_name","approval_state","-approval_state","condition_met_at","-condition_met_at","materials_state","-materials_state","materials_state_at","-materials_state_at","review_count","-review_count","rating","-rating"]},"example":"-code"},{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Offset"}],"responses":{"200":{"description":"Submissions rows.","content":{"application/json":{"schema":{"type":"object","required":["data","total","limit","has_more"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/SubmissionListRow"}},"total":{"type":"integer","description":"Rows matching the filters, ignoring pagination."},"limit":{"type":"integer"},"offset":{"type":["integer","null"],"description":"null in cursor mode."},"has_more":{"type":"boolean"},"next_cursor":{"type":["string","null"],"description":"Pass back as ?cursor= for the next page; null on the last page. Always null in offset mode."}}}}}},"400":{"$ref":"#/components/responses/InvalidCursor"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/EventNotFound"}}},"post":{"operationId":"createSubmission","summary":"Create a submission (manual)","description":"An admin-authored proposal, not one that came through a public form. `status` starts `pending`, `source` is `manual`, and `code` is allocated as SESS-<n> by the same allocator the rest of the app uses. Answers and participants cannot be attached here — a submission that needs them belongs on a form. Tags go on afterwards with PUT /events/{event_id}/submissions/{id}/tags.","tags":["Submissions"],"parameters":[{"$ref":"#/components/parameters/EventId"},{"$ref":"#/components/parameters/IdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"title":{"type":"string","description":"Required on create; must be non-empty."},"description":{"type":["string","null"]},"format":{"type":["string","null"]},"level":{"type":["string","null"]},"language":{"type":["string","null"]},"track_id":{"type":["string","null"],"description":"Must belong to this event, else 400. null clears it."},"status":{"type":"string","enum":["draft","pending","accept_queue","accepted","decline_queue","declined","withdrawn"],"description":"Update only — ignored on create, which always starts `pending`."}},"additionalProperties":false,"description":"Only these fields are writable. Anything else in the body is ignored — the schedule, decision flags, answers and participants are not settable through this API. Tags have their own endpoint (PUT /events/{event_id}/submissions/{id}/tags)."},"example":{"title":"Shipping on Fridays","description":"A field report.","format":"talk","level":"intermediate"}}}},"responses":{"201":{"description":"Created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Submission"}}},"headers":{"Idempotency-Replayed":{"description":"Present and `true` only when this response was replayed from a previous request with the same Idempotency-Key.","schema":{"type":"string","enum":["true"]}}}},"400":{"$ref":"#/components/responses/ValidationError"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/EventNotFound"},"422":{"$ref":"#/components/responses/IdempotencyMismatch"}}}},"/events/{event_id}/submissions/export":{"get":{"operationId":"exportSubmissions","summary":"Export submissions (CSV/XLSX)","description":"Byte-for-byte the file the workspace export button produces, honouring the same filters — the right endpoint when a human wants the result, and a poor one when your code wants the data (use the list endpoint, which paginates and returns JSON). Up to 10,000 rows; no pagination. Submissions gain a derived `outcome` column and reviews readable criterion names, so the file is not simply the list rows as CSV.","tags":["Submissions"],"parameters":[{"$ref":"#/components/parameters/EventId"},{"name":"q","in":"query","required":false,"description":"Free-text match over title and code.","schema":{"type":"string"}},{"name":"status","in":"query","required":false,"description":"Exact status: draft | pending | accept_queue | accepted | decline_queue | declined | withdrawn.","schema":{"type":"string","enum":["draft","pending","accept_queue","accepted","decline_queue","declined","withdrawn"]}},{"name":"min_reviews","in":"query","required":false,"description":"Submissions with at least this many recorded reviews.","schema":{"type":"integer","minimum":0}},{"name":"max_reviews","in":"query","required":false,"description":"Submissions with at most this many recorded reviews — max_reviews=1 is the coverage worklist (\"everything with fewer than two reads\").","schema":{"type":"integer","minimum":0}},{"name":"approval_state","in":"query","required":false,"description":"Exact employer-approval flag: pending | granted | refused, or none for submissions where approval was never asked (NULL). Independent of status (D4).","schema":{"type":"string","enum":["pending","granted","refused","none"]}},{"name":"materials_state","in":"query","required":false,"description":"Exact post-accept materials flag: received | reviewed | revision_requested | final, or none for an accepted talk with no upload at all (NULL). Independent of status (D9).","schema":{"type":"string","enum":["received","reviewed","revision_requested","final","none"]}},{"name":"has_condition","in":"query","required":false,"description":"true → the accept carries a condition (\"accepted if you bring a business co-presenter\"); false → it does not. Independent of status (D4).","schema":{"type":"boolean"}},{"name":"condition_outstanding","in":"query","required":false,"description":"true → carries a condition nobody has marked met yet (condition_met_at IS NULL) — the chase list; false → carries one that has been met.","schema":{"type":"boolean"}},{"name":"decision_outcome","in":"query","required":false,"description":"Exact third-outcome flag: revise, or none for the ordinary accept/decline (NULL). A revise row keeps its declined/decline_queue status (D5), so filter on this rather than on status to find it.","schema":{"type":"string","enum":["revise","none"]}},{"name":"track_id","in":"query","required":false,"description":"Submissions on this track.","schema":{"type":"string"}},{"name":"tag_id","in":"query","required":false,"description":"Submissions carrying this tag.","schema":{"type":"string"}},{"name":"submitter_contact_id","in":"query","required":false,"description":"Submissions this contact submitted (the narrow relation).","schema":{"type":"string"}},{"name":"participant_contact_id","in":"query","required":false,"description":"Submissions this contact appears on as a participant, any role (the narrow relation).","schema":{"type":"string"}},{"name":"contact_id","in":"query","required":false,"description":"Submissions that are this contact's in the broad sense: submitted by them OR with them as a participant. The global anchor filter uses this.","schema":{"type":"string"}},{"name":"reviewed_by","in":"query","required":false,"description":"Submissions this contact has recorded a review on.","schema":{"type":"string"}},{"name":"decision_accepted","in":"query","required":false,"description":"true → submissions already decided as accepted, queued or sent (status accepted or accept_queue). The slot counter's count (workplan 15 D2).","schema":{"type":"boolean"}},{"name":"format","in":"query","required":false,"description":"csv (default) or xlsx.","schema":{"type":"string","enum":["csv","xlsx"],"default":"csv"}},{"name":"sort","in":"query","required":false,"description":"Same vocabulary as the list endpoint.","schema":{"type":"string","enum":["code","-code","title","-title","status","-status","format","-format","track_name","-track_name","submitter_name","-submitter_name","created_at","-created_at","notified_at","-notified_at","starts_at","-starts_at","room_name","-room_name","approval_state","-approval_state","condition_met_at","-condition_met_at","materials_state","-materials_state","materials_state_at","-materials_state_at","review_count","-review_count","rating","-rating"]}},{"name":"limit","in":"query","required":false,"description":"Row cap, 1–10000. Default 10000.","schema":{"type":"integer","minimum":1,"maximum":10000}}],"responses":{"200":{"description":"The file, as an attachment (Content-Disposition names it `<event-slug>-<resource>-<date>.<ext>`).","content":{"text/csv":{"schema":{"type":"string"}},"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":{"schema":{"type":"string","format":"binary"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/EventNotFound"}}}},"/events/{event_id}/tasks":{"get":{"operationId":"listTasks","summary":"List tasks","description":"Rows are task ASSIGNMENTS (one per person per task), not task definitions. `id` is the assignment id; `task_id` is what the task write endpoints take.\n\nFilterable, sortable, paginated. Filters combine with AND; an unknown filter name is ignored, never an error — check `total` if you are unsure a filter applied. The admin workspace runs this exact query, so what you read here is what an organiser sees.","tags":["Tasks"],"parameters":[{"$ref":"#/components/parameters/EventId"},{"name":"q","in":"query","required":false,"description":"Free-text match over task title and assignee name/email.","schema":{"type":"string"}},{"name":"status","in":"query","required":false,"description":"Exact assignment status: not_started | in_progress | complete.","schema":{"type":"string","enum":["not_started","in_progress","complete"]}},{"name":"task_id","in":"query","required":false,"description":"Assignments of this task.","schema":{"type":"string"}},{"name":"contact_id","in":"query","required":false,"description":"Assignments belonging to this contact. The global anchor filter uses this.","schema":{"type":"string"}},{"name":"submission_id","in":"query","required":false,"description":"Assignments tied to this submission. The global anchor filter uses this.","schema":{"type":"string"}},{"name":"overdue","in":"query","required":false,"description":"true → incomplete assignments past their due date.","schema":{"type":"boolean"}},{"name":"sort","in":"query","required":false,"description":"Sort field; prefix with \"-\" for descending. An unrecognised value is ignored (offset mode falls back to the resource default, cursor mode to id order) rather than erroring.","schema":{"type":"string","enum":["task_title","-task_title","assignee_name","-assignee_name","status","-status","due_at","-due_at","completed_at","-completed_at"]},"example":"-task_title"},{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Offset"}],"responses":{"200":{"description":"Tasks rows.","content":{"application/json":{"schema":{"type":"object","required":["data","total","limit","has_more"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/TaskAssignment"}},"total":{"type":"integer","description":"Rows matching the filters, ignoring pagination."},"limit":{"type":"integer"},"offset":{"type":["integer","null"],"description":"null in cursor mode."},"has_more":{"type":"boolean"},"next_cursor":{"type":["string","null"],"description":"Pass back as ?cursor= for the next page; null on the last page. Always null in offset mode."}}}}}},"400":{"$ref":"#/components/responses/InvalidCursor"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/EventNotFound"}}},"post":{"operationId":"createTask","summary":"Create a task definition","description":"Creates the task itself. It has no assignees yet — assigning people to it is an admin-app action, so a task created here shows up in the workspace but sends nothing until someone assigns or a trigger fires. Note the asymmetry with `GET /events/{event_id}/tasks`, which lists assignments.","tags":["Tasks"],"parameters":[{"$ref":"#/components/parameters/EventId"},{"$ref":"#/components/parameters/IdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"properties":{"title":{"type":"string","description":"Required on create."},"description":{"type":["string","null"]},"target":{"type":"string","enum":["contact","group","submission"],"description":"What the task hangs off. Default contact."},"assignment_mode":{"type":"string","enum":["manual","automatic"],"description":"automatic = assigned by the trigger; manual = assigned by hand in the app. Default manual."},"trigger":{"type":"string","enum":["on_accept","on_schedule","none"],"description":"When automatic assignment fires. Default none."},"action_type":{"type":"string","enum":["file_upload","portal_form","acknowledge","external_link"],"description":"What the assignee does. Default acknowledge."},"portal_form_id":{"type":["string","null"],"description":"Must belong to this event. Required in practice when action_type is portal_form."},"file_request_id":{"type":["string","null"],"description":"Must belong to this event. Required in practice when action_type is file_upload."},"due_at":{"type":["string","null"],"format":"date-time","description":"ISO 8601."},"reminder_offsets_days":{"type":["array","null"],"items":{"type":"integer"},"description":"Days before due_at to remind, e.g. [14, 3]. Sent as a real array here; read back as a JSON string."},"required":{"type":"boolean"}}},"example":{"title":"Upload your slides","action_type":"file_upload","trigger":"on_accept","due_at":"2026-09-01T00:00:00.000Z","reminder_offsets_days":[14,3],"required":true}}}},"responses":{"201":{"description":"Created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Task"}}},"headers":{"Idempotency-Replayed":{"description":"Present and `true` only when this response was replayed from a previous request with the same Idempotency-Key.","schema":{"type":"string","enum":["true"]}}}},"400":{"$ref":"#/components/responses/ValidationError"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/EventNotFound"},"422":{"$ref":"#/components/responses/IdempotencyMismatch"}}}},"/events/{event_id}/tasks/export":{"get":{"operationId":"exportTasks","summary":"Export tasks (CSV/XLSX)","description":"Byte-for-byte the file the workspace export button produces, honouring the same filters — the right endpoint when a human wants the result, and a poor one when your code wants the data (use the list endpoint, which paginates and returns JSON). Up to 10,000 rows; no pagination. Submissions gain a derived `outcome` column and reviews readable criterion names, so the file is not simply the list rows as CSV.","tags":["Tasks"],"parameters":[{"$ref":"#/components/parameters/EventId"},{"name":"q","in":"query","required":false,"description":"Free-text match over task title and assignee name/email.","schema":{"type":"string"}},{"name":"status","in":"query","required":false,"description":"Exact assignment status: not_started | in_progress | complete.","schema":{"type":"string","enum":["not_started","in_progress","complete"]}},{"name":"task_id","in":"query","required":false,"description":"Assignments of this task.","schema":{"type":"string"}},{"name":"contact_id","in":"query","required":false,"description":"Assignments belonging to this contact. The global anchor filter uses this.","schema":{"type":"string"}},{"name":"submission_id","in":"query","required":false,"description":"Assignments tied to this submission. The global anchor filter uses this.","schema":{"type":"string"}},{"name":"overdue","in":"query","required":false,"description":"true → incomplete assignments past their due date.","schema":{"type":"boolean"}},{"name":"format","in":"query","required":false,"description":"csv (default) or xlsx.","schema":{"type":"string","enum":["csv","xlsx"],"default":"csv"}},{"name":"sort","in":"query","required":false,"description":"Same vocabulary as the list endpoint.","schema":{"type":"string","enum":["task_title","-task_title","assignee_name","-assignee_name","status","-status","due_at","-due_at","completed_at","-completed_at"]}},{"name":"limit","in":"query","required":false,"description":"Row cap, 1–10000. Default 10000.","schema":{"type":"integer","minimum":1,"maximum":10000}}],"responses":{"200":{"description":"The file, as an attachment (Content-Disposition names it `<event-slug>-<resource>-<date>.<ext>`).","content":{"text/csv":{"schema":{"type":"string"}},"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":{"schema":{"type":"string","format":"binary"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/EventNotFound"}}}},"/events/{event_id}/messages":{"get":{"operationId":"listMessages","summary":"List messages","description":"The outbound email log. This API cannot send mail; it can only read what was sent.\n\nFilterable, sortable, paginated. Filters combine with AND; an unknown filter name is ignored, never an error — check `total` if you are unsure a filter applied. The admin workspace runs this exact query, so what you read here is what an organiser sees.","tags":["Messages"],"parameters":[{"$ref":"#/components/parameters/EventId"},{"name":"q","in":"query","required":false,"description":"Free-text match over recipient email and subject.","schema":{"type":"string"}},{"name":"template_key","in":"query","required":false,"description":"Exact template key, e.g. submission_confirmation, magic_link, task_reminder.","schema":{"type":"string"}},{"name":"status","in":"query","required":false,"description":"Exact status: queued | sent | failed | bounced.","schema":{"type":"string","enum":["queued","sent","failed","bounced"]}},{"name":"contact_id","in":"query","required":false,"description":"Messages sent to this contact. The global anchor filter uses this.","schema":{"type":"string"}},{"name":"submission_id","in":"query","required":false,"description":"Messages sent to this submission's contacts (its submitter or any participant). The global anchor filter uses this.","schema":{"type":"string"}},{"name":"sort","in":"query","required":false,"description":"Sort field; prefix with \"-\" for descending. An unrecognised value is ignored (offset mode falls back to the resource default, cursor mode to id order) rather than erroring.","schema":{"type":"string","enum":["created_at","-created_at","sent_at","-sent_at","template_key","-template_key","to_email","-to_email","status","-status"]},"example":"-created_at"},{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Offset"}],"responses":{"200":{"description":"Messages rows.","content":{"application/json":{"schema":{"type":"object","required":["data","total","limit","has_more"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Message"}},"total":{"type":"integer","description":"Rows matching the filters, ignoring pagination."},"limit":{"type":"integer"},"offset":{"type":["integer","null"],"description":"null in cursor mode."},"has_more":{"type":"boolean"},"next_cursor":{"type":["string","null"],"description":"Pass back as ?cursor= for the next page; null on the last page. Always null in offset mode."}}}}}},"400":{"$ref":"#/components/responses/InvalidCursor"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/EventNotFound"}}}},"/events/{event_id}/messages/export":{"get":{"operationId":"exportMessages","summary":"Export messages (CSV/XLSX)","description":"Byte-for-byte the file the workspace export button produces, honouring the same filters — the right endpoint when a human wants the result, and a poor one when your code wants the data (use the list endpoint, which paginates and returns JSON). Up to 10,000 rows; no pagination. Submissions gain a derived `outcome` column and reviews readable criterion names, so the file is not simply the list rows as CSV.","tags":["Messages"],"parameters":[{"$ref":"#/components/parameters/EventId"},{"name":"q","in":"query","required":false,"description":"Free-text match over recipient email and subject.","schema":{"type":"string"}},{"name":"template_key","in":"query","required":false,"description":"Exact template key, e.g. submission_confirmation, magic_link, task_reminder.","schema":{"type":"string"}},{"name":"status","in":"query","required":false,"description":"Exact status: queued | sent | failed | bounced.","schema":{"type":"string","enum":["queued","sent","failed","bounced"]}},{"name":"contact_id","in":"query","required":false,"description":"Messages sent to this contact. The global anchor filter uses this.","schema":{"type":"string"}},{"name":"submission_id","in":"query","required":false,"description":"Messages sent to this submission's contacts (its submitter or any participant). The global anchor filter uses this.","schema":{"type":"string"}},{"name":"format","in":"query","required":false,"description":"csv (default) or xlsx.","schema":{"type":"string","enum":["csv","xlsx"],"default":"csv"}},{"name":"sort","in":"query","required":false,"description":"Same vocabulary as the list endpoint.","schema":{"type":"string","enum":["created_at","-created_at","sent_at","-sent_at","template_key","-template_key","to_email","-to_email","status","-status"]}},{"name":"limit","in":"query","required":false,"description":"Row cap, 1–10000. Default 10000.","schema":{"type":"integer","minimum":1,"maximum":10000}}],"responses":{"200":{"description":"The file, as an attachment (Content-Disposition names it `<event-slug>-<resource>-<date>.<ext>`).","content":{"text/csv":{"schema":{"type":"string"}},"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":{"schema":{"type":"string","format":"binary"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/EventNotFound"}}}},"/events/{event_id}/reviews":{"get":{"operationId":"listReviews","summary":"List reviews","description":"One row per reviewer per submission per round. `weighted_total` is on that round's own scale — use the submission list's `rating` for a cross-round number.\n\nFilterable, sortable, paginated. Filters combine with AND; an unknown filter name is ignored, never an error — check `total` if you are unsure a filter applied. The admin workspace runs this exact query, so what you read here is what an organiser sees.","tags":["Reviews"],"parameters":[{"$ref":"#/components/parameters/EventId"},{"name":"submission_id","in":"query","required":false,"description":"Reviews of this submission. The global anchor filter uses this.","schema":{"type":"string"}},{"name":"plan_id","in":"query","required":false,"description":"Reviews recorded in this evaluation round.","schema":{"type":"string"}},{"name":"reviewer_contact_id","in":"query","required":false,"description":"Reviews written by this reviewer.","schema":{"type":"string"}},{"name":"contact_id","in":"query","required":false,"description":"Reviews of this contact's submissions (submitted by them OR with them as a participant — the same broad relation as the submissions resource's contact_id). The global anchor filter uses this.","schema":{"type":"string"}},{"name":"conflict_of_interest","in":"query","required":false,"description":"true → only reviews where the reviewer declared a conflict of interest; false → only scored reviews.","schema":{"type":"boolean"}},{"name":"q","in":"query","required":false,"description":"Free-text match over the review comment.","schema":{"type":"string"}},{"name":"sort","in":"query","required":false,"description":"Sort field; prefix with \"-\" for descending. An unrecognised value is ignored (offset mode falls back to the resource default, cursor mode to id order) rather than erroring.","schema":{"type":"string","enum":["created_at","-created_at","weighted_total","-weighted_total","reviewer_name","-reviewer_name","submission_code","-submission_code"]},"example":"-created_at"},{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Offset"}],"responses":{"200":{"description":"Reviews rows.","content":{"application/json":{"schema":{"type":"object","required":["data","total","limit","has_more"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Review"}},"total":{"type":"integer","description":"Rows matching the filters, ignoring pagination."},"limit":{"type":"integer"},"offset":{"type":["integer","null"],"description":"null in cursor mode."},"has_more":{"type":"boolean"},"next_cursor":{"type":["string","null"],"description":"Pass back as ?cursor= for the next page; null on the last page. Always null in offset mode."}}}}}},"400":{"$ref":"#/components/responses/InvalidCursor"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/EventNotFound"}}}},"/events/{event_id}/reviews/export":{"get":{"operationId":"exportReviews","summary":"Export reviews (CSV/XLSX)","description":"Byte-for-byte the file the workspace export button produces, honouring the same filters — the right endpoint when a human wants the result, and a poor one when your code wants the data (use the list endpoint, which paginates and returns JSON). Up to 10,000 rows; no pagination. Submissions gain a derived `outcome` column and reviews readable criterion names, so the file is not simply the list rows as CSV.","tags":["Reviews"],"parameters":[{"$ref":"#/components/parameters/EventId"},{"name":"submission_id","in":"query","required":false,"description":"Reviews of this submission. The global anchor filter uses this.","schema":{"type":"string"}},{"name":"plan_id","in":"query","required":false,"description":"Reviews recorded in this evaluation round.","schema":{"type":"string"}},{"name":"reviewer_contact_id","in":"query","required":false,"description":"Reviews written by this reviewer.","schema":{"type":"string"}},{"name":"contact_id","in":"query","required":false,"description":"Reviews of this contact's submissions (submitted by them OR with them as a participant — the same broad relation as the submissions resource's contact_id). The global anchor filter uses this.","schema":{"type":"string"}},{"name":"conflict_of_interest","in":"query","required":false,"description":"true → only reviews where the reviewer declared a conflict of interest; false → only scored reviews.","schema":{"type":"boolean"}},{"name":"q","in":"query","required":false,"description":"Free-text match over the review comment.","schema":{"type":"string"}},{"name":"format","in":"query","required":false,"description":"csv (default) or xlsx.","schema":{"type":"string","enum":["csv","xlsx"],"default":"csv"}},{"name":"sort","in":"query","required":false,"description":"Same vocabulary as the list endpoint.","schema":{"type":"string","enum":["created_at","-created_at","weighted_total","-weighted_total","reviewer_name","-reviewer_name","submission_code","-submission_code"]}},{"name":"limit","in":"query","required":false,"description":"Row cap, 1–10000. Default 10000.","schema":{"type":"integer","minimum":1,"maximum":10000}}],"responses":{"200":{"description":"The file, as an attachment (Content-Disposition names it `<event-slug>-<resource>-<date>.<ext>`).","content":{"text/csv":{"schema":{"type":"string"}},"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":{"schema":{"type":"string","format":"binary"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/EventNotFound"}}}},"/events/{event_id}/comments":{"get":{"operationId":"listComments","summary":"List comments","description":"Committee comments. `kind: rationale` rows are the note a reviewer left when saving scores.\n\nFilterable, sortable, paginated. Filters combine with AND; an unknown filter name is ignored, never an error — check `total` if you are unsure a filter applied. The admin workspace runs this exact query, so what you read here is what an organiser sees.","tags":["Comments"],"parameters":[{"$ref":"#/components/parameters/EventId"},{"name":"submission_id","in":"query","required":false,"description":"Comments on this submission. The global anchor filter uses this.","schema":{"type":"string"}},{"name":"kind","in":"query","required":false,"description":"Exact kind: rationale (a reviewer's score-save comment) | discussion.","schema":{"type":"string","enum":["rationale","discussion"]}},{"name":"author_contact_id","in":"query","required":false,"description":"Comments written by this contact.","schema":{"type":"string"}},{"name":"q","in":"query","required":false,"description":"Free-text match over the comment body.","schema":{"type":"string"}},{"name":"sort","in":"query","required":false,"description":"Sort field; prefix with \"-\" for descending. An unrecognised value is ignored (offset mode falls back to the resource default, cursor mode to id order) rather than erroring.","schema":{"type":"string","enum":["created_at","-created_at","submission_code","-submission_code","kind","-kind","author_name","-author_name"]},"example":"-created_at"},{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Offset"}],"responses":{"200":{"description":"Comments rows.","content":{"application/json":{"schema":{"type":"object","required":["data","total","limit","has_more"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Comment"}},"total":{"type":"integer","description":"Rows matching the filters, ignoring pagination."},"limit":{"type":"integer"},"offset":{"type":["integer","null"],"description":"null in cursor mode."},"has_more":{"type":"boolean"},"next_cursor":{"type":["string","null"],"description":"Pass back as ?cursor= for the next page; null on the last page. Always null in offset mode."}}}}}},"400":{"$ref":"#/components/responses/InvalidCursor"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/EventNotFound"}}}},"/events/{event_id}/comments/export":{"get":{"operationId":"exportComments","summary":"Export comments (CSV/XLSX)","description":"Byte-for-byte the file the workspace export button produces, honouring the same filters — the right endpoint when a human wants the result, and a poor one when your code wants the data (use the list endpoint, which paginates and returns JSON). Up to 10,000 rows; no pagination. Submissions gain a derived `outcome` column and reviews readable criterion names, so the file is not simply the list rows as CSV.","tags":["Comments"],"parameters":[{"$ref":"#/components/parameters/EventId"},{"name":"submission_id","in":"query","required":false,"description":"Comments on this submission. The global anchor filter uses this.","schema":{"type":"string"}},{"name":"kind","in":"query","required":false,"description":"Exact kind: rationale (a reviewer's score-save comment) | discussion.","schema":{"type":"string","enum":["rationale","discussion"]}},{"name":"author_contact_id","in":"query","required":false,"description":"Comments written by this contact.","schema":{"type":"string"}},{"name":"q","in":"query","required":false,"description":"Free-text match over the comment body.","schema":{"type":"string"}},{"name":"format","in":"query","required":false,"description":"csv (default) or xlsx.","schema":{"type":"string","enum":["csv","xlsx"],"default":"csv"}},{"name":"sort","in":"query","required":false,"description":"Same vocabulary as the list endpoint.","schema":{"type":"string","enum":["created_at","-created_at","submission_code","-submission_code","kind","-kind","author_name","-author_name"]}},{"name":"limit","in":"query","required":false,"description":"Row cap, 1–10000. Default 10000.","schema":{"type":"integer","minimum":1,"maximum":10000}}],"responses":{"200":{"description":"The file, as an attachment (Content-Disposition names it `<event-slug>-<resource>-<date>.<ext>`).","content":{"text/csv":{"schema":{"type":"string"}},"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":{"schema":{"type":"string","format":"binary"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/EventNotFound"}}}},"/events/{event_id}/submissions/{id}":{"get":{"operationId":"getSubmission","summary":"Get one submission","description":"The full record — parsed form answers, participants, tags and the review summary. This is the same shape the workspace detail tab renders, and the only place answers and participants are exposed.","tags":["Submissions"],"parameters":[{"$ref":"#/components/parameters/EventId"},{"name":"id","in":"path","required":true,"description":"The submission id (an opaque string; read it from a list response).","schema":{"type":"string"}}],"responses":{"200":{"description":"The submission.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubmissionDetail"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}}},"put":{"operationId":"updateSubmission","summary":"Update a submission","description":"Sparse: only the fields present in the body change. Unlike the status endpoint below this also accepts `status`, with the same no-email guarantee.","tags":["Submissions"],"parameters":[{"$ref":"#/components/parameters/EventId"},{"name":"id","in":"path","required":true,"description":"The submission id (an opaque string; read it from a list response).","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"title":{"type":"string","description":"Required on create; must be non-empty."},"description":{"type":["string","null"]},"format":{"type":["string","null"]},"level":{"type":["string","null"]},"language":{"type":["string","null"]},"track_id":{"type":["string","null"],"description":"Must belong to this event, else 400. null clears it."},"status":{"type":"string","enum":["draft","pending","accept_queue","accepted","decline_queue","declined","withdrawn"],"description":"Update only — ignored on create, which always starts `pending`."}},"additionalProperties":false,"description":"Only these fields are writable. Anything else in the body is ignored — the schedule, decision flags, answers and participants are not settable through this API. Tags have their own endpoint (PUT /events/{event_id}/submissions/{id}/tags)."},"example":{"level":"advanced"}}}},"responses":{"200":{"description":"Updated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Submission"}}}},"400":{"$ref":"#/components/responses/ValidationError"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}}},"delete":{"operationId":"deleteSubmission","summary":"Delete a submission","description":"Irreversible, and it cascades: answers, participants, tags, reviews, comments and task assignments go with it. To take a proposal out of consideration without destroying the record, set status `withdrawn` instead.","tags":["Submissions"],"parameters":[{"$ref":"#/components/parameters/EventId"},{"name":"id","in":"path","required":true,"description":"The submission id (an opaque string; read it from a list response).","schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeleteResult"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/events/{event_id}/submissions/{id}/status":{"post":{"operationId":"setSubmissionStatus","summary":"Change a submission status","description":"Moves a submission between pipeline states. Decision emails are deliberately NOT sent from here — batch notification stays in the app (Evaluation → send decisions), so an API status change never mails a speaker by surprise. Setting `accepted` therefore does not tell anyone they were accepted.","tags":["Submissions"],"parameters":[{"$ref":"#/components/parameters/EventId"},{"name":"id","in":"path","required":true,"description":"The submission id (an opaque string; read it from a list response).","schema":{"type":"string"}},{"$ref":"#/components/parameters/IdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["status"],"properties":{"status":{"type":"string","enum":["draft","pending","accept_queue","accepted","decline_queue","declined","withdrawn"]}},"additionalProperties":false},"example":{"status":"accept_queue"}}}},"responses":{"200":{"description":"Updated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatusResult"}}},"headers":{"Idempotency-Replayed":{"description":"Present and `true` only when this response was replayed from a previous request with the same Idempotency-Key.","schema":{"type":"string","enum":["true"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"description":"Invalid status value (`invalid_status`), or this Idempotency-Key was already used with a different body (`idempotency_mismatch`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/events/{event_id}/submissions/{id}/tags":{"put":{"operationId":"setSubmissionTags","summary":"Replace a submission's tags","description":"Tags by NAME, matched case-insensitively — the same strings the detail GET returns, so you can read a submission, add one to the list you got back, and send it. Whole-set replace: what you send is exactly what the submission ends up carrying, and an empty array clears it. A name the event does not have is refused (422 `unknown_tag`) rather than silently dropped or silently created; send `create_missing: true` when you do mean to coin one. GET /events/{event_id}/tags lists the vocabulary.","tags":["Submissions"],"parameters":[{"$ref":"#/components/parameters/EventId"},{"name":"id","in":"path","required":true,"description":"The submission id (an opaque string; read it from a list response).","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["tags"],"properties":{"tags":{"type":"array","items":{"type":"string"},"description":"Tag names. Duplicates (case-insensitively) collapse; at most 100."},"create_missing":{"type":"boolean","description":"Create any name this event does not have yet, instead of refusing. Default false."}},"additionalProperties":false},"example":{"tags":["needs AV","first-time speaker"]}}}},"responses":{"200":{"description":"The stored set, in name order.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"tags":{"type":"array","items":{"type":"string"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"description":"A name this event does not have (`unknown_tag`), or a malformed list (`invalid_tags`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/events/{event_id}/tags":{"get":{"operationId":"listTags","summary":"List the event's tags","description":"The event's tag vocabulary in name order — the names the submission tags endpoint accepts. Tags are labels that cut across tracks; they are created in the app's Settings, by an import, or by a tags write sending `create_missing`.","tags":["Submissions"],"parameters":[{"$ref":"#/components/parameters/EventId"}],"responses":{"200":{"description":"The tags.","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"color":{"type":["string","null"]}}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/EventNotFound"}}}},"/events/{event_id}/contacts/{id}":{"get":{"operationId":"getContact","summary":"Get one contact","description":"The roster row: org-level identity merged with this event's profile.","tags":["Contacts"],"parameters":[{"$ref":"#/components/parameters/EventId"},{"name":"id","in":"path","required":true,"description":"The contact id (an opaque string; read it from a list response).","schema":{"type":"string"}}],"responses":{"200":{"description":"The contact.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Contact"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}}},"put":{"operationId":"updateContact","summary":"Update a contact","description":"Sparse. Careful: identity fields (email, name, phone, pronouns) are organisation-level, so changing them here changes them on every event this person appears on. Profile fields (company, job_title, biography) touch this event only.","tags":["Contacts"],"parameters":[{"$ref":"#/components/parameters/EventId"},{"name":"id","in":"path","required":true,"description":"The contact id (an opaque string; read it from a list response).","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"description":"The writable fields. The body is flat, but the two halves land in different places: identity is shared across the organisation, profile is recorded against this event only.","properties":{"email":{"type":"string","format":"email","description":"Required on create; lowercased. Unique per ORGANISATION, not per event — one person, every event. Creating with an email already on THIS event is 409 `email_exists`; an email known to the org but not this event attaches that existing person to this event instead of creating a duplicate."},"first_name":{"type":["string","null"]},"last_name":{"type":["string","null"]},"mobile_phone":{"type":["string","null"]},"pronouns":{"type":["string","null"]},"company":{"type":["string","null"],"description":"Per-event."},"job_title":{"type":["string","null"],"description":"Per-event."},"biography":{"type":["string","null"],"description":"Per-event."}}},"example":{"job_title":"Head of Engines"}}}},"responses":{"200":{"description":"Updated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Contact"}}}},"400":{"$ref":"#/components/responses/ValidationError"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"Another contact in this organisation already uses this email (`email_exists`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"operationId":"removeContactFromEvent","summary":"Remove a contact from this event","description":"Detach, not destroy: this event's membership and per-event profile go, the person and their history at the organisation's other events stay. Only when this was their last event is the person deleted outright. A remaining reference (say a reviewer assignment) is a 409, not a 500.","tags":["Contacts"],"parameters":[{"$ref":"#/components/parameters/EventId"},{"name":"id","in":"path","required":true,"description":"The contact id (an opaque string; read it from a list response).","schema":{"type":"string"}}],"responses":{"200":{"description":"Detached (and deleted, if it was their last event).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeleteResult"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"Other records still reference this contact (`constraint`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/events/{event_id}/tasks/{id}":{"put":{"operationId":"updateTask","summary":"Update a task definition","description":"Sparse. `id` here is a TASK id (`task_id` on an assignment row), not an assignment id.","tags":["Tasks"],"parameters":[{"$ref":"#/components/parameters/EventId"},{"name":"id","in":"path","required":true,"description":"The task id (an opaque string; read it from a list response).","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"properties":{"title":{"type":"string","description":"Required on create."},"description":{"type":["string","null"]},"target":{"type":"string","enum":["contact","group","submission"],"description":"What the task hangs off. Default contact."},"assignment_mode":{"type":"string","enum":["manual","automatic"],"description":"automatic = assigned by the trigger; manual = assigned by hand in the app. Default manual."},"trigger":{"type":"string","enum":["on_accept","on_schedule","none"],"description":"When automatic assignment fires. Default none."},"action_type":{"type":"string","enum":["file_upload","portal_form","acknowledge","external_link"],"description":"What the assignee does. Default acknowledge."},"portal_form_id":{"type":["string","null"],"description":"Must belong to this event. Required in practice when action_type is portal_form."},"file_request_id":{"type":["string","null"],"description":"Must belong to this event. Required in practice when action_type is file_upload."},"due_at":{"type":["string","null"],"format":"date-time","description":"ISO 8601."},"reminder_offsets_days":{"type":["array","null"],"items":{"type":"integer"},"description":"Days before due_at to remind, e.g. [14, 3]. Sent as a real array here; read back as a JSON string."},"required":{"type":"boolean"}}},"example":{"due_at":"2026-09-15T00:00:00.000Z"}}}},"responses":{"200":{"description":"Updated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Task"}}}},"400":{"$ref":"#/components/responses/ValidationError"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}}},"delete":{"operationId":"deleteTask","summary":"Delete a task definition","description":"Every assignment of this task cascades away with it, including completed ones.","tags":["Tasks"],"parameters":[{"$ref":"#/components/parameters/EventId"},{"name":"id","in":"path","required":true,"description":"The task id (an opaque string; read it from a list response).","schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeleteResult"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/events/{event_id}/forms":{"get":{"operationId":"listForms","summary":"List submission forms","description":"The CFP forms for this event, newest first, with question and submission counts. Read-only: building and editing forms stays in the admin app. Not paginated.","tags":["Forms"],"parameters":[{"$ref":"#/components/parameters/EventId"}],"responses":{"200":{"description":"Forms.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/FormSummary"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/EventNotFound"}}}},"/events/{event_id}/forms/{id}":{"get":{"operationId":"getForm","summary":"Get one form","description":"The form plus its questions in display order. Read this to learn what the `answers` on a submission mean — the answer labels come from these questions.","tags":["Forms"],"parameters":[{"$ref":"#/components/parameters/EventId"},{"name":"id","in":"path","required":true,"description":"The form id (an opaque string; read it from a list response).","schema":{"type":"string"}}],"responses":{"200":{"description":"The form.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FormDetail"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}}}}}}