openapi: 3.1.0
info:
  title: Hijinks Webhook API
  version: 1.0.0-beta
  summary: Play every Hijinks game on any chat tool through a bridge you write.
  description: |
    Hijinks talks to a **bridge**: a small HTTP service you run next to your
    chat tool. Hijinks POSTs signed events telling the bridge what to post
    (outbound, see `webhooks`); the bridge POSTs signed events back when a
    member runs a command, presses a button or types an answer (inbound,
    `POST /hooks/{workspaceId}`).

    A bridge that cannot take requests from the internet — a chat server on
    an intranet with outbound access only — **polls** instead: connect
    without a target URL and fetch the same events from
    `GET /hooks/{workspaceId}/events`. See that path.

    ## Staleness

    Every outbound event carries `expiresAt`: past it, the event is no
    longer worth showing — a question whose round is over, a reply to a
    press a member made minutes ago — and a bridge **drops it unapplied**.
    `null` means it never goes stale (a self-paced round's question, a
    digest). A pushed event is never late enough to matter; the rule is for
    a polling bridge catching up after downtime, and for any bridge that
    queues events internally.

    **Private beta.** Hijinks is not public yet; custom-platform workspaces
    open with the rest of the product at launch. This contract may change
    before then.

    ## Signing

    Both directions carry the same header, signed with the workspace's one
    secret (shown once in the dashboard when you connect):

        Hijinks-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>

    A poll (`GET /hooks/{workspaceId}/events`) has no body: the bridge signs
    the **query string** exactly as sent (everything after `?`, or the empty
    string), and Hijinks signs the response body the same way it signs a push.

    Sign and verify the **exact raw bytes** of the body — never a
    re-serialised parse of it. Compare digests in constant time, and refuse a
    timestamp more than 5 minutes from your clock. During a secret rotation
    Hijinks accepts both the old and the new secret for 24 hours and signs
    each outbound event with both — one `v1=` entry per secret, the new one
    first — so a bridge still holding the old secret keeps verifying until you
    redeploy it. Any one `v1=` entry matching is enough.

    ## Text

    `message.text` is chat-flavoured Markdown, ready to print: `*italic*`,
    `**bold**`, `` `code` ``, `[label](https://…)`, `> quotes`, fenced code
    blocks and backslash escapes. **A bare `\n` is a line break** and a blank
    line separates paragraphs, as in Slack, Discord, Mattermost and most chat
    tools. A strict CommonMark renderer must be told to treat newlines as
    hard breaks (for example markdown-it `breaks: true`), or lines will run
    together. Member names and question text are escaped, GFM's `|` and `~`
    included.

    ## Compatibility

    Schemas list every field Hijinks sends today. Ignore fields you do not
    recognise: new fields and new `kind` values may be added without notice
    during the beta. Removing or renaming a field is a breaking change.
  contact:
    email: support@hijinks.team
externalDocs:
  description: Guides, quickstart and the reference bridge
  url: https://docs.hijinks.team/
servers:
  - url: https://api.hijinks.team
    description: Hijinks API (inbound events)

tags:
  - name: Inbound
    description: Events a bridge sends to Hijinks.
  - name: Polling
    description: Outbound events fetched by a bridge that cannot receive them.

