Greenroom

OpenAPI 3.1.1 · v1

Greenroom read-only API

Server-to-server reads of one event's proposals, speakers, and published program.

The machine-readable document is at /api/v1/openapi.json and needs no key. This page renders that same document — they cannot disagree.

Overview

Greenroom's /api/v1/* surface is read-only, event-scoped, and gated by a server-side API key. It exists so another system can mirror a conference program; it is not the application's own API, and it can neither create nor modify anything.

Two kinds of key are accepted, and they differ in reach. The deployment-wide GREENROOM_API_KEY is set by the operator in the server environment and may address any event on the deployment. A per-event key is issued by an event's organizer from that event's settings, looks like grk_<id>_<secret>, and may address that one event and nothing else — presenting it with any other event selector is refused exactly as an invalid key is. Either kind is sent the same way, in either header below.

The <id> in the middle of a per-event key is not secret: it is how the server finds which key you presented, and it is what your event's settings page shows you in the list. The <secret> after it is the part that authenticates, and the server keeps only a one-way hash of it — nobody, including the organizer who created it, can read a key back after it is issued.

Every request needs a credential, and every request without an accepted one is 401 UNAUTHORIZED. There is no separate "this server is not configured" answer: a deployment can be fully set up with nothing but per-event keys, so a missing or wrong credential is an authentication failure and is reported as one. A deployment that has configured neither a GREENROOM_API_KEY (one shorter than 32 characters is ignored entirely, as though unset) nor any per-event key simply refuses every request, because no credential can be accepted — it still exposes no program data.

A presented credential is always checked: against the deployment-wide key when one is set, and against this deployment's issued per-event keys either way. An organizer's per-event key therefore works whether or not the operator has set a global one.

Checking the deployment-wide key costs no database read. Checking a per-event key costs exactly one indexed lookup of the key itself, which reaches no event and no program data; the event named by event is only ever read after a credential has been accepted, and for a per-event key only ever within that key's own event.

Every refusal of a credential is the same 401 UNAUTHORIZED: an unknown key, a revoked key, and a valid per-event key aimed at somebody else's event are deliberately indistinguishable, and none of them reveals whether the event named actually exists.

This document is itself unauthenticated: it describes the contract and contains no program data and no credential. Every credential in every example below is the literal placeholder replace-with-at-least-32-random-characters — the deployment-wide key is not event-scoped, so publishing it would make every event this API can address public. Ask the deployment's operator for a real one, or your event's organizer for a per-event one.

Authentication

  • Authorization

    Authorization: Bearer replace-with-at-least-32-random-characters

    Carries either accepted key: the deployment-wide GREENROOM_API_KEY, or an event's own grk_<id>_<secret> key, which reaches only that event.

    Checked first: when both headers are sent, a malformed Authorization value is a failure rather than something an unrelated X-API-Key can bypass.

  • X-API-Key

    X-API-Key: replace-with-at-least-32-random-characters

    Carries either accepted key, exactly as the Bearer scheme does.

    Read only when no Authorization header is present.

The key is never published here. GREENROOM_API_KEY is deployment-wide rather than event-scoped, so every example below uses the placeholder replace-with-at-least-32-random-characters. Ask the operator of the deployment for a real one; pasting the placeholder gets you a 401.

Set your deployment and key

export BASE_URL="https://your-app.example"
export GREENROOM_API_KEY="replace-with-at-least-32-random-characters"

List proposals (Bearer)

curl -H "Authorization: Bearer $GREENROOM_API_KEY" \
  "$BASE_URL/api/v1/submissions?event=forward-2026&limit=50&offset=0"

List speakers (X-API-Key)

curl -H "X-API-Key: $GREENROOM_API_KEY" \
  "$BASE_URL/api/v1/speakers?event=forward-2026"

Read this contract (no key)

curl "$BASE_URL/api/v1/openapi.json"

Which surface answers your question

Most integration questions are already answered by a page that needs no key. The key-gated API is for the ones that are not.

You want toUseKeyNotes
Mirror the proposal pipeline into another system/api/v1/submissionsRequiredThe only surface that returns proposals. Review data is excluded by construction.
Publish the speaker lineup on your own site/api/v1/speakersRequiredPrefer /speakers or /embed/speakers if a rendered page will do — neither needs a key.
Sync the published program into another app/api/v1/scheduleRequiredPlaced and published sessions only. /api/comms/calendar exports the same program as .ics.
Show the program to visitors/schedule and /speakersNoneThe canonical public pages. /embed/schedule and /embed/speakers are the same, chrome-free, for an iframe.
Read this contract before asking for a key/api/v1/openapi.jsonNoneThis document, as JSON. No key, no database read, no program data.

