콘솔 API (계정 키)

콘솔 화면에서 손으로 하는 일 — 앱 만들기, 키 발급, 허용 origin·웹훅·한도 설정, 앱 삭제와 복구 — 을 HTTP로 한다. 계정 키(ck_…) 하나면 스크립트와 AI가 가입 뒤의 나머지를 대신할 수 있다.

  • 기준 주소: https://console.chat.gravex.app (모든 경로가 /api/apps로 시작한다. 문서 사이트가 아니라 콘솔 주소다)
  • 본문은 JSON(UTF-8)이고 Content-Type: application/json을 보낸다. 아니면 415 unsupported_media_type이다.
  • 시각은 ISO 8601 문자열이다(2026-10-25T08:30:00.000Z). REST API 레퍼런스의 에포크 밀리초와 다르다.
  • 이 API는 앱과 키를 다룬다. 방·메시지를 만들고 보내는 일은 여기서 받은 sk_로 REST API와 서버 연동이 한다. 콘솔 API로 방을 만들지는 않는다.

계정 키 받기

계정 키는 사람이 한 번 만든다. 가입과 키 발급은 AI가 대신할 수 없다.

  1. 콘솔에서 이메일로 받은 6자리 코드로 가입한다.
  2. 위쪽 메뉴의 API 키 → 새 키 → 이름(예: 노트북 .env)을 적는다.
  3. 키 전체(ck_…)가 한 번만 보인다. 바로 복사해 프로젝트의 .env에 넣는다. 콘솔은 해시만 저장해서 다시 보여 줄 수 없다.
sh
# .env — 값은 사람이 넣는다
CHAT_ACCOUNT_KEY=ck_...

계정당 살아 있는 키는 최대 10개다. 안 쓰는 키는 API 키 화면에서 폐기한다.

보관 원칙

ck_는 내 계정의 전권이다. 이 키로 내 모든 앱의 키를 새로 발급하고, 앱을 지우고, 웹훅을 바꿀 수 있다.

  • 서버나 로컬 .env에만 둔다. 프런트엔드 코드, 브라우저, 모바일 앱에 넣지 않는다.
  • git 저장소에 커밋하지 않는다. .env가 .gitignore에 있는지 먼저 확인한다.
  • AI 대화창에 붙여 넣지 않는다. 대화는 기록으로 남는다. AI에게는 ".env의 CHAT_ACCOUNT_KEY를 읽어서 써"라고만 시킨다.
  • 일이 끝나면 폐기한다. 필요할 때 새로 만들면 된다. 유출됐다고 의심되면 바로 폐기한다. 폐기해도 이미 만든 앱과 그 pk_·sk_, 웹훅 설정은 그대로 동작한다 — 계정 키는 이 API를 부르는 자격일 뿐이다. 폐기한 뒤에는 .env의 CHAT_ACCOUNT_KEY 줄도 지운다.
  • 키 하나가 하는 일은 감사 로그에 키 id와 함께 남는다.

인증

모든 요청에 헤더 하나를 붙인다. 쿠키는 필요 없다.

sh
curl https://console.chat.gravex.app/api/apps \
  -H "Authorization: Bearer $CHAT_ACCOUNT_KEY"