paths:
  /hooks/{workspaceId}:
    post:
      tags: [Inbound]
      operationId: sendInboundEvent
      summary: Report a command, button press or typed message
      description: |
        Hijinks checks a request in a fixed order and answers the first
        failure:

        | Check | Status |
        |---|---|
        | Body is JSON and at most 64 KB | `400` / `413` |
        | At most 500 requests per second from the sending IP address, signed or not | `429` |
        | Workspace exists, is a custom-platform workspace and is connected | `404` |
        | `Hijinks-Signature` matches the raw body | `401` |
        | Signed timestamp within 5 minutes of Hijinks' clock | `409` |
        | At most 250 requests per second for this workspace | `429` |
        | Body matches `InboundEvent` (the field at fault is named) | `400` |

        Anything that passes is answered `202 {"ok": true}` before it is acted
        on. What the event causes — a question, an updated message, a DM —
        arrives as outbound events, never in this response. A `message` that
        answers nothing in play is accepted and dropped, so a bridge may
        forward every message in a channel without filtering.

        **Rate limits.** Every button press and typed answer is one request,
        so the workspace's 250 a second covers a whole game answering at
        once — 200 players, the largest per-game cap of any plan that has
        one — with room for polls. Only signed requests count against the
        workspace's limit; every request counts against its IP address's
        500, which is checked before anything else so unsigned traffic is
        refused cheaply. Bridges for several workspaces sharing one egress
        address share that address's limit. A `429` carries `Retry-After`
        (seconds): wait that long and send the request again.

        **Retries and idempotency.** Hijinks acts on a request once. Give
        each request your own `id` (any string up to 128 characters, unique
        per request — a UUID will do): a request repeating an `id` seen in
        the last 24 hours is answered `202` and ignored, however it is
        signed. A request without an `id` is ignored only when it is the
        very same signed request again (same timestamp, same bytes). So:

        - **Retry** a timeout, a dropped connection, a `5xx` or a `429`
          with the same body and `id`, re-signed with the current time.
        - **Do not retry** any other `4xx`: the request itself is wrong.

        Without an `id`, a retry re-signed with a new timestamp is a new
        request — and a repeated typed guess in a word game costs a turn.

        Send `Content-Type: application/json`; the signature is checked over
        the body bytes as received.
      parameters:
        - name: workspaceId
          in: path
          required: true
          description: The workspace id shown in the dashboard next to the inbound URL.
          schema:
            $ref: '#/components/schemas/WorkspaceId'
        - $ref: '#/components/parameters/HijinksSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InboundEvent'
            examples:
              command:
                summary: A member ran `/hijinks start`
                value:
                  type: command
                  channel: town-square
                  user: { id: '8x7tq3w5ojy1mrd9ziakh4ebfc', name: Maya K. }
                  text: start
              commandWithChannelName:
                summary: The same, with the channel's display name
                value:
                  type: command
                  channel: { id: town-square, name: Town Square }
                  user: { id: '8x7tq3w5ojy1mrd9ziakh4ebfc', name: Maya K. }
                  text: leaderboard
              action:
                summary: A member pressed an answer button, with the bridge's request id for safe retries
                value:
                  type: action
                  id: 3f2c9a7e-0d41-4b8e-a5f6-9c0e7d2b1a44
                  channel: town-square
                  user: { id: '8x7tq3w5ojy1mrd9ziakh4ebfc', name: Maya K. }
                  messageId: msg_01JA2Z8KQ4M6V3XG5H7N9PRSTW
                  action: answer_2_1
              message:
                summary: A typed answer in a word game
                value:
                  type: message
                  channel: town-square
                  user: { id: 'q1w2e3r4t5y6u7i8o9p0asdfgh', name: Leo }
                  text: crane
              messageReply:
                summary: A typed answer sent as a reply to a Hijinks message
                value:
                  type: message
                  channel: town-square
                  user: { id: 'q1w2e3r4t5y6u7i8o9p0asdfgh' }
                  text: the beatles
                  replyTo: msg_01JA2Z9B3C4D5E6F7G8H9JKMNP
              messageInDm:
                summary: A Know Your Team answer typed in the member's DM, with no reply thread
                value:
                  type: message
                  channel: 'dm:q1w2e3r4t5y6u7i8o9p0asdfgh'
                  user: { id: 'q1w2e3r4t5y6u7i8o9p0asdfgh', name: Leo }
                  text: Delivering newspapers
              actionInDm:
                summary: A member pressed a button on a message Hijinks sent to their DM
                value:
                  type: action
                  channel: 'dm:8x7tq3w5ojy1mrd9ziakh4ebfc'
                  user: { id: '8x7tq3w5ojy1mrd9ziakh4ebfc', name: Maya K. }
                  messageId: msg_01JA2ZB7D2E3F4G5H6J7K8M9NP
                  action: async_start_6f0c2b9e-8d4a-4f6b-9c1e-2a7d5e0b4c83
      responses:
        '202':
          description: Accepted. Effects arrive as outbound events.
          content:
            application/json:
              schema:
                type: object
                required: [ok]
                properties:
                  ok: { const: true }
              example: { ok: true }
        '400':
          description: |
            The body is not valid JSON (no `field`), or it does not match
            `InboundEvent` (`field` names the first field at fault, dotted
            for nested ones, e.g. `user.id`). Unknown fields are refused.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                statusCode: 400
                error: Bad Request
                message: 'a "dm:" channel is only for action and message events, as "dm:" and their own user.id'
                field: channel
        '401':
          description: Missing, malformed or wrong `Hijinks-Signature`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                statusCode: 401
                error: Unauthorized
                message: Bad or missing Hijinks-Signature
        '404':
          description: |
            No such workspace, or it is not a custom-platform workspace, or it
            has been disconnected. The same answer for every reason.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                statusCode: 404
                error: Not Found
                message: Unknown workspace
        '409':
          description: |
            The signature is valid but its timestamp is more than 5 minutes
            from Hijinks' clock: a replay, or a bridge whose clock has drifted.
            Re-sign with the current time.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                statusCode: 409
                error: Conflict
                message: Timestamp outside the 5-minute window
        '413':
          description: The body is larger than 64 KB.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                statusCode: 413
                message: request entity too large
        '429':
          description: More than 500 requests in one second from the sending IP address, or more than 250 signed requests in one second for this workspace. Wait `Retry-After` seconds and send the request again, with the same `id`.
          headers:
            Retry-After:
              description: Seconds to wait before retrying.
              schema:
                type: integer
                minimum: 1
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                statusCode: 429
                message: 'ThrottlerException: Too Many Requests'

  /hooks/{workspaceId}/events:
    get:
      tags: [Polling]
      operationId: pollEvents
      summary: Fetch outbound events (polling bridges)
      description: |
        The alternative to an inbound HTTPS URL. Connect the custom platform
        **without a target URL** and Hijinks holds every outbound event —
        the same envelopes, with the same ids and sequence numbers, that it
        would otherwise POST — for **24 hours**. The bridge fetches them here,
        oldest first, and passes the `next` cursor back as `after` on its next
        poll. A cursor never skips an event: an event still being written
        when you poll is served after it, never before. Events older than
        24 hours are gone; a page whose `after` is that old says so with
        `gap: true`.

        Poll every few seconds (the reference bridge polls every 2). When
        `hasMore` is true, poll again at once. An endpoint with a target URL
        has its events pushed, never held, and is answered `403` — so before
        setting one, drain what is held with a last poll: events not fetched
        by then are never pushed.

        **Starting without a cursor.** `after=latest` answers no events and
        the newest cursor: the bridge starts from now. Use it on a first
        start and whenever the saved cursor is lost; leaving `after` out
        instead replays everything held, up to 24 hours of games.

        **Catching up.** After downtime a page can hold a backlog. Drop every
        event past its `expiresAt`. Of several `message.update`s for one
        `message.id`, apply only the one with the highest `sequence`. An
        update for a message whose create you dropped is dropped with it.

        **When your chat refuses a message.** Do not advance past a create
        (`message.create`, `dm.create`) your chat refused: poll the same
        `after` again and retry it until it succeeds or expires — handled
        events repeat, and you dedupe them on `id`. A refused
        `message.update` or `message.ephemeral` can be dropped: the next
        update supersedes it, and a reply is soon stale anyway.

        **Health.** A bridge that stops polling while events wait for it
        shows as failing in the dashboard after 5 minutes, and the
        workspace's owner is emailed once per outage.

        **Signing the request.** `Hijinks-Signature` as for a push, computed
        over the query string exactly as sent — `after=41&limit=100`, or the
        empty string with no query — in place of a body:
        `v1=HMAC-SHA256(secret, "<t>.<query string>")`.

        **Verifying the response.** The `200` carries `Hijinks-Signature`
        over the exact response bytes, in the push format (one `v1=` per
        active secret during a rotation). Verify it before acting on any event.

        The checks and their order are those of `POST /hooks/{workspaceId}`
        (the IP and workspace rate limits included; a poll counts against
        both), then the query parameters (`400` naming the parameter). Every
        poll counts as a delivery in the dashboard's health, and "Send test
        event" queues a `ping` and reports whether the bridge polled within
        the last minute.
      parameters:
        - name: workspaceId
          in: path
          required: true
          description: The workspace id shown in the dashboard next to the inbound URL.
          schema:
            $ref: '#/components/schemas/WorkspaceId'
        - name: after
          in: query
          required: false
          description: |
            The `next` cursor of the previous poll, stored as given — or
            `latest` for no events and the newest cursor (start from now).
            Omitted, the page starts from the oldest event held.
          schema:
            oneOf:
              - $ref: '#/components/schemas/Cursor'
              - const: latest
        - name: limit
          in: query
          required: false
          description: Most events to answer.
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 100
        - $ref: '#/components/parameters/HijinksSignature'
      responses:
        '200':
          description: A page of held events, oldest first.
          headers:
            Hijinks-Signature:
              description: Over the exact response body, as on a push.
              schema:
                type: string
            Hijinks-Workspace:
              description: The workspace the events belong to.
              schema:
                $ref: '#/components/schemas/WorkspaceId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EventPage'
              examples:
                page:
                  summary: One event waiting
                  value:
                    events:
                      - id: evt_01JA2ZD9E0F1G2H3J4K5M6N7P8
                        type: ping
                        workspace: 6f0c2b9e-8d4a-4f6b-9c1e-2a7d5e0b4c83
                        sequence: 1760018880000007
                        createdAt: '2026-10-09T14:08:00.000Z'
                        expiresAt: '2026-10-09T14:09:00.000Z'
                    next: '4182'
                    hasMore: false
                    gap: false
                empty:
                  summary: Nothing new since the cursor
                  value:
                    events: []
                    next: '4182'
                    hasMore: false
                    gap: false
                latest:
                  summary: The answer to `after=latest`
                  value:
                    events: []
                    next: '4182'
                    hasMore: false
                    gap: false
                gap:
                  summary: The cursor is over 24 hours old; what came between expired
                  value:
                    events: []
                    next: '17'
                    hasMore: false
                    gap: true
        '400':
          description: A query parameter is malformed, unknown or repeated; `field` names it.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                statusCode: 400
                error: Bad Request
                message: limit must be a whole number from 1 to 100
                field: limit
        '401':
          description: Missing, malformed or wrong `Hijinks-Signature` over the query string.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: The endpoint has a target URL, so its events are pushed and none are held.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                statusCode: 403
                error: Forbidden
                message: This workspace has a target URL, so its events are pushed; clear it to poll
        '404':
          description: As for `POST /hooks/{workspaceId}`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: As for `POST /hooks/{workspaceId}`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          description: As for `POST /hooks/{workspaceId}`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

