Skip to Content

웹훅

웹훅은 Turn이나 Run이 끝났을 때 UpServe가 당신의 서버로 알림을 보내는 방식입니다. 알림에는 결과 원문이 없고 식별자와 상태, 조회 경로만 있으므로, 받은 뒤에 API 키로 현재 상태를 다시 읽어야 합니다.

등록

웹훅 관리는 API 키가 아니라 로그인한 조직 OWNER의 권한(JWT) 으로 하는 관리 API입니다. 개발자 화면(https://upserve.app/settings/developer)의 웹훅 탭에서 할 수 있고, 관리 API는 /api/runtime/projects/{project_id}/webhooks 아래에 있습니다. 이 경로들은 /v1 OpenAPI에 포함되지 않으며, 실행용 API 키로는 웹훅 주소나 secret을 바꿀 수 없습니다.

# Management API: authenticated with the owner's login token (JWT), not an API key curl -s -X POST "https://api.upserve.app/api/runtime/projects/$PROJECT_ID/webhooks" \ -H "Authorization: Bearer $OWNER_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{"url":"https://receiver.example/upserve","event_types":["turn.completed","turn.failed","run.completed","run.failed"]}'
  • 주소는 HTTPS만 허용합니다. Project당 삭제하지 않은 endpoint는 최대 10개입니다.
  • event_types를 생략하면 Turn과 Run의 completed, failed, canceled, expired, action_required를 받습니다. run.step.completed는 단계 완료일 뿐 Run 전체 완료가 아닙니다.
  • 응답으로 서버가 만든 secret이 한 번만 나옵니다. 수신 서버의 비밀 저장소에 보관하세요.
  • secret 회전(POST /api/runtime/projects/{project_id}/webhooks/{endpoint_id}/rotate-secret) 중에는 이전과 새 secret을 둘 다 검증해야 합니다. 이미 보낸 요청의 최대 송신 시간 20초와 서명 허용 창 300초가 지난 뒤 이전 secret을 지워도 됩니다.
  • 주소나 구독 이벤트를 바꾸면 대기 중이던 전달은 취소되고, 새 주소로 과거 이벤트를 자동 전송하지 않습니다.

알림 본문

본문은 최대 4KiB의 JSON입니다.

{ "event_id": "7c2e0d44-0000-4000-8000-000000000008", "type": "turn.completed", "aggregate_kind": "SESSION", "sequence": 4, "occurred_at": "2026-10-05T09:00:20+00:00", "session_id": "6f1c0a52-0000-4000-8000-000000000001", "turn_id": "6f1c0a52-0000-4000-8000-000000000002", "status_url": "/v1/sessions/6f1c0a52-0000-4000-8000-000000000001/turns/6f1c0a52-0000-4000-8000-000000000002", "outputs_url": "/v1/sessions/6f1c0a52-0000-4000-8000-000000000001/turns/6f1c0a52-0000-4000-8000-000000000002", "status": "SUCCEEDED" }

입력과 결과 원문, 파일, 다운로드 주소는 들어 있지 않습니다. status_url과 outputs_url은 인증이 필요한 상대 경로입니다. Run 알림에는 run_id가 있고 /v1/runs/{run_id}를 가리킵니다. 단계 이벤트에는 step_id, 질문과 승인 이벤트에는 action_id가 추가됩니다.

서명 검증

모든 요청에 Upserve-Signature 헤더가 있습니다.

Upserve-Signature: t=1791190820,v1=<64 hex characters> signed_bytes = ASCII(timestamp) + "." + raw_body
  • 서명은 timestamp와 .와 원본 요청 바이트를 이어 붙인 값의 HMAC-SHA256이며, 키는 secret 문자열 전체입니다.
  • JSON을 파싱한 뒤 다시 직렬화한 바이트로 검증하면 안 됩니다. 받은 그대로의 바이트를 쓰세요.
  • 비교는 상수 시간 비교(hmac.compare_digest, timingSafeEqual)로 하세요.
import hashlib import hmac import json import re import time def verify(body: bytes, header: str, secrets: list[str], *, now=None) -> dict: if len(body) > 4096: raise ValueError("Webhook body too large") match = re.fullmatch(r"t=([0-9]{1,12}),v1=([0-9a-f]{64})", header) if not match: raise ValueError("Invalid signature header") if abs((time.time() if now is None else now) - int(match[1])) > 300: raise ValueError("Expired signature") signed = match[1].encode("ascii") + b"." + body if not any(hmac.compare_digest(hmac.new(s.encode("ascii"), signed, hashlib.sha256).hexdigest(), match[2]) for s in secrets): raise ValueError("Invalid signature") return json.loads(body)

TypeScript 판은 다음과 같습니다.

import { createHmac, timingSafeEqual } from 'node:crypto'; export function verify(body: Buffer, header: string, secrets: string[], now = Math.floor(Date.now() / 1000)) { if (body.length > 4096) throw new Error('Webhook body too large'); const match = /^t=([0-9]{1,12}),v1=([0-9a-f]{64})$/.exec(header); if (!match || Math.abs(now - Number(match[1])) > 300) throw new Error('Invalid or expired signature'); const supplied = Buffer.from(match[2], 'hex'); const signed = Buffer.concat([Buffer.from(match[1] + '.', 'ascii'), body]); if (!secrets.some((secret) => timingSafeEqual(createHmac('sha256', secret).update(signed).digest(), supplied))) { throw new Error('Invalid signature'); } return JSON.parse(body.toString('utf8')); }

timestamp 허용 창

t=의 시각이 지금과 300초(±5분) 넘게 다르면 거절하세요. 재시도와 재전송도 시도마다 새 timestamp와 서명을 계산하므로, 서버 시계가 정확해야 합니다. 시계가 틀어진 서버는 정상 알림을 거절하게 됩니다.

event_id 중복 제거

전달은 최소 한 번 보장입니다. 같은 이벤트가 여러 번 올 수 있으니 영속 저장소에 event_id를 UNIQUE로 저장해 중복을 거르세요.

def accept(db, body: bytes, header: str, secrets: list[str]) -> bool: event = verify(body, header, secrets) with db: inserted = db.execute( "INSERT INTO webhook_inbox(event_id, body) VALUES (?, ?) ON CONFLICT(event_id) DO NOTHING", (event["event_id"], body), ).rowcount # Return 204 to the sender in both cases: a duplicate is already stored return bool(inserted)
  • 서명을 검증하고 저장에 성공하면 즉시 2xx를 돌려주세요. 중복 수신도 2xx 입니다.
  • 무거운 처리는 응답 뒤 별도 작업에서 하세요. 수신 요청 안에서 오래 걸리면 시간 초과로 재시도됩니다.
  • 중복 제거용 ID는 재전송이 가능한 기간인 최대 30일 동안 유지하세요.
  • 같은 이벤트의 재시도와 재전송은 event_id와 본문이 같습니다. 서로 다른 본문이 같은 event_id로 오면 거절하세요.

순서 역전

웹훅의 전달 순서는 보장되지 않습니다. 끝난 Run의 completed 알림 뒤에 늦은 진행 알림이 올 수도 있습니다. sequence는 같은 Session이나 Run 안에서만 비교하세요. 그리고 알림의 status로 저장된 상태를 덮어쓰지 말고, 알림을 받으면 인증된 API 조회로 현재 상태를 읽어서 반영하세요.

# After accepting an event, read the authoritative state with your API key curl -s "https://api.upserve.app/v1/runs/$RUN_ID" \ -H "Authorization: Bearer $UPSERVE_API_KEY"

웹훅은 작업을 시키는 신호가 아닙니다. 통신 문제를 복구하려고 새 실행을 접수하지 마세요. 알림에 들어 있는 주소를 따라가지 말고, 알림의 ID로 직접 만든 경로에 설정해 둔 API 기본 URL을 붙여 조회하세요. 그 요청도 redirect를 따르지 않게 하세요.

재시도와 재전송

  • HTTP 2xx만 성공입니다. 그 밖의 응답과 네트워크 오류는 재시도합니다.
  • 재시도는 첫 시도를 기준으로 1분, 5분, 30분, 2시간, 8시간, 24시간 뒤입니다. 처음을 포함해 최대 7회이며, 모두 실패하면 전달 상태가 FAILED가 됩니다. FAILED 목록이 DLQ 역할을 합니다.
  • 전달 실패는 Turn이나 Run의 성공, 실패 상태를 바꾸지 않습니다. 성공한 Run에 실패한 전달이 있을 수 있습니다.
  • 전달 이력은 GET /api/runtime/projects/{project_id}/webhook-deliveries로 보고, 보존 중인 이벤트는 POST /api/runtime/projects/{project_id}/webhook-deliveries/{delivery_id}/replay로 다시 보냅니다. Idempotency-Key가 필요합니다. Project당 분당 10회로 제한됩니다.
  • 재전송은 알림을 다시 보낼 뿐 기존 실행을 다시 하지 않습니다.

네트워크 조건

  • HTTPS만 쓸 수 있고, TLS 인증서를 검증합니다.
  • redirect(3xx)는 따라가지 않고 실패로 기록합니다. 주소를 옮겼다면 endpoint를 새 주소로 바꾸세요.
  • 매 시도마다 DNS를 다시 확인해 모든 주소가 공인 주소인지 검사합니다. 내부망, loopback, link-local, metadata 주소, localhost, .local, .internal은 거절됩니다.
  • 한 번의 전송은 DNS를 포함해 최대 20초입니다.
  • 실패 이력에는 고정된 오류 코드와 HTTP 상태 숫자만 남기고, 수신 서버의 응답 본문은 저장하지 않습니다.