이벤트와 웹훅
Station 에서 일어나는 일(명령의 진행, 기기 접속, virtual 기기의 컴퓨트 상태, Dock 변화)은 이벤트로 나와요. 같은 이벤트를 세 가지로 받을 수 있어요.
| 방법 | 쓰는 곳 | 인증 |
|---|---|---|
SSE GET /stations/{id}/events |
서버, 브라우저 | 사용자 인증 또는 ?ticket= |
WebSocket GET /stations/{id}/events/socket |
브라우저 | ?ticket= 만 |
| 웹훅 | 내 서버 | 서명 (Endue-Signature) |
이벤트 모양
섹션 제목: “이벤트 모양”{ "event_id": "evt_…", "seq": 41, "type": "device.status_changed", "occurred_at": "2026-10-05T01:02:03Z", "station_id": "stn_…", "resource_id": "res_…", "data": { "compute_status": "running", "status_reason": null, "online": true }}| type | data |
|---|---|
command.queued · dispatched · running · completed · late_result |
{command_id, request_id, command, status, duration_ms?, error_code?, output_url?} |
command.progress |
{command_id, request_id, seq, data} — 실시간 구독만 (웹훅으로 가지 않아요) |
resource.online · resource.offline |
{online, reason?, agent_version?} |
resource.catalog_changed |
{tool_count} |
device.status_changed |
{compute_status, status_reason, online} — virtual 기기의 컴퓨트 상태가 바뀔 때 |
dock.changed |
{} — Dock 을 다시 읽어요 |
resource_id 는 이벤트가 가리키는 기기예요 — Device 의 device_id 와 같은 값이에요. 기기 접속 이벤트는 예전 이름(resource.*) 그대로예요. 예전의 app.status_changed 는 device.status_changed 가 됐고, 이벤트의 app_id 필드는 없어졌어요.
모르는 type 은 무시하세요. 명령의 출력은 이벤트에 싣지 않아요 — output_url 로 받아요.
구독하기
섹션 제목: “구독하기”브라우저의 EventSource·WebSocket 은 헤더를 못 실으므로 1회용 입장권을 먼저 받아요.
const { data } = await api('POST', `/stations/${stn}/event-tickets`); // ostk_… 60초, 한 번만const es = new EventSource(`/api/v1/stations/${stn}/events?ticket=${encodeURIComponent(data.ticket)}`);es.onmessage = (m) => console.log(JSON.parse(m.data));- 필터:
types(쉼표 목록 또는 접두사, 예command.*,device.status_changed)와resource_id(=device_id). - 이어 받기: SSE 의
id:가seq예요. 끊긴 뒤Last-Event-ID헤더나last_event_id쿼리로 이어 받아요. 최근 1000개·15분(링버퍼) 밖이면 SSE 는event: resync, WebSocket 은 4410 으로 닫혀요 — REST 로 상태를 다시 읽고 새로 붙어요. - 입장권이 없거나 만료·재사용이면 WebSocket 은 4401.
- 실시간 길은 최선 노력이고, 같은 이벤트가 Queue 를 거친 내구 길로도 와요. 허브가
event_id로 중복을 거르고seq를 한 번만 매기므로 SSE 와 웹훅의seq가 같아요.
curl -s -X POST $OS/api/v1/stations/$STN/webhooks \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"url":"https://example.com/hooks/endue-os","events":["device.status_changed","command.completed"]}' \ | jq -r .data.secret # oswh_… — 이 응답에서만 보여요- https 의 공인 호스트만 (사설·루프백 주소는 422
WEBHOOK_URL_NOT_ALLOWED). Station 당 10개. - 최소 한 번 전달해요. 같은 이벤트는 구독당 한 번만 만들고
Endue-Event-Id로 중복을 걸러요. - 2xx 가 아니면 지수 백오프로 약 24시간 재시도해요. 연속 20번 실패하면
paused—resume으로 다시 켜요.
서명 확인
섹션 제목: “서명 확인”Endue-Signature: t=1791163160,v1=5d41402abc4b2a76b9719d911017c592…v1 = hex(HMAC-SHA256(secret, t + "." + 본문)). 본문은 받은 바이트 그대로 써요.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(secret, header, rawBody, toleranceSec = 300) { const parts = Object.fromEntries(header.split(',').map((p) => p.split('='))); const t = Number(parts.t); if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; const want = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'); return want.length === parts.v1?.length && timingSafeEqual(Buffer.from(want), Buffer.from(parts.v1));}