webhooks:
  message.create:
    post:
      summary: Post a message to a channel
      description: |
        Post `message` in `channel` and remember the mapping from
        `message.id` to your chat's own message id: a later
        `message.update` names the same `message.id`.

        Retried at +1 s and +4 s when the bridge does not answer 2xx within
        5 seconds, then dropped. A retry carries the same `id`.
      parameters:
        - $ref: '#/components/parameters/HijinksSignature'
        - $ref: '#/components/parameters/HijinksEventId'
        - $ref: '#/components/parameters/HijinksWorkspace'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MessageCreateEvent'
            examples:
              gameStart:
                summary: A live game starting — its questions arrive as updates of this message
                value:
                  id: evt_01JA2Z7H2J3K4M5N6P7Q8R9STV
                  type: message.create
                  workspace: 6f0c2b9e-8d4a-4f6b-9c1e-2a7d5e0b4c83
                  sequence: 1760018637000001
                  createdAt: '2026-10-09T14:03:57.000Z'
                  expiresAt: '2026-10-09T14:13:57.000Z'
                  channel: town-square
                  message:
                    id: msg_01JA2Z8KQ4M6V3XG5H7N9PRSTW
                    kind: game_start
                    text: "*🧠 Trivia*\n**Starting a game…**\n\n*Film · Medium · 5 questions*\n*Started by Maya K. · First question in a few seconds*"
                    buttons: []
                    image: null
                    awaitsReply: false
                    data:
                      category: Film
                      difficulty: medium
                      questionCount: 5
                      language: en
                      startedBy: 8x7tq3w5ojy1mrd9ziakh4ebfc
                      startedByDisplayName: Maya K.
              pictionaryInvite:
                summary: A link button (opens a URL instead of sending an action)
                value:
                  id: evt_01JA2ZA0B1C2D3E4F5G6H7J8K9
                  type: message.create
                  workspace: 6f0c2b9e-8d4a-4f6b-9c1e-2a7d5e0b4c83
                  sequence: 1760018700000004
                  createdAt: '2026-10-09T14:05:00.000Z'
                  expiresAt: '2026-10-09T14:15:00.000Z'
                  channel: town-square
                  message:
                    id: msg_01JA2ZA0B1C2D3E4F5G6H7J8K9
                    kind: pictionary_invite
                    text: "**🎨 Pictionary**\n\nMaya K. opened a drawing room — one player sketches, everyone else races to guess. Playing happens in the browser; scores land back here when it ends."
                    buttons:
                      - [{ label: Join room, url: 'https://api.hijinks.team/pictionary/ab12cd34' }]
                    image: null
                    awaitsReply: false
                    data:
                      createdBy: 8x7tq3w5ojy1mrd9ziakh4ebfc
                      createdByDisplayName: Maya K.
                      url: https://api.hijinks.team/pictionary/ab12cd34
      responses:
        2XX:
          description: Delivered. Any other answer, or none within 5 seconds, is a failure.
  message.update:
    post:
      summary: Replace a message Hijinks created
      description: |
        Replace the text, buttons and image of the message you posted for
        `message.id`. `channel` is the channel of the original
        `message.create` — or `dm:<user id>` when the original was a
        `dm.create`.

        A live game keeps one message: the `game_start` card is created, and
        every question, answer count, reveal and the final board arrive as
        updates of it. So an update can be the only copy of a question —
        handle `kind: question` here, not only on create.

        An update that starts a phase (`question`, `gotcha_bait`,
        `gotcha_reveal`, `time_up`, `game_end`) is retried at +1 s and +4 s
        like a create, with the same `id`. Any other update (an answer count, a
        tally) is never retried: the next one supersedes it. Apply updates in
        `sequence` order, skipping one older than the last you applied. An
        update for an id you have not mapped may have overtaken its create's
        retry: hold it for up to 5 seconds, then ignore it (its create was
        dropped).
      parameters:
        - $ref: '#/components/parameters/HijinksSignature'
        - $ref: '#/components/parameters/HijinksEventId'
        - $ref: '#/components/parameters/HijinksWorkspace'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MessageUpdateEvent'
            examples:
              question:
                summary: The next question, drawn over the game's message
                value:
                  id: evt_01JA2Z8KQ4M6V3XG5H7N9PRSTX
                  type: message.update
                  workspace: 6f0c2b9e-8d4a-4f6b-9c1e-2a7d5e0b4c83
                  sequence: 1760018640000001
                  createdAt: '2026-10-09T14:04:00.000Z'
                  expiresAt: '2026-10-09T14:14:00.000Z'
                  channel: town-square
                  message:
                    id: msg_01JA2Z8KQ4M6V3XG5H7N9PRSTW
                    kind: question
                    text: "*🧠 Question 3 of 5 · Film · Medium*\n**Which film was marketed with the tagline “In space, no one can hear you scream”?**\n\n*⏱ 30 seconds to answer*"
                    buttons:
                      - [{ label: Alien, action: answer_2_0 }]
                      - [{ label: Gravity, action: answer_2_1 }]
                      - [{ label: Event Horizon, action: answer_2_2 }]
                      - [{ label: Sunshine, action: answer_2_3 }]
                    image: null
                    awaitsReply: false
                    data:
                      questionIndex: 2
                      totalQuestions: 5
                      category: Film
                      difficulty: medium
                      text: Which film was marketed with the tagline “In space, no one can hear you scream”?
                      allAnswers: [Alien, Gravity, Event Horizon, Sunshine]
                      secondsPerQuestion: 30
                      language: en
              timeUp:
                summary: The question after the clock ran out
                value:
                  id: evt_01JA2Z9B3C4D5E6F7G8H9JKMNP
                  type: message.update
                  workspace: 6f0c2b9e-8d4a-4f6b-9c1e-2a7d5e0b4c83
                  sequence: 1760018670000002
                  createdAt: '2026-10-09T14:04:30.000Z'
                  expiresAt: '2026-10-09T14:14:30.000Z'
                  channel: town-square
                  message:
                    id: msg_01JA2Z8KQ4M6V3XG5H7N9PRSTW
                    kind: time_up
                    text: "*🧠 Question 3 of 5 · Film · Medium*\n**Which film was marketed with the tagline “In space, no one can hear you scream”?**\n\n✅ Alien\n\n🎉 Got it: Priya and Tom\n\n*3 answered*"
                    buttons: []
                    image: null
                    awaitsReply: false
                    data:
                      questionIndex: 2
                      totalQuestions: 5
                      category: Film
                      difficulty: medium
                      text: Which film was marketed with the tagline “In space, no one can hear you scream”?
                      correctAnswer: Alien
                      answeredCount: 3
                      correctUserIds: [u-priya, u-tom]
                      language: en
      responses:
        2XX:
          description: Delivered.
  message.ephemeral:
    post:
      summary: Reply that only one member should see
      description: |
        A reply to the member who ran a command (help, settings, a
        leaderboard) or pressed a button — or a note for one member alone
        that no request asked for: their recap when a game ends, whether
        their guess-who guess was right. Show it to `user` alone in
        `channel` if your chat can; otherwise DM it, or post it.

        `message.id` is minted for the reply but never updated.

        Retried at +1 s and +4 s, then dropped.
      parameters:
        - $ref: '#/components/parameters/HijinksSignature'
        - $ref: '#/components/parameters/HijinksEventId'
        - $ref: '#/components/parameters/HijinksWorkspace'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MessageEphemeralEvent'
            examples:
              leaderboard:
                summary: The reply to `/hijinks leaderboard`
                value:
                  id: evt_01JA2ZB5C6D7E8F9G0H1J2K3M4
                  type: message.ephemeral
                  workspace: 6f0c2b9e-8d4a-4f6b-9c1e-2a7d5e0b4c83
                  sequence: 1760018760000005
                  createdAt: '2026-10-09T14:06:00.000Z'
                  expiresAt: '2026-10-09T14:07:00.000Z'
                  channel: town-square
                  user: 8x7tq3w5ojy1mrd9ziakh4ebfc
                  message:
                    id: msg_01JA2ZB5C6D7E8F9G0H1J2K3M4
                    kind: leaderboard
                    text: "**🏆 Leaderboard · All time**\n\n🥇 Maya K. — 42 points (7 games)\n🥈 Leo — 30 points (5 games)\n🥉 Sam — 12 points (1 game)"
                    buttons: []
                    image: null
                    awaitsReply: false
                    data:
                      period: all-time
                      seasonSince: null
                      entries:
                        - { externalUserId: 8x7tq3w5ojy1mrd9ziakh4ebfc, displayName: Maya K., totalScore: 42, gamesPlayed: 7 }
                        - { externalUserId: q1w2e3r4t5y6u7i8o9p0asdfgh, displayName: Leo, totalScore: 30, gamesPlayed: 5 }
                        - { externalUserId: z9y8x7w6v5u4t3s2r1q0ponmlk, displayName: Sam, totalScore: 12, gamesPlayed: 1 }
      responses:
        2XX:
          description: Delivered.
  dm.create:
    post:
      summary: Send a member a private message
      description: |
        Open (or reuse) a direct conversation with `user` and post `message`
        there: async rounds, Know Your Team prompts, reminders. Map
        `message.id` as for `message.create`; a later `message.update` for
        it arrives with `channel: "dm:<user id>"`. Send button presses and
        typed messages in the DM back as `action` and `message` events with
        that same `channel: "dm:<user id>"` (the member's own `user.id`), so
        Hijinks knows they came from the DM: replies go back to the DM, and a
        typed message there answers the prompt waiting in it, with or without
        `replyTo`.

        Retried at +1 s and +4 s, then dropped.
      parameters:
        - $ref: '#/components/parameters/HijinksSignature'
        - $ref: '#/components/parameters/HijinksEventId'
        - $ref: '#/components/parameters/HijinksWorkspace'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DmCreateEvent'
            examples:
              asyncQuestion:
                summary: A question in a self-paced round
                value:
                  id: evt_01JA2ZC7D8E9F0G1H2J3K4M5N6
                  type: dm.create
                  workspace: 6f0c2b9e-8d4a-4f6b-9c1e-2a7d5e0b4c83
                  sequence: 1760018820000006
                  createdAt: '2026-10-09T14:07:00.000Z'
                  expiresAt: null
                  user: q1w2e3r4t5y6u7i8o9p0asdfgh
                  message:
                    id: msg_01JA2ZC7D8E9F0G1H2J3K4M5N6
                    kind: async_question
                    text: "*Question 1 · Geography · Easy*\n**What is the capital of France?**"
                    buttons:
                      - [{ label: Paris, action: async_answer_7c1e_0_0 }]
                      - [{ label: London, action: async_answer_7c1e_0_1 }]
                      - [{ label: Berlin, action: async_answer_7c1e_0_2 }]
                      - [{ label: Madrid, action: async_answer_7c1e_0_3 }]
                    image: null
                    awaitsReply: false
                    data:
                      sessionId: 7c1e
                      questionIndex: 0
                      category: Geography
                      difficulty: easy
                      text: What is the capital of France?
                      allAnswers: [Paris, London, Berlin, Madrid]
                      reportable: false
      responses:
        2XX:
          description: Delivered.
  ping:
    post:
      summary: Test event
      description: |
        Sent when someone presses "Send test event" in the dashboard. Answer
        2xx; the dashboard shows the status code and latency. Never retried.
      parameters:
        - $ref: '#/components/parameters/HijinksSignature'
        - $ref: '#/components/parameters/HijinksEventId'
        - $ref: '#/components/parameters/HijinksWorkspace'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PingEvent'
            examples:
              ping:
                value:
                  id: evt_01JA2ZD9E0F1G2H3J4K5M6N7P8
                  type: ping
                  workspace: 6f0c2b9e-8d4a-4f6b-9c1e-2a7d5e0b4c83
                  sequence: 1760018880000007
                  createdAt: '2026-10-09T14:08:00.000Z'
                  expiresAt: '2026-10-09T14:09:00.000Z'
      responses:
        2XX:
          description: Reachable.

components:
  parameters:
    HijinksSignature:
      name: Hijinks-Signature
      in: header
      required: true
      description: |
        `t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>`.
        Several `v1=` entries are allowed; any one matching is enough.
      schema:
        type: string
        pattern: '^t=\d+(,v1=[0-9a-fA-F]+)+$'
      example: t=1760018640,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
    HijinksEventId:
      name: Hijinks-Event-Id
      in: header
      required: true
      description: The envelope's `id`. Unchanged across retries; dedupe on it.
      schema:
        $ref: '#/components/schemas/EventId'
    HijinksWorkspace:
      name: Hijinks-Workspace
      in: header
      required: true
      description: The envelope's `workspace`, for routing before the body is parsed.
      schema:
        $ref: '#/components/schemas/WorkspaceId'

  schemas:
    # ─── Shared ────────────────────────────────────────────────────

    WorkspaceId:
      type: string
      format: uuid
      description: The Hijinks workspace, as shown in the dashboard.

    Error:
      type: object
      required: [statusCode, message]
      properties:
        statusCode: { type: integer }
        error: { type: string }
        message:
          description: A human-readable reason. Not stable; branch on the status code.
          type: [string, array]
          items: { type: string }
        field:
          type: string
          description: On a schema `400`, the first field at fault (dotted for nested fields).

    # ─── Inbound ───────────────────────────────────────────────────

    InboundEvent:
      description: One event from the bridge. `type` picks the shape.
      oneOf:
        - $ref: '#/components/schemas/CommandEvent'
        - $ref: '#/components/schemas/ActionEvent'
        - $ref: '#/components/schemas/MessageEvent'
      discriminator:
        propertyName: type
        mapping:
          command: '#/components/schemas/CommandEvent'
          action: '#/components/schemas/ActionEvent'
          message: '#/components/schemas/MessageEvent'

    RequestId:
      type: string
      minLength: 1
      maxLength: 128
      description: |
        Optional. Your id for this request, unique per request; send the
        same one when you retry it. A request repeating an `id` seen in the
        last 24 hours is answered `202` and ignored.

    ChannelId:
      type: string
      minLength: 1
      maxLength: 128
      pattern: '^(?!dm:)'
      description: |
        Your chat's id for the channel, opaque to Hijinks; outbound events
        address the channel by it. Ids starting with `dm:` are reserved:
        Hijinks addresses members' DMs as `dm:<user id>` (see `DmChannelId`).

    DmChannelId:
      type: string
      pattern: '^dm:.'
      maxLength: 131
      description: |
        The acting member's DM with Hijinks: `dm:` followed by the event's
        own `user.id`, exactly. Only `action` and `message` events may come
        from a DM. A `dm:` id on a `command`, or one naming another member,
        is refused with `400` (field `channel`) — the second rule spans two
        fields, so this schema alone cannot express it.

    MemberChannel:
      description: |
        Where the event happened — a channel as in `Channel`, or the member's
        own DM as `dm:<user.id>`, bare or with a display name.
      oneOf:
        - $ref: '#/components/schemas/ChannelId'
        - $ref: '#/components/schemas/DmChannelId'
        - type: object
          additionalProperties: false
          required: [id]
          properties:
            id:
              oneOf:
                - $ref: '#/components/schemas/ChannelId'
                - $ref: '#/components/schemas/DmChannelId'
            name:
              type: [string, 'null']
              maxLength: 128

    Channel:
      description: Where the event happened — a bare id, or an id with a display name.
      oneOf:
        - $ref: '#/components/schemas/ChannelId'
        - type: object
          additionalProperties: false
          required: [id]
          properties:
            id: { $ref: '#/components/schemas/ChannelId' }
            name:
              type: [string, 'null']
              maxLength: 128

    User:
      type: object
      additionalProperties: false
      required: [id]
      description: |
        The member who acted. `id` is your chat's id for them, opaque to
        Hijinks and stable — scores and DMs follow it. `name` is shown in
        results and leaderboards; send it whenever you have it.
      properties:
        id:
          type: string
          minLength: 1
          maxLength: 128
        name:
          type: [string, 'null']
          maxLength: 128

    CommandEvent:
      type: object
      additionalProperties: false
      required: [type, channel, user, text]
      description: A member ran `/hijinks <text>` (or however your chat spells the command).
      properties:
        type: { const: command }
        id: { $ref: '#/components/schemas/RequestId' }
        channel: { $ref: '#/components/schemas/Channel' }
        user: { $ref: '#/components/schemas/User' }
        text:
          type: string
          maxLength: 2000
          description: What followed the command, e.g. `start`, `start science`, `leaderboard`. Empty is `help`.

    ActionEvent:
      type: object
      additionalProperties: false
      required: [type, channel, user, messageId, action]
      description: A member pressed a button, in a channel or in their DM.
      properties:
        type: { const: action }
        id: { $ref: '#/components/schemas/RequestId' }
        channel: { $ref: '#/components/schemas/MemberChannel' }
        user: { $ref: '#/components/schemas/User' }
        messageId:
          type: string
          minLength: 1
          maxLength: 128
          description: The Hijinks `message.id` of the message the button was on.
        action:
          type: string
          minLength: 1
          maxLength: 512
          description: The button's `action`, echoed back untouched.

    MessageEvent:
      type: object
      additionalProperties: false
      required: [type, channel, user, text]
      description: |
        A member typed something: word-game guesses, rebus and charades
        answers, replies to a message with `awaitsReply`. A message that
        answers nothing in play is dropped, so forwarding every message is
        fine. Typed in the member's DM, it carries `channel: "dm:<user.id>"`
        and answers the latest awaiting-reply prompt Hijinks sent there;
        `replyTo`, when present, picks the prompt instead.
      properties:
        type: { const: message }
        id: { $ref: '#/components/schemas/RequestId' }
        channel: { $ref: '#/components/schemas/MemberChannel' }
        user: { $ref: '#/components/schemas/User' }
        text:
          type: string
          minLength: 1
          maxLength: 4000
        replyTo:
          type: [string, 'null']
          maxLength: 128
          description: The Hijinks `message.id` this replies to, when your chat has replies.

    # ─── Outbound ──────────────────────────────────────────────────

    EventId:
      type: string
      pattern: '^evt_[0-9A-HJKMNP-TV-Z]{26}$'
      description: Unique per event and unchanged across its retries; dedupe on it.

    MessageId:
      type: string
      pattern: '^msg_[0-9A-HJKMNP-TV-Z]{26}$'
      description: Minted by Hijinks. Keep a map from it to your chat's message id.

    Sequence:
      type: integer
      format: int64
      minimum: 1
      description: |
        Strictly increasing per workspace, so events that arrive out of order
        can be put back in order (an older `message.update` than one already
        applied can be skipped). Not contiguous. Taken from Hijinks' clock in
        microseconds, so it keeps increasing across Hijinks restarts and
        deploys. Below 2^53, so it fits a JSON number in every language.

    CreatedAt:
      type: string
      format: date-time
      description: When Hijinks created the event (ISO 8601, UTC).

    ExpiresAt:
      type: [string, 'null']
      format: date-time
      description: |
        When the event goes stale (ISO 8601, UTC): past it, drop the event
        unapplied. 60 seconds after `createdAt` for `message.ephemeral` and
        `ping`; 10 minutes for a live game's messages and the answers to
        commands; `null` — never stale — for prompts and results that stay
        valid for hours (self-paced rounds, word games with their own end,
        icebreakers, digests). Compare it with your own clock.

    OutboundChannel:
      type: string
      minLength: 1
      description: |
        A channel id your bridge sent in an inbound event — or `dm:<user id>`
        on a `message.update` of a message first sent by `dm.create`.

    MemberId:
      type: string
      minLength: 1
      description: A member's `user.id`, as your bridge sent it.

    MessageKind:
      type: string
      description: |
        What the message is, so a bridge can special-case a few kinds and
        print the rest. `text` is a plain message with no buttons and no
        `data`; `card` is a generic message (confirmations, errors,
        celebrations, game announcements). New kinds may appear; print
        unknown ones like `card`.
      enum:
        - text
        - card
        - game_start
        - question
        - time_up
        - answer_update
        - gotcha_bait
        - gotcha_reveal
        - game_end
        - help
        - settings
        - categories
        - leaderboard
        - category_champs
        - async_announcement
        - async_question
        - async_results
        - icebreaker
        - icebreaker_results
        - small_talk
        - kyt_reveal
        - weekly_digest
        - anagram
        - wordle_start
        - wordle_board
        - wordle_expired
        - hangman_board
        - hangman_end
        - pictionary_invite
        - schedule_creator
        - schedule_list
        - prefs

    Button:
      description: |
        One button. An action button sends its `action` back in an inbound
        `action` event; a link button opens `url` (for example the Pictionary
        drawing room) and sends nothing.
      oneOf:
        - $ref: '#/components/schemas/ActionButton'
        - $ref: '#/components/schemas/LinkButton'

    ActionButton:
      type: object
      additionalProperties: false
      required: [label, action]
      properties:
        label: { type: string }
        action:
          type: string
          maxLength: 512
          description: Opaque. Echo it back untouched; never parse it.

    LinkButton:
      type: object
      additionalProperties: false
      required: [label, url]
      properties:
        label: { type: string }
        url: { type: string, format: uri }

    Message:
      type: object
      additionalProperties: false
      required: [id, kind, text, buttons, image, awaitsReply, data]
      properties:
        id: { $ref: '#/components/schemas/MessageId' }
        kind: { $ref: '#/components/schemas/MessageKind' }
        text:
          type: string
          description: |
            Chat-flavoured Markdown, ready to print. A bare `\n` is a line
            break (see the API description). Always present; never empty for
            a message worth showing.
        buttons:
          type: array
          description: Rows of buttons, top to bottom, each row left to right. Empty when there are none.
          items:
            type: array
            items: { $ref: '#/components/schemas/Button' }
        image:
          type: [string, 'null']
          format: uri
          description: An image or GIF to show with the message, or null.
        awaitsReply:
          type: boolean
          description: |
            Hijinks would like the member's next typed message to answer this
            one. Offer a reply box or thread if your chat has one, and send the
            answer as a `message` event with `replyTo` set. In a DM, `replyTo`
            is optional: a `message` with `channel: "dm:<user id>"` answers the
            latest prompt waiting there.
        data:
          type: [object, 'null']
          description: |
            The platform-neutral view model the message was rendered from,
            for bridges that draw their own UI. Its shape depends on `kind`
            and is not part of the stable contract during the beta.

    MessageCreateEvent:
      type: object
      additionalProperties: false
      required: [id, type, workspace, sequence, createdAt, expiresAt, channel, message]
      properties:
        id: { $ref: '#/components/schemas/EventId' }
        type: { const: message.create }
        workspace: { $ref: '#/components/schemas/WorkspaceId' }
        sequence: { $ref: '#/components/schemas/Sequence' }
        createdAt: { $ref: '#/components/schemas/CreatedAt' }
        expiresAt: { $ref: '#/components/schemas/ExpiresAt' }
        channel: { $ref: '#/components/schemas/OutboundChannel' }
        message: { $ref: '#/components/schemas/Message' }

    MessageUpdateEvent:
      type: object
      additionalProperties: false
      required: [id, type, workspace, sequence, createdAt, expiresAt, channel, message]
      properties:
        id: { $ref: '#/components/schemas/EventId' }
        type: { const: message.update }
        workspace: { $ref: '#/components/schemas/WorkspaceId' }
        sequence: { $ref: '#/components/schemas/Sequence' }
        createdAt: { $ref: '#/components/schemas/CreatedAt' }
        expiresAt: { $ref: '#/components/schemas/ExpiresAt' }
        channel: { $ref: '#/components/schemas/OutboundChannel' }
        message: { $ref: '#/components/schemas/Message' }

    MessageEphemeralEvent:
      type: object
      additionalProperties: false
      required: [id, type, workspace, sequence, createdAt, expiresAt, channel, user, message]
      properties:
        id: { $ref: '#/components/schemas/EventId' }
        type: { const: message.ephemeral }
        workspace: { $ref: '#/components/schemas/WorkspaceId' }
        sequence: { $ref: '#/components/schemas/Sequence' }
        createdAt: { $ref: '#/components/schemas/CreatedAt' }
        expiresAt: { $ref: '#/components/schemas/ExpiresAt' }
        channel: { $ref: '#/components/schemas/OutboundChannel' }
        user: { $ref: '#/components/schemas/MemberId' }
        message: { $ref: '#/components/schemas/Message' }

    DmCreateEvent:
      type: object
      additionalProperties: false
      required: [id, type, workspace, sequence, createdAt, expiresAt, user, message]
      properties:
        id: { $ref: '#/components/schemas/EventId' }
        type: { const: dm.create }
        workspace: { $ref: '#/components/schemas/WorkspaceId' }
        sequence: { $ref: '#/components/schemas/Sequence' }
        createdAt: { $ref: '#/components/schemas/CreatedAt' }
        expiresAt: { $ref: '#/components/schemas/ExpiresAt' }
        user: { $ref: '#/components/schemas/MemberId' }
        message: { $ref: '#/components/schemas/Message' }

    PingEvent:
      type: object
      additionalProperties: false
      required: [id, type, workspace, sequence, createdAt, expiresAt]
      properties:
        id: { $ref: '#/components/schemas/EventId' }
        type: { const: ping }
        workspace: { $ref: '#/components/schemas/WorkspaceId' }
        sequence: { $ref: '#/components/schemas/Sequence' }
        createdAt: { $ref: '#/components/schemas/CreatedAt' }
        expiresAt: { $ref: '#/components/schemas/ExpiresAt' }

    # ─── Polling ───────────────────────────────────────────────────

    Cursor:
      type: string
      pattern: '^\d{1,18}$'
      description: A poll position. Opaque; pass it back as `after` exactly as received.

    OutboundEvent:
      description: Any event Hijinks sends a bridge. `type` picks the shape.
      oneOf:
        - $ref: '#/components/schemas/MessageCreateEvent'
        - $ref: '#/components/schemas/MessageUpdateEvent'
        - $ref: '#/components/schemas/MessageEphemeralEvent'
        - $ref: '#/components/schemas/DmCreateEvent'
        - $ref: '#/components/schemas/PingEvent'
      discriminator:
        propertyName: type
        mapping:
          message.create: '#/components/schemas/MessageCreateEvent'
          message.update: '#/components/schemas/MessageUpdateEvent'
          message.ephemeral: '#/components/schemas/MessageEphemeralEvent'
          dm.create: '#/components/schemas/DmCreateEvent'
          ping: '#/components/schemas/PingEvent'

    EventPage:
      type: object
      additionalProperties: false
      required: [events, next, hasMore, gap]
      properties:
        events:
          type: array
          maxItems: 100
          description: Oldest first; each exactly the envelope a push would have carried.
          items:
            $ref: '#/components/schemas/OutboundEvent'
        next:
          description: |
            Pass as `after` on the next poll. The `after` you sent when no
            event was new. For `after=latest`, the newest cursor (`"0"`
            when the workspace has never held an event). Null only when you
            sent no `after` and nothing is held.
          oneOf:
            - $ref: '#/components/schemas/Cursor'
            - type: 'null'
        hasMore:
          type: boolean
          description: More events wait past this page; poll again at once.
        gap:
          type: boolean
          description: |
            Events after your `after` expired (24 hours) before you fetched
            them: the bridge missed something. Tell the channel if you like
            ("Hijinks was unreachable; some messages were missed") and carry
            on from `next`. Always false without `after`.
