가이드
웹훅
generation.succeeded 및 generation.failed 웹훅을 받고 Node.js 또는 Python에서 CheaperGen-Signature HMAC-SHA256 헤더를 검증하는 방법
폴링 대신 생성 요청에 webhook_url을 전달하세요. 작업이 최종 상태에 도달하면 전체 생성 객체를 담은 HTTPS POST 요청을 해당 URL로 보냅니다. 계정 비밀키로 서명합니다.
웹훅 요청#
모든 POST /api/v1/run/{endpoint} 요청 본문에 webhook_url을 추가하세요. 인터넷에서 접근할 수 있는 HTTPS URL이어야 합니다.
curl -X POST https://cheapergen.com/api/v1/run/bytedance/seedance-2.0-fast/text-to-video \
-H "Authorization: Bearer $CHEAPERGEN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"prompt": "골든아워에 꽃병을 빚는 도예가에게 천천히 다가가는 카메라, 빛 속 먼지 입자, 얕은 심도",
"aspect_ratio": "16:9",
"resolution": "720p",
"duration": 5,
"webhook_url": "https://your-app.com/hooks/cheapergen"
}'이벤트#
generation.succeededevent- 생성이 완료되었습니다.
data.output에는 미디어 URL,data.cost에는 최종 청구액이 포함됩니다. generation.failedevent- 생성에 실패했습니다. 오류 객체
data.error에 원인(reason,message)과 조치 방법(hint,retryable)이 표시됩니다. 자세한 내용은 실패 이유에서 확인하세요. 보류 금액은 환불되며data.cost.amount_usd값은 0입니다.
사용자가 직접 취소한 생성은 웹훅을 발생시키지 않습니다. 직접 취소했으므로 이미 알고 있기 때문입니다.
페이로드#
본문은 이벤트 type과 data의 생성 객체를 담은 JSON입니다. GET /generations/{id}에서 반환하는 객체와 동일합니다.
{
"type": "generation.succeeded",
"data": {
"id": "gen_8c2QmX1nq0k4Lw7Z",
"object": "generation",
"model": "bytedance/seedance-2.0-fast",
"endpoint": "bytedance/seedance-2.0-fast/text-to-video",
"mode": "text-to-video",
"status": "succeeded",
"input": {
"prompt": "골든아워에 꽃병을 빚는 도예가에게 천천히 다가가는 카메라, 빛 속 먼지 입자, 얕은 심도",
"aspect_ratio": "16:9",
"resolution": "720p",
"duration": 5,
"generate_audio": true
},
"output": {
"video": {
"url": "https://cdn.cheapergen.com/outputs/gen_8c2QmX1nq0k4Lw7Z.mp4",
"content_type": "video/mp4"
}
},
"error": null,
"cost": {
"amount_usd": 0.4494,
"currency": "usd",
"estimated": false
},
"source": "api",
"created_at": "2026-10-15T09:21:04.512Z",
"started_at": "2026-10-15T09:21:09.031Z",
"completed_at": "2026-10-15T09:22:31.870Z",
"retention": "1d",
"expires_at": "2026-10-16T09:22:31.870Z",
"purged_at": null
}
}헤더#
CheaperGen-Signaturestring- 타임스탬프 및 서명:
t=<unix seconds>,v1=<hex HMAC-SHA256>. CheaperGen-Webhook-Idstring- 이 알림의 고유 ID입니다(예:
whd_3f9c2a…). 재시도할 때마다 동일하므로 중복 제거에 사용하세요. Content-Typestringapplication/jsonUser-AgentstringCheaperGen-Webhooks/1.0
서명 확인하기#
누구나 공개 URL에 POST 요청을 보낼 수 있으므로 웹훅을 신뢰하기 전에 항상 서명을 확인하세요. 헤더는 다음과 같습니다:
CheaperGen-Signature: t=1760520151,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd- 헤더를 쉼표(
,) 기준으로 분리한 뒤t(타임스탬프)와v1(서명) 값을 확인하세요. - 타임스탬프 뒤에 문자 그대로의
.를 붙이고, 이어서 수신한 원본 요청 본문을 그대로 연결해 서명 문자열을 만드세요. - 서명 비밀 키를 키로 사용해 해당 문자열의 HMAC-SHA256을 계산하고 16진수로 인코딩하세요.
- 상수 시간 방식으로
v1비교를 수행하세요. 재전송 공격을 막으려면 몇 분 이상 지난 타임스탬프는 거부하세요.
서명 비밀키는 whsec_로 시작하며 API 키 옆의 대시보드에서 확인할 수 있습니다. API 키처럼 서버에 안전하게 보관하세요.
코드에서 API 키로 가져올 수도 있습니다. GET /api/v1/webhooks/secret은(는) { "key": "whsec_…" }을(를) 반환합니다. 계정에 아직 시크릿이 없으면 첫 호출 때 생성됩니다.
curl https://cheapergen.com/api/v1/webhooks/secret \
-H "Authorization: Bearer $CHEAPERGEN_API_KEY"원본 본문 사용하기
받은 바이트를 기준으로 서명을 확인하세요. JSON을 파싱한 뒤 다시 직렬화하면 공백과 키 순서가 달라져 서명이 일치하지 않습니다.Node.js#
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 300; // reject deliveries older than 5 minutes
/**
* Verify a CheaperGen webhook.
* @param rawBody the request body exactly as received (string or Buffer) — not re-serialized JSON
* @param header the value of the "CheaperGen-Signature" header
* @param secret your signing secret (whsec_…)
*/
export function verifyCheaperGenSignature(rawBody, header, secret) {
const parts = Object.fromEntries(
String(header ?? "")
.split(",")
.map((kv) => kv.trim().split("=", 2)),
);
const t = Number(parts.t);
const v1 = parts.v1;
if (!Number.isFinite(t) || !v1) return false;
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(v1, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Python#
import hashlib, hmac, time
TOLERANCE_SECONDS = 300 # reject deliveries older than 5 minutes
def verify_cheapergen_signature(raw_body: bytes, header: str, secret: str) -> bool:
"""Verify a CheaperGen webhook. raw_body must be the exact bytes received."""
try:
parts = dict(kv.strip().split("=", 1) for kv in (header or "").split(","))
t, v1 = int(parts["t"]), parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - t) > TOLERANCE_SECONDS:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)전달 및 재시도#
- 전달을 확인하려면 10초 안에 2xx 상태 코드로 응답하세요. 리디렉션은 따라가지 않습니다.
- 엔드포인트가 시간 초과되거나 2xx 이외의 상태로 응답하면 최대 1시간 동안 재시도합니다. 첫 시도 후 약 1, 3, 8, 18, 38, 60분에 재시도하며(총 7회) 그 후에는 중단합니다.
410 Gone응답을 받으면 재시도를 즉시 중단합니다. - 매 시도는 새
t(으)로 다시 서명되므로, 재시도도 첫 전달과 마찬가지로 5분 검사를 통과합니다. - 전달은 두 번 이상 도착할 수 있으며, 드물게 순서가 바뀔 수도 있습니다. 핸들러를 멱등적으로 만드세요.
CheaperGen-Webhook-Id헤더(같은 알림의 재시도마다 동일)를 키로 사용하고 이미 처리한 알림은 무시하세요. - 웹훅은 알림일 뿐 유일한 기준 정보가 아닙니다. 놓친 경우에도
GET /api/v1/generations/{id}에서 생성 정보를 확인할 수 있습니다. 자체적으로 아직 대기 중인 작업을 주기적으로 조회하면 모든 예외 상황을 처리할 수 있습니다.
로컬에서 테스트하기#
웹훅 URL은 인터넷에서 접속할 수 있는 공개 HTTPS 주소여야 합니다. 개발 중에는 터널을 사용해 로컬 서버를 공개하세요 (예: cloudflared tunnel 또는 ngrok http 3000). 터널의 HTTPS URL을 webhook_url로 입력하세요.
궁금한 점이나 빠진 내용이 있나요? support@cheapergen.com로 이메일을 보내 주세요. 모든 의견을 읽고 있습니다.