Endpoints

GET/api/v1/submissions

List this event's proposals

Requires an API key (bearerApiKey or apiKeyHeader).

Every proposal belonging to the selected event, with its form, category, speaker roster, and the answers given to that form's fields.

Review data is never part of this payload: assignments, scores, rubric criteria, and private reviewer comments have no field here and cannot be reached through it.

Browse reads order by createdAt, id and may narrow with one lifecycle status.

Query parameters

NameRequiredAcceptsNotes
eventYesstring, 1–191 charactersThe event's slug or id. Required on every request — this API has no implicit current event and never spans events. With the deployment-wide key, an unknown value is 404 EVENT_NOT_FOUND. With a per-event key, anything other than that key's own event is 401 UNAUTHORIZED, whether or not such an event exists: a per-event key cannot be used to discover which other events this deployment hosts.
limitNointeger, 1–100, default 50Rows per page. Must be a whole number from 1 to 100; anything else is 400 INVALID_QUERY.
offsetNointeger, 0–1000000, default 0Rows to skip. Must be a whole number from 0 to 1000000; anything else is 400 INVALID_QUERY.
statusNostringOne exact lifecycle status that narrows the existing event-scoped submissions browse read.

Ordering: createdAt, then id ascending. The trailing id is a tiebreaker, so paging with offset cannot repeat or skip a row.

Response (200)

{
  "version": "v1",
  "data": [
    {
      "id": "clx0abstract00000000000001",
      "title": "Scheduling a conference without losing a room",
      "description": "What placement conflicts actually cost, and how to see them early.",
      "format": "TALK",
      "durationMinutes": 30,
      "status": "ACCEPTED",
      "submittedAt": "2026-01-14T09:12:00.000Z",
      "createdAt": "2026-01-12T17:40:00.000Z",
      "updatedAt": "2026-02-02T11:05:00.000Z",
      "form": {
        "id": "clx0form00000000000000001",
        "name": "Call for speakers",
        "slug": "call-for-speakers"
      },
      "category": {
        "id": "clx0cat000000000000000001",
        "name": "Operations"
      },
      "speakers": [
        {
          "id": "clx0user00000000000000001",
          "name": "Ada Lovelace",
          "email": "ada.lovelace@example.test",
          "avatarUrl": null,
          "isPrimary": true
        }
      ],
      "answers": {
        "audience-level": "intermediate",
        "needs-av": true
      }
    }
  ],
  "error": null,
  "meta": {
    "event": {
      "id": "clx0event0000000000000000",
      "name": "Forward 2026",
      "slug": "forward-2026",
      "timezone": "UTC"
    },
    "pagination": {
      "limit": 50,
      "offset": 0,
      "total": 2,
      "hasMore": false,
      "nextOffset": null
    }
  }
}

Failures

StatusCodeWhen
400EVENT_REQUIREDThe query is missing event (EVENT_REQUIRED) or a parameter is out of bounds (INVALID_QUERY).
401UNAUTHORIZEDThe only authentication failure this surface reports. One refusal covers every cause: no key sent at all, a value that is not shaped like a key, a key matching neither the deployment-wide key nor any issued per-event key, a per-event key that has been revoked, and a valid per-event key used against an event other than its own. It is also the answer when this deployment has configured no credential of either kind — the surface is reachable, so it authenticates rather than claiming to be unconfigured. Both comparisons are constant-time over fixed-size hashes, so a failure discloses neither the secret's length nor how much of it was right — and never whether the event named exists.
404EVENT_NOT_FOUNDNo event has that slug or id. Returned after authentication, so it is not an unauthenticated probe for which events exist.
500INTERNAL_ERRORAn unexpected failure, reported inside this envelope. The message is fixed: database text and request details are never returned to a caller.

GET/api/v1/submissions/{submissionId}

Get one event-scoped proposal

Requires an API key (bearerApiKey or apiKeyHeader).

Returns exactly the proposal projection used by the submissions list. Review assignments, scores, rubric criteria, and private reviewer comments are excluded. The authenticated event predicate is applied to the id lookup; a missing or cross-event id returns the same 404 SUBMISSION_NOT_FOUND.

Query parameters

NameRequiredAcceptsNotes
eventYesstring, 1–191 charactersThe event's slug or id. Required on every request — this API has no implicit current event and never spans events. With the deployment-wide key, an unknown value is 404 EVENT_NOT_FOUND. With a per-event key, anything other than that key's own event is 401 UNAUTHORIZED, whether or not such an event exists: a per-event key cannot be used to discover which other events this deployment hosts.
submissionIdYesstring, 1–191 charactersProposal id. It is matched with the selected, authenticated event, so another event's id is indistinguishable from a missing one.

