REST API 레퍼런스
엔진의 REST 라우트 전부다. 어떤 언어로든 HTTP와 JSON만 되면 부를 수 있다. 실시간 이벤트는 REST가 아니라 실시간 프로토콜(WebSocket)이 담당하고, 이 문서는 요청해서 받아 오는 쪽이다.
- 기준 주소:
https://api.chat.gravex.app(모든 경로가/v1로 시작한다) - 본문은 JSON(UTF-8)이다. 본문이 있는 요청에는
Content-Type: application/json을 보낸다. - 시각은 모두 UTC 에포크 밀리초 정수다(
createdAt: 1792861844890). - 방·메시지의 id는 서버가 만드는 13자 문자열(Crockford Base32, 예
0AAS4P1MM0000)이다. 속뜻을 읽지 말고 문자열로만 다룬다. - 유저 id는 서비스가 정한 문자열(토큰의
sub)이다. 경로에 넣을 때는 URL 인코딩한다. - 모르는 응답 필드는 무시한다. 필드는 더해지기만 하고 이름이 바뀌거나 없어지지 않는다.
인증
호출자는 둘이다. 라우트마다 어느 쪽이 되는지는 아래 표의 권한 칸에 있다.
| 호출자 | 헤더 | 용도 |
|---|---|---|
서버 키 (sk) |
Authorization: Bearer sk_… |
백엔드. 서비스의 대리인이라 방 멤버 검사를 건너뛴다 |
유저 토큰 (유저) |
Authorization: Bearer <JWT> 와 X-Chat-Key: pk_… |
앱·브라우저. 한 유저로서 동작하고 WebSocket과 같은 접근 규칙을 받는다 |
- 유저 토큰은 백엔드가
sk_로 서명한 JWT다. 만드는 법은 서버 연동의 「유저 토큰 규격」에 있다. - 두 헤더가 함께 오면
sk_가 이긴다.Bearer의 대소문자는 가리지 않는다. sk_는 서버에만 둔다. 앱이나 브라우저에 넣지 않는다.- 브라우저 요청은
Origin이pk_의 허용 origin에 있어야 한다(스킴·호스트·포트가 정확히 같아야 한다). 목록에 없으면 401이고 메시지에 이유가 적힌다.Origin헤더가 없는 요청(iOS·Android·Flutter·서버)은 이 검사를 받지 않는다. - 인증 실패는 401
unauthorized, 인증은 됐지만 그 일을 못 하는 호출자는 403forbidden이다. - 다른 앱의 방 id는 403이 아니라 404다.
- 토큰 서명이 틀렸든, 만료됐든, 무효화됐든 이유를 가리지 않고 401 하나다. 원인은 응답이 아니라 토큰 규격과 대조해서 찾는다.
- 유저 토큰이 만료되면 401이 온다. 백엔드에서 새 토큰을 받아 한 번만 다시 시도한다. 두 번째 401은 만료가 아니다.
공통 규칙
에러 형식
실패는 HTTP 상태 코드와 함께 이 모양의 JSON이다.
{ "error": { "code": "rate_limited", "message": "rate limit exceeded", "retryAfterMs": 800 } }- 분기는
code로 한다.message는 사람이 읽는 영어 문장이고 바뀔 수 있다. retryAfterMs는rate_limited에만 있다.appCode는moderation_denied에만 있다.- 본문이 없는 성공은 204다.
code |
HTTP | 뜻 | 재시도 |
|---|---|---|---|
unauthorized |
401 | 키·토큰이 없거나 틀렸다 | 토큰이면 새로 받아 한 번 |
forbidden |
403 | 이 호출자는 이 일을 못 한다 | 아니오 |
banned |
403 | 밴된 유저의 발행·리액션 | 밴이 풀린 뒤 |
moderation_denied |
403 | 서비스의 before_publish 훅이 거절했다. message는 서비스가 정한 문구이고 appCode는 서비스가 정한 기계 판독 코드다 |
아니오 |
quota_exceeded |
403 | 앱의 저장 쿼터가 찼다 | 아니오 |
not_found |
404 | 없는 자원이거나 지워졌거나 다른 앱의 것이다 | 아니오 |
invalid |
400 | 요청이 틀렸다(형식, 필수 필드, 허용되지 않는 값). 본문이 1 MB를 넘거나 JSON이 아닐 때도 400이다 | 고친 뒤 |
invalid |
409 | 방 key가 이미 쓰이고 있다 |
다른 key로 |
too_large |
413 | 메시지 본문이 bodyTextMaxLen을 넘는다 |
줄인 뒤 |
cursor_too_old |
410 | 요청한 after가 보존 범위보다 오래됐다 |
아래 「히스토리」 |
rate_limited |
429 | 한도 초과. Retry-After(초)와 retryAfterMs가 온다 |
retryAfterMs 뒤 |
conflict |
409 | 지금 상태와 부딪힌다(이미 도는 내보내기, 탈퇴하지 않은 유저의 영구 삭제, 이미 도는 영구 삭제) | 상태를 바꾼 뒤 |
internal |
500 | 서버 오류 | 백오프로 |
HTTP 409는 code가 무엇이든 "지금 상태와 부딪힘"으로 다룬다. 5xx와 네트워크 오류는 지수 백오프로 다시 시도한다.
한도
- 메시지 발행(
POST …/messages)은 유저마다 초당 5건, 순간 10건이다. 편집(PATCH …/messages/…)은 초당 2건이다. 유저 토큰으로 방을 만드는 일은 하루 50개다. - 서버 키(
sk_) 호출은 이 한도를 세지 않는다. - 걸리면 429
rate_limited다. 응답 헤더Retry-After는 초 단위, 본문의retryAfterMs는 밀리초이고 같은 시간이다. 그만큼 기다렸다가 같은 요청을 다시 보낸다. - 한도 값은 앱마다 다를 수 있다. 실효값은 WebSocket
hello의limits에 있다(실시간 프로토콜). - 크기 한도: 메시지 본문 4000자, 첨부 10개,
entities100개,appMeta4 KB, 요청 본문 1 MB.
페이지네이션
| 방식 | 쓰는 곳 | 규칙 |
|---|---|---|
| cursor | 방 목록, 멤버, 내 방, 유저, 읽음, 리액션, 이벤트 | ?cursor=&limit=. limit 기본 50, 최대 100. 응답의 cursor를 그대로 다음 요청에 넣는다. 빈 문자열("")이 마지막 페이지다. 커서는 불투명 문자열이니 해석하지 않는다. 해석되지 않는 커서는 400이다 |
| seq 창 | 메시지 히스토리 | ?before=&after=&limit=. 아래 「히스토리」 |
| 없음 | GET /v1/me/unread, GET …/presence |
상한에서 자르고 roomsCapped·capped로 알린다 |
cursor 방식에서 cursor가 있으면 다음 페이지가 반드시 있다. 페이지 사이에 새 항목이 생겨도 이미 지나간 페이지는 밀리지 않는다.
브라우저에서 부를 때 (CORS)
- 모든
/v1응답에 CORS 헤더가 붙는다(에러 응답에도).Access-Control-Allow-Origin은 요청의Origin을 그대로 돌려준다. - 프리플라이트(
OPTIONS)는 204다. 허용 메서드는GET, POST, PUT, PATCH, DELETE, OPTIONS, 허용 헤더는Authorization, Content-Type, X-Chat-Key다. Retry-After는 브라우저에서 읽을 수 있게 노출된다.- WebSocket은 CORS 대상이 아니다. 네이티브 앱과 서버는 CORS와 무관하다.
라우트 한눈에
권한 칸의 sk는 서버 키, 유저는 유저 토큰이다. 괄호는 추가 조건이다.
| # | 메서드·경로 | 권한 | 하는 일 |
|---|---|---|---|
| 1 | PUT /v1/rooms |
sk, 유저(앱 설정) | 방 get-or-create |
| 2 | PUT /v1/rooms/{key} |
sk, 유저(앱 설정) | 1번의 별칭(경로에 key) |
| 3 | GET /v1/rooms |
sk, 유저 | 방 목록 |
| 4 | GET /v1/rooms/by-key/{key} |
sk, 유저 | 별칭으로 방 조회 |
| 5 | GET /v1/rooms/{roomId} |
sk, 유저 | 방 조회 |
| 6 | PATCH /v1/rooms/{roomId} |
sk, 유저(owner·admin) | 방 수정 |
| 7 | DELETE /v1/rooms/{roomId} |
sk, 유저(owner) | 방 삭제, ?purge=true는 영구 삭제 |
| 8 | POST /v1/rooms/{roomId}/join |
유저 | 공개 방 참여 |
| 9 | GET /v1/rooms/{roomId}/members |
sk, 유저 | 멤버 목록 |
| 10 | PUT /v1/rooms/{roomId}/members/{userId} |
sk, 유저(owner·admin) | 멤버 추가·역할 변경 |
| 11 | DELETE /v1/rooms/{roomId}/members/me |
유저 | 스스로 나가기 |
| 12 | DELETE /v1/rooms/{roomId}/members/{userId} |
sk, 유저(owner·admin) | 멤버 내보내기 |
| 13 | GET /v1/rooms/{roomId}/messages |
sk, 유저(q는 sk만) |
히스토리·검색 |
| 14 | POST /v1/rooms/{roomId}/messages |
sk, 유저 | 메시지 발행 |
| 15 | PATCH /v1/rooms/{roomId}/messages/{messageId} |
sk, 유저(본인) | 메시지 수정 |
| 16 | DELETE /v1/rooms/{roomId}/messages/{messageId} |
sk, 유저(본인·owner·admin) | 메시지 삭제 |
| 17 | GET /v1/rooms/{roomId}/messages/{messageId}/reactions |
sk, 유저 | 리액션 누른 사람 |
| 18 | GET /v1/rooms/{roomId}/reads |
sk, 유저 | 읽음 위치 목록 |
| 19 | GET /v1/rooms/{roomId}/presence |
sk, 유저 | 지금 방에 있는 사람 |
| 20 | POST /v1/rooms/{roomId}/custom |
sk | 저장되지 않는 제어 신호 |
| 21 | PATCH /v1/me |
유저 | 내 meta 수정 |
| 22 | GET /v1/me/rooms |
유저 | 내 방 목록(안 읽은 수 포함) |
| 23 | GET /v1/me/unread |
유저 | 안 읽은 수 총계 |
| 24 | GET /v1/users |
sk | 유저 목록 |
| 25 | PUT /v1/users/{userId} |
sk | 유저 프로필 수정 |
| 26 | DELETE /v1/users/{userId} |
sk | 탈퇴(익명화), ?purge=true는 영구 삭제 |
| 27 | DELETE /v1/users/{userId}/messages |
sk | 그 유저의 메시지 전부 거두기 |
| 28 | PUT /v1/users/{userId}/ban |
sk | 밴 |
| 29 | DELETE /v1/users/{userId}/ban |
sk | 밴 해제 |
| 30 | POST /v1/users/{userId}/disconnect |
sk | 연결 강제 종료 |
| 31 | POST /v1/users/{userId}/revoke-tokens |
sk | 발급된 토큰 전부 무효화 |
| 32 | GET /v1/events |
sk | 놓친 이벤트 따라잡기 |
| 33 | GET /v1/export |
sk | 앱 전체 데이터 내려받기 |
(9·13·18·19번은 GET /v1/rooms/{roomId}/{members|messages|reads|presence} 네 경로다. 이 넷 말고 다른 이름은 404다.)
리액션 누르기, 읽음 위치 쓰기, 입력 중 알림은 REST 라우트가 없다. 셋 다 WebSocket 프레임(react·read·typing)으로만 한다. 읽는 쪽(17·18번, 안 읽은 수)만 REST다.
객체
방
{
"id": "0AAS4P1MM0000", "appId": "0AAS4P1HW0000", "key": "general", "type": "public",
"name": "일반", "meta": {}, "lastSeq": 12, "minSeq": 1,
"createdAt": 1792861844890, "updatedAt": 1792861844949
}type은public,private,channel,dm이다.key는dm에 없다.name은 설정했을 때만 온다.lastSeq는 방에 마지막으로 발행된 메시지의 seq,minSeq는 히스토리로 읽을 수 있는 가장 오래된 seq다(그보다 한참 오래된after는 410이다).- 삭제된 방은
deletedAt이 찍히지만 조회 라우트는 지워진 방을 404로 돌려준다.
메시지
{
"id": "0AAS4P1P40800", "seq": 12, "kind": "text",
"sender": { "id": "u-1", "name": "alice", "avatar": "https://example.com/a.png" },
"body": { "text": "안녕 @bob", "entities": [{ "type": "mention", "userId": "u-2", "offset": 3, "length": 4 }] },
"replyTo": "0AAS4P1P40000", "threadId": "0AAS4P1P40000",
"mentions": ["u-2"], "meta": {}, "appMeta": {},
"reactions": [{ "emoji": "👍", "count": 2 }],
"thread": { "count": 3, "lastSeq": 20, "lastAt": 1792861900000 },
"myReactions": ["👍"],
"createdAt": 1792861844902, "editedAt": 1792861850000
}| 필드 | 설명 |
|---|---|
id, seq |
seq는 방 안에서 1씩 늘고 구멍이 없는 번호다. 스레드 답글도 seq를 쓴다 |
kind |
text 또는 system(서버 키로만 보낼 수 있다) |
sender |
{id, name, avatar?}. id는 서비스의 유저 id다 |
body |
{text?, attachments?, entities?}. 첨부는 {type, url, key?, mime?, w?, h?, name?, size?}이고 type은 image·video·audio·file, url은 https만 된다. 엔진은 파일을 저장하지 않는다 |
replyTo |
인용한 메시지 id |
threadId |
스레드 답글이면 루트 메시지 id. 본 타임라인 메시지에는 없다 |
mentions |
멘션된 유저 id(서비스의 id) 배열 |
meta |
보낸 사람이 붙인 JSON 객체 |
appMeta |
서버 키로만 붙일 수 있는 JSON 객체(4 KB 이하) |
reactions |
[{emoji, count}] 집계 |
thread |
답글이 있는 루트에만 {count, lastSeq, lastAt} |
myReactions |
유저 토큰으로 읽을 때만 온다. 내가 누른 이모지. 빈 배열은 "아무것도 안 눌렀다", 필드가 없으면 "보는 유저가 없다"(서버 키) |
editedAt, deletedAt |
있으면 수정됨/삭제됨 |
- 삭제된 메시지(tombstone)는 행이 남는다.
deletedAt이 있고body는 비고reactions·myReactions는 빈 배열이다.seq는 그대로다. - 이 객체는 WebSocket의
message.created·message.updated와 같은 모양이다. 차이는 채워지는 필드뿐이다. - 메시지 객체에
roomId는 없다. 어느 방인지는 요청 경로로 안다.
유저 (내 정보)
PATCH /v1/me와 PUT /v1/users/{userId}의 응답이다.
{ "id": "u-1", "name": "alice", "avatar": "https://example.com/a.png", "meta": { "plan": "pro" } }GET /v1/users의 항목은 더 많다: externalId, name, avatarUrl?, meta, createdAt, updatedAt, lastSeenAt?, deletedAt?(탈퇴한 유저), bannedUntil?(살아 있는 밴만), isBot, lastUserAgent.
방
1 · 2. PUT /v1/rooms, PUT /v1/rooms/{key} — 방 get-or-create
요청 본문: {key?, type?, name?, meta?, members?}
| 필드 | 설명 |
|---|---|
type |
public(기본), private, channel, dm |
key |
public·private·channel은 필수. ^[A-Za-z0-9_.:-]{1,128}$. dm에 주면 400 |
members |
dm에서 필수. 서비스의 유저 id 정확히 둘. 둘 다 한 번은 접속한 유저여야 한다(없으면 404) |
name, meta |
새로 만들 때만 쓰인다 |
- 새로 만들면 201, 이미 있으면 200이고 둘 다 방 객체다. 이미 있는 방의
name·meta는 바뀌지 않는다(바꾸려면 6번). public·private·channel은key로,dm은 두 사람의 조합으로 같은 방을 찾는다.PUT /v1/rooms/{key}는key를 경로에 넣는 같은 호출이다. 본문의key와 경로의key가 다르면 400이다. 새 코드는PUT /v1/rooms를 쓴다.- 유저 토큰은 앱이 클라이언트의 방 생성을 켜 둔 경우에만 만들 수 있다(꺼져 있으면 403).
dm에는 호출자가 들어 있어야 한다. 유저 토큰으로 만든 방은 만든 유저가 owner가 된다. - 서버 키로 만든
channel에는 owner가 없다. 글을 쓸 사람을 10번으로 owner나 admin으로 지정한다.
curl -X PUT https://api.chat.gravex.app/v1/rooms \
-H "Authorization: Bearer $CHAT_SECRET_KEY" -H "Content-Type: application/json" \
-d '{"key":"lobby","type":"public","name":"로비"}'3. GET /v1/rooms — 방 목록
쿼리: type(쉼표로 여럿), cursor, limit.
type을 생략하면public·channel이다. 유저 토큰은public·channel만 받는다(공개 방 탐색). 서버 키는private·dm도 받는다. 그 밖의 값은 400이다.- 새 방부터 준다. 응답:
{"rooms": [방…], "cursor": ""}
4 · 5. GET /v1/rooms/by-key/{key}, GET /v1/rooms/{roomId} — 방 조회
방 객체를 돌려준다. 방을 가리키는 정본은 id다. key는 바꿀 수 있는 별칭이라 by-key만 별칭으로 찾는다. 유저 토큰은 그 방을 읽을 수 있어야 한다(아래 「접근 규칙」).
6. PATCH /v1/rooms/{roomId} — 방 수정
요청 본문: {key?, name?, meta?}. 주지 않은 필드는 건드리지 않는다. 응답은 방 객체이고 방에는 room.updated가 나간다.
- 유저 토큰은 방의 owner 또는 admin이어야 한다.
- 이미 쓰이는
key는 409다.dm에key를 주면 400이다.
7. DELETE /v1/rooms/{roomId} — 방 삭제
- 기본은 소프트 삭제다. 204를 돌려주고 방에는
room.deleted가 나간다. 메시지는 남고, 방은 조회·참여·발행 모두 404가 되며key가 비워져 같은key로 다시 만들 수 있다. 이미 지워진 방은 404다. 유저 토큰은 owner만 된다. ?purge=true는 영구 삭제를 접수한다. 서버 키만 되고 유저 토큰은 403이다(방 주인도 안 된다). 살아 있는 방이면 먼저 소프트 삭제한다. 응답은 202{"job": {…}}이고, 같은 방에 진행 중인 작업이 있으면 같은 작업으로 202다. 되돌릴 수 없다.purge가true·false가 아니면 400이다.
멤버
방 종류별로 읽고 쓰는 규칙은 아래 「접근 규칙」이다. 멤버십은 "내 방 목록에 들어가고 알림을 받는다"는 뜻이다.
8. POST /v1/rooms/{roomId}/join — 공개 방 참여
유저 토큰 전용이다. public·channel 방의 멤버가 된다. 이미 멤버여도 200이고 {"roomId": "…", "role": "member"}를 돌려준다. private·dm은 403이다. 새로 들어갔을 때만 방에 member.joined가 나간다.
9. GET /v1/rooms/{roomId}/members — 멤버 목록
쿼리: cursor, limit. 응답:
{ "members": [{ "userId": "u-1", "name": "alice", "role": "owner", "joinedAt": 1792861844892 }], "cursor": "" }탈퇴한 유저의 행은 {"role": "member", "joinedAt": …, "withdrawn": true}로 남는다(userId 없음, name은 익명 라벨).
10. PUT /v1/rooms/{roomId}/members/{userId} — 멤버 추가·역할 변경
요청 본문: {role?} — owner, admin, member(기본). 없으면 넣고 있으면 역할을 바꾼다. 204. 그 유저는 한 번은 접속한 적이 있어야 한다(없으면 404).
- 유저 토큰은 owner 또는 admin이어야 한다. owner의 역할은 owner만 바꾼다. 방의 마지막 owner는 강등할 수 없다(400).
- 실제로 바뀌었을 때만
member.joined가 나간다.
11 · 12. DELETE /v1/rooms/{roomId}/members/me, …/members/{userId} — 나가기·내보내기
둘 다 204다. 실제로 지워졌을 때만 member.left가 나간다.
members/me는 유저 토큰 전용이고 호출자 자신이 나간다. 서버 키로는 403이다.members/{userId}는 서버 키 또는 owner·admin이다.- 서비스의 유저 id를
me로 만들지 않는다.members/me가 항상 이긴다.
메시지
13. GET /v1/rooms/{roomId}/messages — 히스토리·검색
쿼리: before, after, limit(기본·최대 100), view, q. 응답: {"messages": [메시지…]} — 항상 seq 오름차순이다.
| 호출 | 결과 |
|---|---|
| 아무것도 없이 | 가장 최근 limit개 |
before=N |
seq가 N보다 작은 것 중 가장 최근 limit개(위로 스크롤) |
after=N |
seq가 N보다 큰 것 중 가장 오래된 limit개(구멍 메우기) |
after=N&before=M |
N과 M 사이 |
view: 생략하면 스레드 답글까지 방 전체(all),main은 본 타임라인만,thread:<루트 메시지 id>는 그 스레드만이다. 그 밖의 값은 400이다.main·thread:는 보여 주기용이다. 동기화 위치로 쓰지 않는다.q(서버 키 전용)는body.text부분 일치 검색이다. 대소문자를 가리지 않고 삭제된 메시지는 걸리지 않는다. 유저 토큰이q를 주면 403, 공백뿐이면 400이다. 검색 결과의seq는 이어지지 않는다(걸리지 않은 메시지가 빠진 것이다).after가 방의 보존 하한보다 오래됐으면 **410cursor_too_old**다. 이어 붙일 수 없으니 최근 히스토리부터 다시 시작한다.- 한 페이지의 필드(
sender이름,reactions,thread,myReactions)는 서버가 한꺼번에 채워서 준다.
14. POST /v1/rooms/{roomId}/messages — 발행
요청 본문:
| 필드 | 필수 | 설명 |
|---|---|---|
clientMessageId |
예 | 중복 방지 키. 없으면 400. 아래 설명 |
body |
예 | {text?, attachments?, entities?}. text 4000자, 첨부 10개, entities 100개 이하 |
kind |
text(기본). system은 서버 키만 |
|
meta |
JSON 객체 | |
replyTo, threadId |
같은 방의 메시지 id. 아래 설명 | |
sender |
서버 키는 예 | {id, name}. 서버 키에는 돌려줄 유저가 없어 sender.id가 없거나 비면 400. 유저 토큰이 주면 403 |
appMeta |
서버 키만(4 KB 이하 JSON 객체). 유저 토큰이 주면 403 |
응답은 201 {"messageId": "…", "seq": 13, "createdAt": 1792861844902}다.
clientMessageId가 멱등키다. 같은 보낸 사람이 같은 값으로 다시 보내면(24시간 안) 새 메시지를 만들지 않고 처음 저장된 메시지의messageId·seq를 돌려준다. 응답을 못 받았으면 같은 값으로 다시 보낸다. 새 값을 쓰면 메시지가 둘이 된다. UUIDv4를 권장한다.threadId는 스레드 답글이라는 뜻이다. 답글의 id를 주면 그 루트로 바꿔 저장한다.replyTo는 인용이고 스레드를 열지 않는다. 인용 대상이 스레드 안에 있으면 그 스레드의 답글이 된다.entities의{type:"mention", userId, offset, length}는userId가 실존할 때만 멘션으로 센다. 모르는 id는 거절이 아니라 제외다.- 앱에
before_publish훅이 있으면 유저 토큰의 발행은 훅을 거치고 403moderation_denied가 올 수 있다. 서버 키는 거치지 않는다. 훅이 본문을 바꾸면 바뀐 본문이 저장된다. - 밴된 유저는 403
banned, 저장 쿼터가 차면 403quota_exceeded다.
curl -X POST https://api.chat.gravex.app/v1/rooms/$ROOM_ID/messages \
-H "Authorization: Bearer $USER_TOKEN" -H "X-Chat-Key: $VITE_CHAT_PK" \
-H "Content-Type: application/json" \
-d '{"clientMessageId":"cm-6f1c","body":{"text":"안녕"}}'15. PATCH /v1/rooms/{roomId}/messages/{messageId} — 수정
요청 본문: {body} — 필수이고 본문 전체를 통째로 바꾼다. 없으면 400이다. 응답은 200과 메시지 객체이고 방에는 message.updated가 나간다. editedAt이 찍히고 seq는 움직이지 않는다.
- 유저 토큰은 본인 메시지만 고친다. 방 owner·admin도 남의 메시지는 못 고친다(지우기만 한다). 앱의 수정 가능 시간(
editWindowMs, 기본은 제한 없음) 안에서만 된다. - 서버 키는 누구 메시지든 고친다.
- 이미 삭제된 메시지는 404다. 유저당 초당 2건 한도가 있다(서버 키는 제외).
16. DELETE /v1/rooms/{roomId}/messages/{messageId} — 삭제
204다. 행을 지우지 않고 본문을 비우는 tombstone이다(seq 그대로). 방에는 message.deleted가 나간다. 두 번 지워도 204다. 유저 토큰은 본인 메시지와, owner·admin이 남의 메시지를 지우는 경우만 된다. 다른 방의 메시지 id는 404다.
17. GET /v1/rooms/{roomId}/messages/{messageId}/reactions — 리액션 누른 사람
쿼리: emoji(그 이모지만), cursor, limit. 응답: {"reactions": [{"userId": "u-1", "emoji": "👍"}], "cursor": ""}.
한 행은 (유저, 이모지) 쌍이라 한 사람이 여러 줄일 수 있다. 탈퇴한 유저는 userId 없이 withdrawn: true로 남는다. 삭제된 메시지는 404가 아니라 빈 페이지다.
18. GET /v1/rooms/{roomId}/reads — 읽음 위치
쿼리: userId(그 유저의 행만), cursor, limit. 응답: {"reads": [{"userId": "u-1", "seq": 12, "threadId": "…"}], "cursor": ""}.
한 행은 (유저, 범위) 쌍이다. 본 타임라인은 threadId가 없고 스레드마다 한 줄이 따로 있다. 탈퇴한 유저는 userId 없이 withdrawn: true다. 없는 유저의 userId는 404가 아니라 빈 페이지다. 내 읽음 위치를 되찾으려면 userId에 내 id를 넣는다.
19. GET /v1/rooms/{roomId}/presence — 지금 방에 있는 사람
쿼리: full=true. 기본은 {"count": 3}이고 full=true면 {"count": 3, "users": [{"id": "u-1", "name": "alice", "avatar": "…"}]}다. users는 1000명에서 잘리고 잘렸으면 capped: true다. 빈 방은 404가 아니라 {"count": 0}이다.
20. POST /v1/rooms/{roomId}/custom — 저장되지 않는 제어 신호
서버 키 전용이다(유저 토큰은 403). 요청 본문: {"payload": {…}} — JSON 객체이고 16 KiB 이하다(아니면 400). 방을 보고 있는 연결에 custom 이벤트로 payload를 그대로 뿌리고 204를 돌려준다.
- 저장되지 않는다. 웹훅·
GET /v1/events로도 나가지 않고, 놓친 연결에 다시 보내지도 않는다. 204는 "뿌렸다"이지 "받았다"가 아니다. 저장해야 하는 신호는kind:"system"메시지로 보낸다.
내 정보 (유저 토큰 전용)
서버 키로 부르면 403이다.
21. PATCH /v1/me — 내 meta 수정
요청 본문: {meta}. meta는 최상위 키만 얕게 병합하고, 값이 null인 키는 지운다. 중첩 객체는 합쳐지지 않고 통째로 바뀐다. 응답은 유저 객체({id, name, avatar, meta})다.
name·avatar는 400이다. 이름과 아바타는 백엔드가 서명하는 토큰에서 오거나 25번으로 바꾼다.- 최상위
meta.provider는 백엔드가 보증하는 칸이라 400이다. - 병합한 뒤의
meta가 4 KB 또는 최상위 키 64개를 넘으면 400이고 아무것도 쓰이지 않는다.
22. GET /v1/me/rooms — 내 방 목록
쿼리: cursor, limit. 마지막 활동 순이다(메시지가 없는 방은 맨 아래). 응답:
{
"rooms": [{
"room": { "id": "0AAS4P1MM0000", "type": "public", "key": "general", "lastSeq": 12, "minSeq": 1, "meta": {}, "createdAt": 1, "updatedAt": 2 },
"lastSeq": 12, "readSeq": 10, "unread": 2, "unreadMentions": 1,
"lastMessage": { "id": "0AAS4P1P40800", "seq": 12, "sender": { "id": "u-1", "name": "alice" }, "kind": "text", "body": { "text": "안녕" }, "createdAt": 1792861844902 }
}],
"cursor": ""
}unread는lastSeq - readSeq가 아니라 서버가 센 값이다(본 타임라인 메시지만, 내 메시지·삭제된 메시지 제외). 1000에서 자르고 잘렸으면unreadCapped: true다.readSeq는 본 타임라인의 읽음 위치다.lastMessage는 메시지가 있을 때만 온다.
23. GET /v1/me/unread — 안 읽은 수 총계
응답: {"rooms": [{"roomId": "…", "unread": 2, "unreadMentions": 1}], "unread": 5, "unreadMentions": 1, "roomsCapped": false}.
내가 멤버인 방별 숫자와 그 합이다. 방이 500개를 넘으면 500개까지만 세고 roomsCapped: true다(총계는 일부의 합이다). WebSocket hello.unread와 같은 계산이다.
유저 관리 (서버 키 전용)
유저는 한 번이라도 접속한 뒤에 생긴다. 접속한 적 없는 유저 id는 404다. 유저 토큰으로 부르면 모두 403이다.
24. GET /v1/users — 유저 목록
쿼리: cursor, limit과 선택 필터. 유저 id 역순(새로 생긴 유저부터)이고 탈퇴한 유저도 나온다. 해석되지 않는 값은 400이다. 필터를 바꾸면 cursor를 버리고 처음부터 받는다.
| 파라미터 | 값 |
|---|---|
q |
externalId 접두어 또는 name 부분 일치. 대소문자 무시 |
status |
live, withdrawn, all(기본) |
last_seen_after, last_seen_before |
RFC3339. 마지막 접속 시각 범위(after 포함, before 제외) |
created_after, created_before |
RFC3339. 가입 시각 범위 |
bots |
include(기본), exclude, only |
응답: {"users": [유저…], "cursor": ""}
25. PUT /v1/users/{userId} — 프로필 수정
요청 본문: {name?, avatar?, meta?}. 응답은 유저 객체다.
name은 256바이트,avatar는 2048바이트까지다(넘으면 400이고 아무것도 쓰이지 않는다).meta는 21번과 같은 얕은 병합이고 상한도 같다.- 탈퇴한 유저는 404다. 접속 중이면 그 연결의 이름이 바로 바뀐다.
- 다음에 접속할 때 토큰의
name·avatar가 이 값을 다시 덮는다.
26. DELETE /v1/users/{userId} — 탈퇴·영구 삭제
| 쿼리 | 결과 |
|---|---|
| 없음 | 탈퇴 = 익명화. 이름은 "탈퇴한 사용자", 아바타·meta는 비워진다. 유저 행과 id는 남고 메시지도 그대로다. 그 유저의 연결을 끊는다 |
purge_messages=true |
탈퇴에 더해 그 유저의 메시지를 모두 tombstone으로 만들고 메시지에 찍힌 이름도 지운다 |
purge=true |
탈퇴한 유저의 행을 영구 삭제하는 작업을 접수한다(되돌릴 수 없다) |
- 탈퇴와
purge_messages의 응답은 200{"userId": "…", "anonymized": true, "purgedMessages": 40, "scrubbedSenders": 2, "purgeCapped": false}다.anonymized는 "이번 호출이 익명화했는가"다. purgeCapped: true면 한 번에 다 못 지운 것이다.false가 될 때까지 같은 호출을 되풀이한다.- 이미 탈퇴한 유저는 404다. 다만
purge_messages=true는 이미 탈퇴한 유저에게도 받는다(메시지 거두기를 이어 한다). purge=true는 202{"job": {…}}이다. 탈퇴하지 않은 유저는 409다. 같은 유저에 진행 중인 작업이 있으면 같은 작업으로 202다. 그 뒤에는 그 유저 id를 다시 쓸 수 있게 되지만, 옛 메시지의sender.id가 같은 문자열을 들고 있으므로 새 사람에게 옛 id를 재사용하지 않는다.- 두 쿼리의 값이
true·false가 아니면 400이다.
27. DELETE /v1/users/{userId}/messages — 메시지 거두기
그 유저가 보낸 살아 있는 메시지를 모두 tombstone으로 바꾼다. 탈퇴시키지 않는다. 응답은 200 {"userId": "…", "purgedMessages": 40, "purgeCapped": false}다. purgeCapped: true면 다시 부른다(이미 지운 것은 건너뛰어 멱등이고, 끝나면 purgedMessages: 0이다). 메시지마다 message.deleted가 나간다. 보낸 사람 이름은 남는다. 새 메시지가 계속 올라오지 않게 밴을 먼저 한다. 없는 유저와 탈퇴한 유저는 404다.
28 · 29. PUT·DELETE /v1/users/{userId}/ban — 밴·해제
- 밴 요청 본문:
{until, reason?}.until은 필수(UTC 에포크 밀리초)이고 없으면 400이다. 무기한은 먼 미래 시각을 준다. 204. - 밴된 유저의 발행·리액션은
banned로 거절된다. 읽기는 막지 않는다. - 해제는 204다. 밴이 없어도 204다.
30. POST /v1/users/{userId}/disconnect — 연결 강제 종료
요청 본문(선택): {connectionId?, reason?}. connectionId가 있으면 그 연결 하나만, 없으면 그 유저의 연결 전부를 끊는다(connectionId는 hello에서 받는다. 형식이 틀리면 400). reason은 WebSocket close 사유로 나간다(기본 disconnected). 204는 "지시했다"이지 "몇 개 끊었다"가 아니다. 놓친 연결이 있으면 다시 부른다(멱등). 없는 유저는 404다. 탈퇴한 유저에게도 동작한다.
31. POST /v1/users/{userId}/revoke-tokens — 토큰 무효화
그 유저의 지금까지 발급된 토큰을 전부 무효화하고 연결도 끊는다(close 사유 tokens_revoked). 204다. 무효화 시각은 초 단위로 내림하므로 1초 뒤에 발급한 토큰부터 확실히 통한다. 탈퇴한 유저는 404다.
이벤트와 내보내기 (서버 키 전용)
32. GET /v1/events — 놓친 이벤트 따라잡기
쿼리: after(이벤트 id), limit(기본 50, 최대 100). 응답:
{ "events": [{ "delivery": "0AAS4P1QQ0000", "event": "message.created", "createdAt": 1792861844902, "data": { } }], "cursor": "" }delivery·event·data는 웹훅이 보내는 헤더·본문과 같다. 웹훅 수신과 서명은 웹훅에 있다.after를 해석할 수 없으면 400이다.cursor는 페이지가 꽉 찼을 때만 온다.- 따라잡기가 끝났다고 판단하기 전에 마지막 페이지를 한 번 더 읽거나 조금 앞선
after로 다시 읽는다. 중복은delivery로 거른다. - 앱이 구독하지 않은 이벤트는 여기에도 없다.
33. GET /v1/export — 앱 전체 내려받기
쿼리가 없다. 유저·방·멤버·메시지·리액션·읽음 위치를 NDJSON 한 파일로 스트리밍한다(Content-Type: application/x-ndjson). 첨부 파일 자체는 없다.
curl -sS -H "Authorization: Bearer $CHAT_SECRET_KEY" https://api.chat.gravex.app/v1/export -o export.ndjson
tail -n 1 export.ndjson # {"type":"end",…} 이어야 완전한 파일이다- 한 줄이 JSON 하나이고 줄마다
type이 있다. 순서:export→user(전부) → 방마다room,member,message(seq 오름차순),reaction,read→end(counts포함). - 마지막 줄이
end가 아니면 불완전한 파일이다. 상태 코드는 이미 200이라 중간 실패를 알릴 수 없다. 처음부터 다시 받는다(이어받기 없음). - 앱당 동시에 하나다. 이미 돌고 있으면 409, 서버가 바쁘면 429(
Retry-After)다. - 스냅숏이 아니다. 받는 동안의 쓰기는 들어갈 수도 안 들어갈 수도 있다.
접근 규칙
유저 토큰 호출은 WebSocket과 같은 규칙을 받는다. 서버 키는 이 검사를 건너뛴다.
| 방 종류 | 읽기 | 쓰기 | 목록 탐색 |
|---|---|---|---|
public |
누구나 | 누구나 | 나온다 |
channel |
누구나 | 멤버 중 owner·admin만 | 나온다 |
private |
멤버만 | 멤버만 | 안 나온다 |
dm |
두 사람만 | 두 사람만 | 안 나온다 |
public·channel은 멤버가 아니어도 읽을 수 있지만, 8번으로 멤버가 되어야 내 방 목록과 알림에 들어간다.- 읽을 수 없는 방은 403
forbidden이다. 다른 앱의 방 id나 지워진 방은 404다.
| 역할 | 읽기 | 쓰기 | 멤버 관리 | 방 수정 | 방 삭제 |
|---|---|---|---|---|---|
owner |
O | O | O | O | O |
admin |
O | O | O | O | X |
member |
O | 방 종류에 따름 | X | X | X |
앱이 쓰는 라우트
모바일·프론트 앱은 백엔드에서 유저 토큰을 받아 유저 토큰으로만 부른다. 보통 쓰는 라우트는 35번, 89번, 11번, 1319번, 2123번이다. 나머지(방 만들기, 멤버 관리, 유저 관리, 이벤트, 내보내기)는 백엔드가 서버 키로 한다. 실시간 연결과 재접속, 구멍 메우기는 실시간 프로토콜을 따른다.