Open Morse

Reference

Meetings

22 routes, generated from Morse’s own OpenAPI document. Every path below hangs off the base URL, and every one needs the bearer header.

The examples are built from each route’s schema, so the shapes and types are exactly what the API declares. The values are illustrative, and no one has run them.

Unauthenticated on purpose: the join page must render for someone who is not signed in yet. Returns no title (ADR-009).

GET/m/{code}

i is the per-recipient invite token. Presented and valid, it earns one extra fact: who invited them. This is provenance, not verification — a forwarded email forwards the token, exactly as a calendar invite does — and the name is no more than the host's, which every visitor already sees. Carries how many are inside, never who (plan 018 D4, narrowed in review of #132). Read once when the join page loads, not polled: the question is whether there is anyone there to let you in, which is answered on arrival. A repeated read would make a forwarded link a presence feed, sampled without anything on the host's screen to show it happened.

GET/m/{code}

curl "$MORSE/m/4KJ9P2" \
  -H "Authorization: Bearer $MORSE_TOKEN"
200
{
  "code": "4KJ9P2",
  "host_name": "Priya Shah",
  "id": "3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60",
  "invited_by_name": "Priya Shah",
  "recording_enabled": true,
  "status": "…",
  "transcription_enabled": true
}

Your meetings, newest first, at most 100: ones you host, and ones you were in, invited to or waited for.

GET/meetings

GET/meetings

curl "$MORSE/meetings" \
  -H "Authorization: Bearer $MORSE_TOKEN"
200
[
  {
    "archived": true,
    "at_risk": true,
    "attendee_count": 3,
    "attendees": [
      {
        "id": "3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60",
        "name": "Priya Shah"
      }
    ],
    "booked": true,
    "booked_by_me": true,
    "booked_timezone": "Asia/Kolkata",
    "booking_note": "…",
    "calendar_sync_status": "synced",
    "code": "4KJ9P2",
    "copilot_enabled": true,
    "created_at": "2026-10-02T10:30:00+05:30",
    "duration_minutes": 30,
    "ended_at": "2026-10-02T10:30:00+05:30",
    "generated_title": "…",
    "has_whiteboard": true,
    "host_id": "3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60",
    "host_name": "Priya Shah",
    "host_title": "…",
    "id": "3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60",
    "in_room": true,
    "internal": true,
    "invite_counts": 3,
    "invitee_names": [
      "Priya Shah"
    ],
    "invitees": [
      {
        "id": "3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60",
        "name": "Priya Shah"
      }
    ],
    "is_host": true,
    "locked": true,
    "my_proposed_start": "2026-10-02T10:30:00+05:30",
    "my_response": "…",
    "notes_ready": true,
    "notes_viewed": true,
    "notes_writing": true,
    "on_calendar": true,
    "open_suggestions": 1,
    "record_access": "host",
    "recording_available": true,
    "recording_enabled": true,
    "scheduled_at": "2026-10-02T10:30:00+05:30",
    "started_at": "2026-10-02T10:30:00+05:30",
    "status": "…",
    "summary_template": "…",
    "title": "Pricing review",
    "transcription_enabled": true,
    "whiteboard_open": true
  }
]

Create a meeting you host, for now or scheduled (scheduled_at, a length and a timezone).

POST/meetings

Optionally invite people in the same call with emails; each comes back under invites with how it was delivered. Returns the meeting with its join code.

POST/meetings

curl -X POST "$MORSE/meetings" \
  -H "Authorization: Bearer $MORSE_TOKEN" \
  -H "x-timezone: Asia/Kolkata" \
  -H "Content-Type: application/json" \
  -d '{ "booked_timezone": "Asia/Kolkata", "duration_minutes": 30, "emails": [ "priya@neuralarc.ai" ], "key_emails": [ "priya@neuralarc.ai" ], "scheduled_at": "2026-10-02T10:30:00+05:30", "title": "Pricing review" }'