경로 계정 키
/api/apps/** — 이 문서의 모든 라우트 된다
/api/account/keys/** — 계정 키 발급·목록·폐기 안 된다. 콘솔 화면(로그인 세션)에서만. 키로 키를 만들 수 없다
/api/auth/** — 로그인·로그아웃 안 된다
운영자용 경로 안 된다. 운영자 계정의 키도 마찬가지다
  • 키가 없거나 틀렸거나 폐기됐으면 401 unauthorized다. 이유를 가르지 않는다.
  • 키마다 1분에 120요청까지다. 넘으면 429 rate_limited이고 Retry-After(초)만큼 기다렸다 다시 보낸다. 틀린 키로 반복해서 두드리면 그 주소도 429가 된다.
  • 브라우저에서 부르는 용도가 아니다(CORS를 열지 않는다). 서버나 로컬 스크립트에서 부른다.
  • 남의 앱, 없는 앱, 지운 앱의 id는 모두 404 not_found다. 403으로 앱이 있다는 사실을 알려 주지 않는다.

에러 형식

실패는 HTTP 상태 코드와 이 모양의 JSON이다. 분기는 code로 한다.

json
{ "code": "limit_reached", "limit": 10 }
code HTTP 뜻
unauthorized 401 계정 키가 없거나 틀렸거나 폐기됐다
not_found 404 내 앱이 아니거나 없는 앱·키·방·유저다
invalid_request 400 본문·쿼리가 틀렸다. 한도 변경이면 field(와 limit)가 같이 온다
invalid_name 400 앱 이름은 1~100자
invalid_origins 400 origin 모양이 틀렸다. invalid에 틀린 값 목록이 온다
invalid_env 400 env는 production이나 development
limit_above_ceiling 400 이 앱의 한도 상한을 넘겼다. limit에 상한이 온다
limit_below_min 400 최솟값 아래다. limit에 최솟값이 온다
limit_reached 409 계정당 앱 10개, 앱당 종류별 활성 키 5개 상한이다. limit에 상한이 온다
conflict 409 지금 상태와 부딪힌다(예: 이미 도는 영구 삭제)
export_in_progress 409 이 앱의 내보내기가 이미 돌고 있다
unsupported_media_type 415 본문을 application/json으로 보내지 않았다
rate_limited 429 요청이 너무 많다. Retry-After 뒤에 다시
export_busy 429 엔진이 다른 내보내기로 바쁘다. 몇 초 뒤에 다시
engine_error 502 채팅 엔진이 에러를 돌려줬다
engine_unavailable 503 채팅 엔진에 닿지 못했다. 잠시 뒤에 다시
unknown 500 서버 오류

5xx는 지수 백오프로 다시 시도한다. 429와 409는 위 설명대로 상태가 바뀐 뒤에 다시 시도한다.

앱 만들고 키 받기

계정 키만 있으면 아래 순서로 끝난다. AI에게 시킬 때도 같은 순서다.

1. 앱을 만든다

허용 origin에는 개발 중인 주소를 넣는다. env를 생략하면 development다.

sh
curl -X POST https://console.chat.gravex.app/api/apps \
  -H "Authorization: Bearer $CHAT_ACCOUNT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "my-chat", "origins": ["http://localhost:5173"], "env": "development"}'

응답은 201이고 pk_와 sk_ 전체 값이 이 응답에 같이 온다.

json
{
  "app": { "id": "0AAS4P1MM0000", "name": "my-chat", "createdAt": "2026-10-25T08:30:00.000Z",
           "webhook": { "url": null, "beforePublishEnabled": false, "beforePublishOnError": "allow",
                        "stopped": false, "stoppedAt": null, "hasSecret": false },
           "limits": { "effective": {}, "overrides": {}, "defaults": {}, "max": {}, "min": {}, "fixed": [] } },
  "publishableKey": { "id": "0AAS4P1MM0001", "appId": "0AAS4P1MM0000", "kind": "pk", "value": "pk_...",
                      "label": null, "allowedOrigins": ["http://localhost:5173"], "revokedAt": null,
                      "createdAt": "2026-10-25T08:30:00.000Z" },
  "secretKey": { "id": "0AAS4P1MM0002", "appId": "0AAS4P1MM0000", "kind": "sk", "value": "sk_...",
                 "label": null, "allowedOrigins": [], "revokedAt": null,
                 "createdAt": "2026-10-25T08:30:00.000Z" }
}

2. 두 키를 .env에 채운다

  • publishableKey.value(pk_…)는 프런트에 들어가는 공개값이다.
  • secretKey.value(sk_…)는 이 응답에서만 보인다. 콘솔은 저장하지 않는다. 응답을 받은 코드가 곧바로 .env에 쓰게 하고, 화면·로그·대화에 찍지 않는다. 놓쳤으면 아래 「키 발급」으로 새 sk_를 받고 옛 것을 폐기한다.
sh
# .env
VITE_CHAT_PK=pk_...
CHAT_SECRET_KEY=sk_...

프런트가 읽는 것은 VITE_CHAT_PK 하나다(Vite는 VITE_로 시작하는 변수만 브라우저 번들에 넣는다). CHAT_SECRET_KEY와 CHAT_ACCOUNT_KEY는 서버에서만 읽는다. Vite의 envPrefix를 CHAT_로 넓히지 않는다 — sk_와 ck_가 번들에 실린다. Vite가 아닌 프런트라면 pk_에만 그 프레임워크의 공개 접두사를 붙인다(Next.js는 NEXT_PUBLIC_CHAT_PK).

3. 허용 origin과 웹훅을 설정한다

개발 서버 포트가 바뀌는 식으로 origin을 나중에 바꾸려면 PATCH /api/apps/{id}/keys/{keyId}(아래 「키」), 웹훅은 PATCH /api/apps/{id}/webhook(아래 「웹훅」)이다. 웹훅 주소를 처음 넣는 응답에는 서명 시크릿(secret)이 한 번 온다. .env의 CHAT_WEBHOOK_SECRET에 바로 쓴다.

4. 그다음은 레퍼런스

앱과 키가 생겼으면 이 API의 일은 끝이다. 채팅을 붙이는 법은 시작하기와 서버 연동, 방·메시지·유저를 다루는 서버 호출은 REST API 레퍼런스, 실시간 접속은 실시간 프로토콜을 따른다. 방은 백엔드가 sk_로 만든다(PUT /v1/rooms/{key}).

라우트 한눈에

경로의 {id}는 앱 id(app.id), {keyId}는 키 id(id)다. 모든 라우트가 계정 키로 된다.

라우트 하는 일
GET /api/apps 내 앱 목록
POST /api/apps 앱 만들기(pk_·sk_ 함께 발급)
GET /api/apps/{id} 앱 하나와 통계
PATCH /api/apps/{id} 환경 라벨(env) 바꾸기
DELETE /api/apps/{id} 앱 삭제(키 폐기·접속 종료)
POST /api/apps/{id}/restore 삭제한 앱 복구(새 pk_·sk_)
GET /api/apps/{id}/purge 영구 삭제 작업 진행 조회
POST /api/apps/{id}/purge 삭제한 앱의 데이터 영구 삭제
GET /api/apps/{id}/keys 앱의 키 목록
POST /api/apps/{id}/keys pk_·sk_ 새로 발급
PATCH /api/apps/{id}/keys/{keyId} pk_의 허용 origin 교체
DELETE /api/apps/{id}/keys/{keyId} 키 폐기(?purge=true면 폐기된 키 삭제)
PATCH /api/apps/{id}/webhook 웹훅 주소·before_publish 설정, 시크릿 교체
POST /api/apps/{id}/webhook/resume 멈춘 웹훅 배달 재개
PATCH /api/apps/{id}/limits 앱 한도 변경
GET /api/apps/{id}/users 유저 목록
DELETE /api/apps/{id}/users/{userId} 탈퇴한 유저의 영구 삭제(?purge=true 필수)
PUT /api/apps/{id}/users/{userId}/ban 유저 밴
DELETE /api/apps/{id}/users/{userId}/ban 밴 해제
DELETE /api/apps/{id}/users/{userId}/messages 유저가 보낸 메시지 모두 거두기
GET /api/apps/{id}/rooms 방 목록
DELETE /api/apps/{id}/rooms/{roomId} 방 영구 삭제(?purge=true 필수)
GET /api/apps/{id}/rooms/{roomId}/messages 방의 메시지 조회·검색
DELETE /api/apps/{id}/rooms/{roomId}/messages/{messageId} 메시지 하나 삭제
GET /api/apps/{id}/export 앱 전체 데이터 내려받기(NDJSON)

앱

GET /api/apps

json
{ "apps": [ { "appId": "0AAS4P1MM0000", "name": "my-chat", "env": "development", "createdAt": "2026-10-25T08:30:00.000Z" } ] }

삭제한 앱은 빠진다. 최신순이다.

POST /api/apps

필드 필수 설명
name 예 1~100자
origins 아니오 pk_를 쓸 수 있는 페이지의 origin 배열. https://example.com처럼 경로 없이, 스킴·호스트·포트가 정확히. 최대 50개
env 아니오 production 또는 development(기본). 콘솔 목록에 붙는 라벨일 뿐 엔진은 똑같이 동작한다. 운영과 개발은 앱을 따로 만든다

201과 위 「앱 만들고 키 받기」의 응답. 계정당 살아 있는 앱은 10개이고 넘으면 409 limit_reached다.

GET /api/apps/{id}

json
{
  "app": { "appId": "0AAS4P1MM0000", "name": "my-chat", "env": "development", "createdAt": "2026-10-25T08:30:00.000Z" },
  "stats": { "users": 12, "rooms": 3, "messages": 480, "activeUsers7d": 5, "activeUsers30d": 9,
             "bots": 0, "activeBots7d": 0, "activeBots30d": 0, "botsIncluded": false,
             "approximate": [], "webhook": { "pending": 0, "oldestPendingSeconds": null, "stopped": false } }
}

통계는 봇을 뺀 값이 기본이다. 엔진이 못 주는 숫자는 null이고 0으로 읽지 않는다. approximate에 든 필드는 근사치다.

PATCH /api/apps/{id}

본문 { "env": "production" }. 바뀐 app(위 GET의 app과 같은 모양)을 돌려준다. 엔진을 부르지 않는 콘솔 라벨 변경이다.

DELETE /api/apps/{id}

앱을 소프트 삭제한다. 엔진이 키를 모두 폐기하고 접속을 끊고 웹훅을 멈춘다. 데이터는 남는다.

json
{ "appId": "0AAS4P1MM0000", "name": "my-chat", "deletedAt": "2026-10-25T09:00:00.000Z" }

엔진 호출이 중간에 실패해도 다시 보내면 된다.

POST /api/apps/{id}/restore

삭제한 앱에만 된다(살아 있는 앱·남의 앱은 404). 옛 키는 되살아나지 않고 새 pk_·sk_가 응답에 한 번 온다. 응답은 POST /api/apps와 같은 모양이다. 영구 삭제가 이미 시작된 앱은 409다.

GET /api/apps/{id}/purge, POST /api/apps/{id}/purge

삭제한 앱의 데이터(유저·방·메시지)를 영구히 지우는 작업이다. 두 라우트 다 삭제한 앱에만 된다.

  • POST는 작업을 큐에 넣고 202 { "job": PurgeJob }이다. 되돌릴 수 없다.
  • GET은 이 앱의 작업을 새것부터 { "jobs": [PurgeJob] }로 준다.
json
{ "job": { "id": "...", "kind": "app", "targetId": "0AAS4P1MM0000", "state": "pending",
           "requestedAt": "2026-10-25T09:05:00.000Z", "startedAt": null, "finishedAt": null,
           "error": null, "deleted": {}, "attempts": 0 } }

state는 pending → running → done이다. failed는 끝이 아니다. 5분 뒤 다시 집는다.

키

pk_는 목록에서 전체 값이 보이고 sk_는 마스킹된다(sk_…abcd). sk_ 전체는 발급 응답에서만 보인다. 앱마다 종류별로 활성 키 5개까지다.

GET /api/apps/{id}/keys

json
{ "keys": [ { "id": "0AAS4P1MM0001", "appId": "0AAS4P1MM0000", "kind": "pk", "value": "pk_...", "label": null,
              "allowedOrigins": ["http://localhost:5173"], "revokedAt": null, "createdAt": "2026-10-25T08:30:00.000Z" } ] }

POST /api/apps/{id}/keys

본문 { "kind": "pk" | "sk", "origins": [...] }. origins는 pk에만 의미가 있다. 201과 키 하나(위 publishableKey와 같은 모양)이고, sk면 value에 전체 값이 온다. 종류별 상한을 넘으면 409 limit_reached.

PATCH /api/apps/{id}/keys/{keyId}

pk_의 허용 origin을 통째로 바꾼다. 본문 { "origins": ["https://example.com", "http://localhost:5173"] }. 빈 배열도 된다 (어떤 브라우저도 허용하지 않는다). 바뀐 키를 돌려준다. 하나를 더하거나 빼려면 GET /api/apps/{id}/keys로 지금 목록을 읽어 고친 목록 전체를 보낸다 — 보내지 않은 origin은 지워진다.

DELETE /api/apps/{id}/keys/{keyId}

키를 폐기한다. 바로 효력이 있고 되돌릴 수 없다. 폐기된 키를 돌려준다(revokedAt이 채워진다). 같은 종류의 마지막 키를 폐기하기 전에 새 키를 발급해 배포한다.

?purge=true를 붙이면 폐기된 키의 기록을 지운다. 살아 있는 키에는 쓸 수 없고, 응답은 204이며, 한 번 지우면 다시 부를 수 없다(404). purge 값은 true 또는 false만 받는다.

웹훅

PATCH /api/apps/{id}/webhook

언급한 필드만 바꾼다. 세 가지 지시를 한 라우트가 받는다.

본문 효과
{ "url": "https://example.com/chat-webhook" } 받을 주소 설정. https 주소
{ "url": null } 웹훅 끄기
{ "beforePublishEnabled": true, "beforePublishOnError": "deny" } before_publish 켜기와 훅 실패 시 동작(allow 또는 deny)
{ "rotateSecret": true } 서명 시크릿 교체
json
{ "url": "https://example.com/chat-webhook", "beforePublishEnabled": false, "beforePublishOnError": "allow",
  "stopped": false, "stoppedAt": null, "hasSecret": true, "secret": "..." }

secret은 시크릿이 새로 만들어진 이 응답에서만 온다 — 시크릿이 없던 앱에 처음 url을 넣을 때와 rotateSecret일 때. 조회로는 다시 볼 수 없다. 받자마자 .env의 CHAT_WEBHOOK_SECRET에 쓰고 로그에 남기지 않는다. 이벤트와 서명 검증은 웹훅을 본다.

POST /api/apps/{id}/webhook/resume

드레이너가 포기해서 멈춘 배달(stopped: true)을 다시 돌린다. 본문은 없다. 멈추지 않은 앱에 보내도 200이다. 위와 같은 모양의 설정을 돌려준다.

한도

PATCH /api/apps/{id}/limits

본문은 { "필드": 정수 | null }이다. null은 기본값으로 되돌린다. 한 필드라도 틀리면 아무것도 바꾸지 않는다.

sh
curl -X PATCH https://console.chat.gravex.app/api/apps/$APP_ID/limits \
  -H "Authorization: Bearer $CHAT_ACCOUNT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"publishPerSecond": 3}'
json
{ "rows": [ { "field": "publishPerSecond", "group": "rate", "effective": 3, "default": 5, "max": 100, "min": 1,
              "override": 3, "ceiling": 5, "ceilingSet": false, "ownerUpper": 5, "ownerCanReset": true } ] }

응답은 바뀐 필드의 행(rows)이다. group은 그 필드를 누가 바꾸는지를 말한다.

  • 필드 이름과 지금 값은 POST /api/apps의 app.limits에 있다.
  • 속도·크기 계열(group이 rate·size)은 이 앱의 콘솔 상한(ownerUpper) 이하로만 올릴 수 있다. 운영자가 상한을 따로 정하지 않았으면 엔진 기본값이 상한이라, 기본값보다 높이려면 운영자에게 요청해야 한다. 정책 계열(policy)은 자유롭게 바꾼다. 운영 계열(ops)과 고정 필드(fixed), 모르는 필드는 400 invalid_request이고 응답의 field가 그 필드다.
  • 상한을 넘기면 400 limit_above_ceiling, 최솟값 아래면 400 limit_below_min이고 limit에 그 값이 온다.
  • 바뀐 값이 닿는 시점은 필드마다 다르다. 이어진 연결에는 30초 안에, 어떤 필드는 다음 접속부터 닿는다.
  • 실효 한도는 REST API 레퍼런스와 실시간 프로토콜의 한도와 같은 값이다.

조회

유저·방·메시지는 읽기 전용이다. 만들거나 고치려면 sk_로 REST를 부른다.

GET /api/apps/{id}/users

json
{ "items": [ { "externalId": "u_42", "name": "한나", "avatarUrl": null, "lastSeenAt": "2026-10-25T08:00:00.000Z",
               "deletedAt": null, "createdAt": "2026-10-01T00:00:00.000Z", "bannedUntil": null,
               "isBot": false, "lastUserAgent": "Mozilla/5.0 ..." } ],
  "cursor": "abc", "botsSupported": true }

한 페이지 50명이다. cursor가 null이면 마지막 페이지이고, 아니면 그 값을 ?cursor=에 그대로 넣어 다음 페이지를 받는다. 필터는 쿼리로 건다: q(이름 검색), status(live·withdrawn·all), bots(include·exclude·only), last_seen_after·last_seen_before·created_after·created_before(YYYY-MM-DD). 필터를 바꾸면 커서를 버리고 첫 페이지부터 읽는다.

GET /api/apps/{id}/rooms

json
{ "items": [ { "id": "0AAS4P1MM0010", "key": "lobby", "type": "public", "name": "로비", "lastSeq": 128,
               "createdAt": "2026-10-25T08:31:00.000Z" } ], "cursor": null }

public·channel·private·dm 모두 나온다. 페이지는 유저와 같다(50개, ?cursor=).

GET /api/apps/{id}/rooms/{roomId}/messages

json
{ "messages": [ { "id": "0AAS4P1MM0020", "seq": 128, "kind": "text", "sender": { "id": "u_42", "name": "한나" },
                  "text": "안녕", "attachmentCount": 0, "threadId": null,
                  "createdAt": "2026-10-25T08:40:00.000Z", "editedAt": null, "deletedAt": null } ],
  "hasMore": false }

기본은 가장 최근 50건이다. ?before=<seq>는 그보다 오래된 한 페이지, ?after=<seq>는 그보다 새 메시지를 오래된 것부터다. ?q=검색어는 그 방 안의 본문 검색이고 결과의 seq는 이어지지 않는다. after는 before·q와 함께 쓸 수 없다(400).

GET /api/apps/{id}/export

앱의 유저·방·멤버·메시지·리액션·읽음 위치·첨부 메타데이터를 NDJSON 한 파일로 내려받는다(첨부 파일 자체는 없다). 스냅숏이 아니고 이어받기는 없다. 마지막 줄이 {"type":"end", …}가 아니면 불완전한 파일이다. 앱당 하나만 동시에 돌 수 있다 (409 export_in_progress). 형식은 REST API 레퍼런스의 GET /v1/export와 같다.

sh
curl https://console.chat.gravex.app/api/apps/$APP_ID/export \
  -H "Authorization: Bearer $CHAT_ACCOUNT_KEY" -o export.ndjson

모더레이션과 영구 삭제

PUT /api/apps/{id}/users/{userId}/ban, DELETE /api/apps/{id}/users/{userId}/ban

userId는 서비스가 정한 유저 id(externalId)다. 밴 본문은 { "until": 1793000000000, "reason": "스팸" }이다. until은 필수이고 UTC 에포크 밀리초다(무기한이면 아주 먼 미래). reason은 선택이다. 둘 다 204이고, 밴 해제는 밴이 없어도 204다.

DELETE /api/apps/{id}/users/{userId}/messages

그 유저가 보낸 살아 있는 메시지를 모두 지운 표시(tombstone)로 바꾼다. 탈퇴시키지 않는다.

json
{ "purgedMessages": 40, "purgeCapped": true }

한 번에 거두는 양에 상한이 있다. purgeCapped가 true면 남았다는 뜻이니 false가 될 때까지 다시 부른다.

DELETE /api/apps/{id}/rooms/{roomId}/messages/{messageId}

메시지 하나를 지운 표시로 바꾼다. 204이고 멱등이다. 행은 남고 seq는 움직이지 않는다.

DELETE /api/apps/{id}/users/{userId}?purge=true, DELETE /api/apps/{id}/rooms/{roomId}?purge=true

탈퇴한 유저의 행, 또는 방과 그 메시지를 영구히 지우는 작업을 큐에 넣는다. 202 { "job": PurgeJob }이다(모양은 앱 영구 삭제와 같다). ?purge=true가 없으면 400이다. 되돌릴 수 없다.