The Chat API

We assumed that Courselore’s browser client talks to the server through a REST API. Here are chat’s endpoints:

Request Effect
GET /courses/{course_id}/rooms The rooms the user may see
POST /courses/{course_id}/rooms Create a room
GET /courses/{course_id}/rooms/{room_id} One room
POST /courses/{course_id}/rooms/{room_id}/members Add a member
DELETE /courses/{course_id}/rooms/{room_id}/members/{user_id} Remove a member
GET /courses/{course_id}/rooms/{room_id}/messages Read messages
GET /courses/{course_id}/rooms/{room_id}/messages/{message_id} Read one message
POST /courses/{course_id}/rooms/{room_id}/messages Post a message

GET /courses/{course_id}/rooms returns every public room in the course and the private rooms the user is a member of.

Here is the representation of a message:

{
  "id": 61843,
  "room_id": 12,
  "author": {
    "id": 7,
    "name": "Priya Shah"
  },
  "body": "Design doc: https://docs.example/team-4-design",
  "created_at": "2026-10-13T14:02:11Z"
}

The paths

Recall the rule for nesting: if a resource is always reached in the context of another resource, nest it. Members and messages are always reached through their room, so they go under the room. The collection of rooms goes under the course, because a client lists and creates rooms in a particular course.

Shorter paths

If room identifiers are unique across courses, as they are when room_id is the primary key of chat_rooms, the room’s identifier alone is enough to find the room. Then the API can drop the /courses/{course_id} prefix below the room, as in /rooms/{room_id}/messages. This is common practice. An API can offer the short path as an alias for the long one. Both paths reach the same room. The server still checks that the user is a participant in the room’s course.

We assume the API provides these short paths as aliases. From here on, we drop the /courses/{course_id} prefix below the room, as in /rooms/{room_id}/messages.

Reading messages

The client reads a room’s messages in three ways. Each way is one request:

  • GET /rooms/{room_id}/messages?limit=50 returns the latest 50 messages, newest first.
  • GET /rooms/{room_id}/messages?before={message_id}&limit=50 returns the 50 messages before that message, newest first.
  • GET /rooms/{room_id}/messages?after={message_id} returns every message after that message, oldest first.

Why do we use cursor-based pagination here? People keep posting to a room, and the client shows the newest messages first. So page 1 is the newest 50 messages, page 2 the 50 before those, and so on. When a new message is posted, it becomes the first message of page 1, and every other message moves one place back. The last message of page 1 becomes the first message of page 2, so a client that moves to page 2 sees it twice. A cursor marks a position in the list, and a new message does not move that position. before and after are cursors. Each is a plain message identifier. The client already has these identifiers.

A list of messages has this shape:

{
  "messages": [
    // messages
  ],
  "has_more": true
}

has_more does the job of hasNextPage in the shop API. It is true when there are messages older than the ones in the response.

Posting a message

Here is Priya, a student on Team 4, posting the link to the team’s design doc. Team 4’s room is room 12.

Request:

POST /rooms/12/messages HTTP/1.1
Content-Type: application/json
Idempotency-Key: 9c2e

{
  "body": "Design doc: https://docs.example/team-4-design"
}

Response:

HTTP/1.1 201 Created
Location: /rooms/12/messages/61843
Content-Type: application/json

{
  "id": 61843,
  // other message attributes
}

The response is the same as for a new note in the notes API: 201 Created, the path of the new message in Location, and the message in the body. The path in Location is the endpoint for reading one message.

The Idempotency-Key header works as it does in the notes API. A retry does not post the message twice.

Errors

  • 400 Bad Request: a message with an empty body.
  • 403 Forbidden: a student tries to create a room or add a member. Only instructors may.
  • 404 Not Found: a room that does not exist, or a private room the user is not a member of.
  • 409 Conflict: a retry with the same idempotency key but a different body.

A non-member of a private room gets 404, not 403. This follows a decision we made for the shop API. A shopper who requests another shopper’s cart gets NOT_FOUND, not FORBIDDEN. That way the response does not confirm that the cart exists.