201
{
  "archived": true,
  "at_risk": true,
  "attendee_count": 3,
  "attendees": [
    {
      "id": "3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60",
      "name": "Priya Shah"
    }
  ],
  "booked": true,
  "booked_by_me": true,
  "booked_timezone": "Asia/Kolkata",
  "booking_note": "…",
  "calendar_sync_status": "synced",
  "code": "4KJ9P2",
  "copilot_enabled": true,
  "created_at": "2026-10-02T10:30:00+05:30",
  "duration_minutes": 30,
  "ended_at": "2026-10-02T10:30:00+05:30",
  "generated_title": "…",
  "has_whiteboard": true,
  "host_id": "3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60",
  "host_name": "Priya Shah",
  "host_title": "…",
  "id": "3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60",
  "in_room": true,
  "internal": true,
  "invite_counts": 3,
  "invitee_names": [
    "Priya Shah"
  ],
  "invitees": [
    {
      "id": "3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60",
      "name": "Priya Shah"
    }
  ],
  "invites": [
    {
      "delivery": "…",
      "detail": "…",
      "email": "priya@neuralarc.ai",
      "kind": "…"
    }
  ],
  "is_host": true,
  "locked": true,
  "my_proposed_start": "2026-10-02T10:30:00+05:30",
  "my_response": "…",
  "notes_ready": true,
  "notes_viewed": true,
  "notes_writing": true,
  "on_calendar": true,
  "open_suggestions": 1,
  "record_access": "host",
  "recording_available": true,
  "recording_enabled": true,
  "scheduled_at": "2026-10-02T10:30:00+05:30",
  "started_at": "2026-10-02T10:30:00+05:30",
  "status": "…",
  "summary_template": "…",
  "title": "Pricing review",
  "transcription_enabled": true,
  "whiteboard_open": true
}

Read a line of plain English, such as 'pricing review with Arjun Thursday 3pm UK time', into the schedule form's fields.

POST/meetings/interpret

Books nothing.

POST/meetings/interpret

curl -X POST "$MORSE/meetings/interpret" \
  -H "Authorization: Bearer $MORSE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "text": "Annual plans are billed up front.", "zone": "…" }'
200
{
  "copilot": true,
  "date": "2026-10-02",
  "emails": [
    "priya@neuralarc.ai"
  ],
  "locked": true,
  "minutes": 30,
  "names": [
    "Priya Shah"
  ],
  "recording": true,
  "template": "…",
  "time": "…",
  "timezone": "Asia/Kolkata",
  "title": "Pricing review",
  "transcription": true
}

Find a meeting in your own list by its title, its minutes or — for the host — its transcript.

GET/meetings/search

Never widens what any other route lets you read.

GET/meetings/search

curl "$MORSE/meetings/search?q=pricing" \
  -H "Authorization: Bearer $MORSE_TOKEN"
200
[
  {
    "generated_title": "…",
    "match": "title",
    "meeting_id": "3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60",
    "record_access": "host",
    "scheduled_at": "2026-10-02T10:30:00+05:30",
    "snippet": "…",
    "started_at": "2026-10-02T10:30:00+05:30",
    "status": "…",
    "title": "Pricing review"
  }
]

Your meetings list changed. One event type, meetings, and no payload: the client refetches (events.meetings_topic).

GET/meetings/stream

Declared above /{meeting_id} so the literal path wins the match. No catch-up: the page server-renders the list it opens with, and anything raised between that render and this connection is a gap of milliseconds on a list the client refetches anyway. **Trigger to add one:** a report of a meeting that never appeared until a reload. events is in-process (ADR-007), so this reaches a client only when the publisher ran on the same API process. That holds at one replica and is the docstring's stated trigger to build LISTEN/NOTIFY fan-out — until then an invitee connected elsewhere simply sees the meeting on their next load, which is the behaviour before this stream existed.

GET/meetings/stream

curl "$MORSE/meetings/stream" \
  -H "Authorization: Bearer $MORSE_TOKEN"

One meeting: its status, times, host and settings, and whether you may read its notes.

GET/meetings/{meeting_id}

GET/meetings/{meeting_id}

curl "$MORSE/meetings/3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60" \
  -H "Authorization: Bearer $MORSE_TOKEN"