Response (200)

{
  "version": "v1",
  "data": {
    "id": "clx0abstract00000000000001",
    "title": "Scheduling a conference without losing a room",
    "description": "What placement conflicts actually cost, and how to see them early.",
    "format": "TALK",
    "durationMinutes": 30,
    "status": "ACCEPTED",
    "submittedAt": "2026-01-14T09:12:00.000Z",
    "createdAt": "2026-01-12T17:40:00.000Z",
    "updatedAt": "2026-02-02T11:05:00.000Z",
    "form": {
      "id": "clx0form00000000000000001",
      "name": "Call for speakers",
      "slug": "call-for-speakers"
    },
    "category": {
      "id": "clx0cat000000000000000001",
      "name": "Operations"
    },
    "speakers": [
      {
        "id": "clx0user00000000000000001",
        "name": "Ada Lovelace",
        "email": "ada.lovelace@example.test",
        "avatarUrl": null,
        "isPrimary": true
      }
    ],
    "answers": {
      "audience-level": "intermediate",
      "needs-av": true
    }
  },
  "error": null,
  "meta": {
    "event": {
      "id": "clx0event0000000000000000",
      "name": "Forward 2026",
      "slug": "forward-2026",
      "timezone": "UTC"
    }
  }
}

Failures

StatusCodeWhen
400EVENT_REQUIREDThe query is missing event (EVENT_REQUIRED) or a parameter is out of bounds (INVALID_QUERY).
401UNAUTHORIZEDThe only authentication failure this surface reports. One refusal covers every cause: no key sent at all, a value that is not shaped like a key, a key matching neither the deployment-wide key nor any issued per-event key, a per-event key that has been revoked, and a valid per-event key used against an event other than its own. It is also the answer when this deployment has configured no credential of either kind — the surface is reachable, so it authenticates rather than claiming to be unconfigured. Both comparisons are constant-time over fixed-size hashes, so a failure discloses neither the secret's length nor how much of it was right — and never whether the event named exists.
404SUBMISSION_NOT_FOUNDNo proposal with that id belongs to the selected event. This also covers a cross-event id.
500INTERNAL_ERRORAn unexpected failure, reported inside this envelope. The message is fixed: database text and request details are never returned to a caller.

GET/api/v1/speakers

List this event's speakers

Requires an API key (bearerApiKey or apiKeyHeader).

People reached only through this event's own abstract or session speaker relations — the global user table and event memberships are not a source here, so an organizer or an evaluator never appears as a speaker.

A held-back talk does not announce its speaker. A session qualifies its speakers only while it is published, and a proposal qualifies its speakers only when it has no linked session yet or that session is published. appearances counts the same event-local, publication-filtered relations, so it never hints at a withheld talk.

Ordered by name ascending with id ascending as the tiebreaker.

Query parameters

NameRequiredAcceptsNotes
eventYesstring, 1–191 charactersThe event's slug or id. Required on every request — this API has no implicit current event and never spans events. With the deployment-wide key, an unknown value is 404 EVENT_NOT_FOUND. With a per-event key, anything other than that key's own event is 401 UNAUTHORIZED, whether or not such an event exists: a per-event key cannot be used to discover which other events this deployment hosts.
limitNointeger, 1–100, default 50Rows per page. Must be a whole number from 1 to 100; anything else is 400 INVALID_QUERY.
offsetNointeger, 0–1000000, default 0Rows to skip. Must be a whole number from 0 to 1000000; anything else is 400 INVALID_QUERY.

Ordering: name, then id ascending. The trailing id is a tiebreaker, so paging with offset cannot repeat or skip a row.

Response (200)

{
  "version": "v1",
  "data": [
    {
      "id": "clx0user00000000000000001",
      "name": "Ada Lovelace",
      "email": "ada.lovelace@example.test",
      "avatarUrl": null,
      "profile": {
        "bio": "Works on program operations.",
        "company": "Analytical Engines",
        "jobTitle": "Principal Engineer",
        "headshotUrl": null,
        "slideDeckUrl": null,
        "socialLinks": {
          "mastodon": "https://example.test/@ada"
        }
      },
      "appearances": {
        "submissions": 1,
        "sessions": 1
      }
    }
  ],
  "error": null,
  "meta": {
    "event": {
      "id": "clx0event0000000000000000",
      "name": "Forward 2026",
      "slug": "forward-2026",
      "timezone": "UTC"
    },
    "pagination": {
      "limit": 50,
      "offset": 0,
      "total": 2,
      "hasMore": false,
      "nextOffset": null
    }
  }
}

Failures

