콘솔 API (계정 키)
콘솔 화면에서 손으로 하는 일 — 앱 만들기, 키 발급, 허용 origin·웹훅·한도 설정, 앱 삭제와 복구 — 을 HTTP로 한다.
계정 키(ck_…) 하나면 스크립트와 AI가 가입 뒤의 나머지를 대신할 수 있다.
- 기준 주소:
https://console.chat.gravex.app(모든 경로가/api/apps로 시작한다. 문서 사이트가 아니라 콘솔 주소다) - 본문은 JSON(UTF-8)이고
Content-Type: application/json을 보낸다. 아니면 415unsupported_media_type이다. - 시각은 ISO 8601 문자열이다(
2026-10-25T08:30:00.000Z). REST API 레퍼런스의 에포크 밀리초와 다르다. - 이 API는 앱과 키를 다룬다. 방·메시지를 만들고 보내는 일은 여기서 받은
sk_로 REST API와 서버 연동이 한다. 콘솔 API로 방을 만들지는 않는다.
계정 키 받기
계정 키는 사람이 한 번 만든다. 가입과 키 발급은 AI가 대신할 수 없다.
- 콘솔에서 이메일로 받은 6자리 코드로 가입한다.
- 위쪽 메뉴의 API 키 → 새 키 → 이름(예:
노트북 .env)을 적는다. - 키 전체(
ck_…)가 한 번만 보인다. 바로 복사해 프로젝트의.env에 넣는다. 콘솔은 해시만 저장해서 다시 보여 줄 수 없다.
# .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와 함께 남는다.
인증
모든 요청에 헤더 하나를 붙인다. 쿠키는 필요 없다.
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로 한다.
{ "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다.
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_ 전체 값이 이 응답에 같이 온다.
{
"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_를 받고 옛 것을 폐기한다.
# .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
{ "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}
{
"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}
앱을 소프트 삭제한다. 엔진이 키를 모두 폐기하고 접속을 끊고 웹훅을 멈춘다. 데이터는 남는다.
{ "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] }로 준다.
{ "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
{ "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 } |
서명 시크릿 교체 |
{ "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은 기본값으로 되돌린다. 한 필드라도 틀리면 아무것도 바꾸지 않는다.
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}'{ "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), 모르는 필드는 400invalid_request이고 응답의field가 그 필드다. - 상한을 넘기면 400
limit_above_ceiling, 최솟값 아래면 400limit_below_min이고limit에 그 값이 온다. - 바뀐 값이 닿는 시점은 필드마다 다르다. 이어진 연결에는 30초 안에, 어떤 필드는 다음 접속부터 닿는다.
- 실효 한도는 REST API 레퍼런스와 실시간 프로토콜의 한도와 같은 값이다.
조회
유저·방·메시지는 읽기 전용이다. 만들거나 고치려면 sk_로 REST를 부른다.
GET /api/apps/{id}/users
{ "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
{ "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
{ "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와 같다.
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)로 바꾼다. 탈퇴시키지 않는다.
{ "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이다. 되돌릴 수 없다.