200
{
  "archived": true,
  "at_risk": true,
  "attendee_count": 3,
  "attendees": [
    {
      "id": "3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60",
      "name": "Priya Shah"
    }
  ],
  "booked": true,
  "booked_by_me": true,
  "booked_timezone": "Asia/Kolkata",
  "booking_note": "…",
  "calendar_sync_status": "synced",
  "code": "4KJ9P2",
  "copilot_enabled": true,
  "created_at": "2026-10-02T10:30:00+05:30",
  "duration_minutes": 30,
  "ended_at": "2026-10-02T10:30:00+05:30",
  "generated_title": "…",
  "has_whiteboard": true,
  "host_id": "3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60",
  "host_name": "Priya Shah",
  "host_title": "…",
  "id": "3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60",
  "in_room": true,
  "internal": true,
  "invite_counts": 3,
  "invitee_names": [
    "Priya Shah"
  ],
  "invitees": [
    {
      "id": "3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60",
      "name": "Priya Shah"
    }
  ],
  "is_host": true,
  "locked": true,
  "my_proposed_start": "2026-10-02T10:30:00+05:30",
  "my_response": "…",
  "notes_ready": true,
  "notes_viewed": true,
  "notes_writing": true,
  "on_calendar": true,
  "open_suggestions": 1,
  "record_access": "host",
  "recording_available": true,
  "recording_enabled": true,
  "scheduled_at": "2026-10-02T10:30:00+05:30",
  "started_at": "2026-10-02T10:30:00+05:30",
  "status": "…",
  "summary_template": "…",
  "title": "Pricing review",
  "transcription_enabled": true,
  "whiteboard_open": true
}

Rename, reschedule, re-length. Host only — joining is not gated on membership, so without the created_by guard any account that can reach a meeting could retitle or move it.

PATCH/meetings/{meeting_id}