StatusCodeWhen
400EVENT_REQUIREDThe query is missing event (EVENT_REQUIRED) or a parameter is out of bounds (INVALID_QUERY).
401UNAUTHORIZEDThe only authentication failure this surface reports. One refusal covers every cause: no key sent at all, a value that is not shaped like a key, a key matching neither the deployment-wide key nor any issued per-event key, a per-event key that has been revoked, and a valid per-event key used against an event other than its own. It is also the answer when this deployment has configured no credential of either kind — the surface is reachable, so it authenticates rather than claiming to be unconfigured. Both comparisons are constant-time over fixed-size hashes, so a failure discloses neither the secret's length nor how much of it was right — and never whether the event named exists.
404EVENT_NOT_FOUNDNo event has that slug or id. Returned after authentication, so it is not an unauthenticated probe for which events exist.
500INTERNAL_ERRORAn unexpected failure, reported inside this envelope. The message is fixed: database text and request details are never returned to a caller.

GET/api/v1/schedule

List this event's published, placed sessions

Requires an API key (bearerApiKey or apiKeyHeader).

One entry per placement: a slot with its room, optional track, and the session that sits in it. Because the record read is the placement itself, backlog and unplaced sessions cannot appear.

Only published sessions are returned. The same filter feeds the page and the total, so meta.pagination.total never advertises rows the page will not hand back.

Ordered by startsAt ascending with id ascending as the tiebreaker.

Query parameters

NameRequiredAcceptsNotes
eventYesstring, 1–191 charactersThe event's slug or id. Required on every request — this API has no implicit current event and never spans events. With the deployment-wide key, an unknown value is 404 EVENT_NOT_FOUND. With a per-event key, anything other than that key's own event is 401 UNAUTHORIZED, whether or not such an event exists: a per-event key cannot be used to discover which other events this deployment hosts.
limitNointeger, 1–100, default 50Rows per page. Must be a whole number from 1 to 100; anything else is 400 INVALID_QUERY.
offsetNointeger, 0–1000000, default 0Rows to skip. Must be a whole number from 0 to 1000000; anything else is 400 INVALID_QUERY.

Ordering: startsAt, then id ascending. The trailing id is a tiebreaker, so paging with offset cannot repeat or skip a row.

Response (200)

{
  "version": "v1",
  "data": [
    {
      "id": "clx0slot00000000000000001",
      "startsAt": "2026-05-14T09:00:00.000Z",
      "endsAt": "2026-05-14T09:30:00.000Z",
      "room": {
        "id": "clx0room00000000000000001",
        "name": "Main Hall",
        "capacity": 400
      },
      "track": {
        "id": "clx0track0000000000000001",
        "name": "Operations",
        "color": "#167565"
      },
      "session": {
        "id": "clx0session000000000000001",
        "title": "Scheduling a conference without losing a room",
        "description": "What placement conflicts actually cost, and how to see them early.",
        "format": "TALK",
        "durationMinutes": 30,
        "speakers": [
          {
            "id": "clx0user00000000000000001",
            "name": "Ada Lovelace",
            "email": "ada.lovelace@example.test",
            "avatarUrl": null,
            "isPrimary": true
          }
        ]
      }
    }
  ],
  "error": null,
  "meta": {
    "event": {
      "id": "clx0event0000000000000000",
      "name": "Forward 2026",
      "slug": "forward-2026",
      "timezone": "UTC"
    },
    "pagination": {
      "limit": 50,
      "offset": 0,
      "total": 2,
      "hasMore": false,
      "nextOffset": null
    }
  }
}

Failures

StatusCodeWhen
400EVENT_REQUIREDThe query is missing event (EVENT_REQUIRED) or a parameter is out of bounds (INVALID_QUERY).
401UNAUTHORIZEDThe only authentication failure this surface reports. One refusal covers every cause: no key sent at all, a value that is not shaped like a key, a key matching neither the deployment-wide key nor any issued per-event key, a per-event key that has been revoked, and a valid per-event key used against an event other than its own. It is also the answer when this deployment has configured no credential of either kind — the surface is reachable, so it authenticates rather than claiming to be unconfigured. Both comparisons are constant-time over fixed-size hashes, so a failure discloses neither the secret's length nor how much of it was right — and never whether the event named exists.
404EVENT_NOT_FOUNDNo event has that slug or id. Returned after authentication, so it is not an unauthenticated probe for which events exist.
500INTERNAL_ERRORAn unexpected failure, reported inside this envelope. The message is fixed: database text and request details are never returned to a caller.

GET/api/v1/openapi.json

This document

No API key required.

Returns this OpenAPI 3.1.1 document as JSON.

Deliberately unauthenticated, and the one route on this surface that is. It performs no database read and returns no program data, so requiring the key would only make the contract unreadable to the integrator deciding whether to ask for one.