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=50returns the latest 50 messages, newest first.GET /rooms/{room_id}/messages?before={message_id}&limit=50returns 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.