콘텐츠로 이동

이벤트와 웹훅

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));
}