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, 인증은 됐지만 그 일을 못 하는 호출자는 403 forbidden이다.
  • 다른 앱의 방 id는 403이 아니라 404다.
  • 토큰 서명이 틀렸든, 만료됐든, 무효화됐든 이유를 가리지 않고 401 하나다. 원인은 응답이 아니라 토큰 규격과 대조해서 찾는다.
  • 유저 토큰이 만료되면 401이 온다. 백엔드에서 새 토큰을 받아 한 번만 다시 시도한다. 두 번째 401은 만료가 아니다.

공통 규칙

에러 형식

실패는 HTTP 상태 코드와 함께 이 모양의 JSON이다.

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개, entities 100개, appMeta 4 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다.

객체

방

json
{
  "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로 돌려준다.

메시지

json
{
  "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}의 응답이다.

json
{ "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으로 지정한다.
bash
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. 응답:

json
{ "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가 방의 보존 하한보다 오래됐으면 **410 cursor_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 훅이 있으면 유저 토큰의 발행은 훅을 거치고 403 moderation_denied가 올 수 있다. 서버 키는 거치지 않는다. 훅이 본문을 바꾸면 바뀐 본문이 저장된다.
  • 밴된 유저는 403 banned, 저장 쿼터가 차면 403 quota_exceeded다.
bash
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. 마지막 활동 순이다(메시지가 없는 방은 맨 아래). 응답:

json
{
  "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). 응답:

json
{ "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). 첨부 파일 자체는 없다.

bash
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번이다. 나머지(방 만들기, 멤버 관리, 유저 관리, 이벤트, 내보내기)는 백엔드가 서버 키로 한다. 실시간 연결과 재접속, 구멍 메우기는 실시간 프로토콜을 따른다.