**True PATCH semantics** (IMPLEMENTATION.md:622's idiom): model_fields_set is the only way to tell "field not sent" from "field sent as null", and here the difference is load-bearing. An absent scheduled_at leaves the time alone, and an explicit null is refused on a meeting that has one — a booked meeting is moved or cancelled, never unscheduled. A rename that silently unscheduled a meeting would be a very expensive bug to find. An empty or whitespace title still clears back to NULL rather than storing a blank: NULL is what marks a meeting as unnamed, and the UI derives a label from its time for those. Storing the derived label would fix it to the creator's timezone and lose the distinction between named and unnamed.

PATCH/meetings/{meeting_id}

curl -X PATCH "$MORSE/meetings/3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60" \
  -H "Authorization: Bearer $MORSE_TOKEN" \
  -H "x-timezone: Asia/Kolkata" \
  -H "Content-Type: application/json" \
  -d '{ "booked_timezone": "Asia/Kolkata", "duration_minutes": 30, "scheduled_at": "2026-10-02T10:30:00+05:30", "title": "Pricing review", "transcription_enabled": true }'
200
{
  "archived": true,
  "at_risk": true,
  "attendee_count": 3,
  "attendees": [
    {
      "id": "3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60",
      "name": "Priya Shah"
    }
  ],
  "booked": true,
  "booked_by_me": true,
  "booked_timezone": "Asia/Kolkata",
  "booking_note": "…",
  "calendar_sync_status": "synced",
  "code": "4KJ9P2",
  "copilot_enabled": true,
  "created_at": "2026-10-02T10:30:00+05:30",
  "duration_minutes": 30,
  "ended_at": "2026-10-02T10:30:00+05:30",
  "generated_title": "…",
  "has_whiteboard": true,
  "host_id": "3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60",
  "host_name": "Priya Shah",
  "host_title": "…",
  "id": "3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60",
  "in_room": true,
  "internal": true,
  "invite_counts": 3,
  "invitee_names": [
    "Priya Shah"
  ],
  "invitees": [
    {
      "id": "3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60",
      "name": "Priya Shah"
    }
  ],
  "is_host": true,
  "locked": true,
  "my_proposed_start": "2026-10-02T10:30:00+05:30",
  "my_response": "…",
  "notes_ready": true,
  "notes_viewed": true,
  "notes_writing": true,
  "on_calendar": true,
  "open_suggestions": 1,
  "record_access": "host",
  "recording_available": true,
  "recording_enabled": true,
  "scheduled_at": "2026-10-02T10:30:00+05:30",
  "started_at": "2026-10-02T10:30:00+05:30",
  "status": "…",
  "summary_template": "…",
  "title": "Pricing review",
  "transcription_enabled": true,
  "whiteboard_open": true
}

Host only, and only once it is over. The meeting goes from everyone's list and every read of it answers 404, but nothing is erased: its transcript, summary, recordings and send record all stay, behind deleted_at. So a summary still being written, or a recording still uploading, finishes into a meeting nobody can open rather than failing.

DELETE/meetings/{meeting_id}

Deleting twice is not an error: a double click or a second tab asked for what has already happened. An upcoming meeting is cancelled rather than deleted: the people invited to it hold a link, and "cancelled" is an answer that link can give. A live one has to end first, or the room would outlive the meeting that says who may be in it.

DELETE/meetings/{meeting_id}

curl -X DELETE "$MORSE/meetings/3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60" \
  -H "Authorization: Bearer $MORSE_TOKEN"

Archive a meeting out of your own list: tidying, not deleting.

PUT/meetings/{meeting_id}/archive

Nothing else changes — the meeting, its notes and everyone else's list stay as they were, a link still opens it, and DELETE brings it back. Host or attendee. Idempotent.

PUT/meetings/{meeting_id}/archive

curl -X PUT "$MORSE/meetings/3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60/archive" \
  -H "Authorization: Bearer $MORSE_TOKEN"

Bring an archived meeting back into your list.

DELETE/meetings/{meeting_id}/archive

Idempotent.

DELETE/meetings/{meeting_id}/archive

curl -X DELETE "$MORSE/meetings/3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60/archive" \
  -H "Authorization: Bearer $MORSE_TOKEN"

Try again to put this meeting on Google Calendar.

POST/meetings/{meeting_id}/calendar-sync

The whole of D21: rather than a retry queue and a worker for a failure that will happen a handful of times a year at this size, the state is made visible and the fix is a button. The host already has the page open.

POST/meetings/{meeting_id}/calendar-sync

curl -X POST "$MORSE/meetings/3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60/calendar-sync" \
  -H "Authorization: Bearer $MORSE_TOKEN"
200
{
  "archived": true,
  "at_risk": true,
  "attendee_count": 3,
  "attendees": [
    {
      "id": "3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60",
      "name": "Priya Shah"
    }
  ],
  "booked": true,
  "booked_by_me": true,
  "booked_timezone": "Asia/Kolkata",
  "booking_note": "…",
  "calendar_sync_status": "synced",
  "code": "4KJ9P2",
  "copilot_enabled": true,
  "created_at": "2026-10-02T10:30:00+05:30",
  "duration_minutes": 30,
  "ended_at": "2026-10-02T10:30:00+05:30",
  "generated_title": "…",
  "has_whiteboard": true,
  "host_id": "3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60",
  "host_name": "Priya Shah",
  "host_title": "…",
  "id": "3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60",
  "in_room": true,
  "internal": true,
  "invite_counts": 3,
  "invitee_names": [
    "Priya Shah"
  ],
  "invitees": [
    {
      "id": "3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60",
      "name": "Priya Shah"
    }
  ],
  "is_host": true,
  "locked": true,
  "my_proposed_start": "2026-10-02T10:30:00+05:30",
  "my_response": "…",
  "notes_ready": true,
  "notes_viewed": true,
  "notes_writing": true,
  "on_calendar": true,
  "open_suggestions": 1,
  "record_access": "host",
  "recording_available": true,
  "recording_enabled": true,
  "scheduled_at": "2026-10-02T10:30:00+05:30",
  "started_at": "2026-10-02T10:30:00+05:30",
  "status": "…",
  "summary_template": "…",
  "title": "Pricing review",
  "transcription_enabled": true,
  "whiteboard_open": true
}

Host only, and only before it starts. Distinct from /end, which stops one that is running — cancelled and completed are different facts about a meeting. The link people hold keeps working, and says it was cancelled.

POST/meetings/{meeting_id}/cancel

Who hears it: everyone the host invited (invitations) — colleagues, with an account or without, and guests the host invited. Google tells the colleagues on the event as it clears it from their calendars, and Morse emails everyone else, so each person hears it once (plan 002 D10). Not a guest who typed their own address at the door: anyone holding the link can do that, with any address, and emailing it the title would tell a link visitor what ADR-009 says they never learn. Anyone knocking is told through the lobby instead. Email being down does not keep the meeting on: it is cancelled either way, and not_emailed tells the host who still needs telling.

POST/meetings/{meeting_id}/cancel

curl -X POST "$MORSE/meetings/3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60/cancel" \
  -H "Authorization: Bearer $MORSE_TOKEN" \
  -H "x-timezone: Asia/Kolkata"
200
{
  "emailed": "priya@neuralarc.ai",
  "not_emailed": "priya@neuralarc.ai"
}

Knowledge worth having open for this meeting.

GET/meetings/{meeting_id}/context

The query is built here rather than taken from the caller, for two reasons: the booking note is the host's alone, so the server has to decide whether it goes in; and a route that embedded and searched arbitrary text would be an open retrieval endpoint, which is a cost to anyone who asks and a way to probe someone else's Knowledge one phrase at a time. Authorised by the same predicate as the list (_IN_LIST): if the meeting is already on your page, you may ask what relates to it. Results are scoped to your own Knowledge by search itself, in SQL, so this cannot widen what you can read. Returns an empty list rather than an error whenever there is nothing honest to say — no key, nothing embedded, a title that is only a date. The card that reads this shows nothing in that case, which is the right answer.

GET/meetings/{meeting_id}/context

curl "$MORSE/meetings/3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60/context" \
  -H "Authorization: Bearer $MORSE_TOKEN"
200
{
  "notes": [
    {
      "doc_id": "3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60",
      "excerpt": "…",
      "folder_name": "Priya Shah",
      "title": "Pricing review"
    }
  ]
}

End a meeting for everyone, which starts its notes being written.

POST/meetings/{meeting_id}/end

Host only.

POST/meetings/{meeting_id}/end

curl -X POST "$MORSE/meetings/3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60/end" \
  -H "Authorization: Bearer $MORSE_TOKEN"

An attendee removing a finished meeting from their own list. Nothing else changes: the meeting, its minutes and everyone else's list are as they were, and a direct link still opens it.

POST/meetings/{meeting_id}/hide

Not for the host, who owns the meeting and deletes it instead — hiding it from the one list that matches on created_by would do nothing. Only ended meetings, because hiding an upcoming or live one would lose the way in. Idempotent: hiding twice keeps the first time.

POST/meetings/{meeting_id}/hide

curl -X POST "$MORSE/meetings/3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60/hide" \
  -H "Authorization: Bearer $MORSE_TOKEN"

Undo a hide: the meeting is back in the caller's own list.

DELETE/meetings/{meeting_id}/hide

Idempotent.

DELETE/meetings/{meeting_id}/hide

curl -X DELETE "$MORSE/meetings/3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60/hide" \
  -H "Authorization: Bearer $MORSE_TOKEN"

Join a meeting: a LiveKit token to connect with, or waiting when it is locked and someone in the room has to let you in.

POST/meetings/{meeting_id}/join

Refused once it has ended or been cancelled.

POST/meetings/{meeting_id}/join

curl -X POST "$MORSE/meetings/3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60/join" \
  -H "Authorization: Bearer $MORSE_TOKEN"
200
{
  "anyone_can_share": true,
  "display_name": "Priya Shah",
  "livekit_url": "https://onmorse.com/priya",
  "meeting_role": "…",
  "room_name": "Priya Shah",
  "status": "…",
  "token": "mp_YOUR_MORSE_TOKEN",
  "whiteboard_open": true
}

Covers leaving the lobby as well as the room.

POST/meetings/{meeting_id}/leave

Without the waiting case the host keeps seeing a knock from someone who has gone, and the waiting badge counts a ghost.

POST/meetings/{meeting_id}/leave

curl -X POST "$MORSE/meetings/3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60/leave" \
  -H "Authorization: Bearer $MORSE_TOKEN"

Who is in the room, for the pre-join screen: whether joining walks into a meeting already under way or an empty room.

GET/meetings/{meeting_id}/occupancy

Anyone who may open the meeting may ask; the lobby already tells anyone waiting the same thing.

GET/meetings/{meeting_id}/occupancy

curl "$MORSE/meetings/3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60/occupancy" \
  -H "Authorization: Bearer $MORSE_TOKEN"
200
{
  "count": 3,
  "names": [
    "Priya Shah"
  ]
}

Undo a delete.

POST/meetings/{meeting_id}/restore

Host only. Deleting only ever hid the meeting, so this puts it back for everyone as it was. Restoring one that isn't deleted is not an error.

POST/meetings/{meeting_id}/restore

curl -X POST "$MORSE/meetings/3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60/restore" \
  -H "Authorization: Bearer $MORSE_TOKEN"

The caller opened this meeting's notes. Clears the unread mark in their own list and nobody else's.

POST/meetings/{meeting_id}/viewed

Gated on the same rule as the list — the host, or anyone with a participant row who was not shown the door — because a mark on a meeting that is not in your list is one nobody can see. 404 rather than 403, so a stranger learns nothing about whether it exists. Idempotent, and the first open is the one kept.

POST/meetings/{meeting_id}/viewed

curl -X POST "$MORSE/meetings/3f9c1a24-5e6f-4b31-9a77-1b2c3d4e5f60/viewed" \
  -H "Authorization: Bearer $MORSE_TOKEN"