CheaperGen

가이드

웹훅

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}에서 반환하는 객체와 동일합니다.

POST https://your-app.com/hooks/cheapergen
{
  "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-Typestring
application/json
User-Agentstring
CheaperGen-Webhooks/1.0

서명 확인하기#

누구나 공개 URL에 POST 요청을 보낼 수 있으므로 웹훅을 신뢰하기 전에 항상 서명을 확인하세요. 헤더는 다음과 같습니다:

CheaperGen-Signature: t=1760520151,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  1. 헤더를 쉼표(,) 기준으로 분리한 뒤 t (타임스탬프)와 v1 (서명) 값을 확인하세요.
  2. 타임스탬프 뒤에 문자 그대로의 .를 붙이고, 이어서 수신한 원본 요청 본문을 그대로 연결해 서명 문자열을 만드세요.
  3. 서명 비밀 키를 키로 사용해 해당 문자열의 HMAC-SHA256을 계산하고 16진수로 인코딩하세요.
  4. 상수 시간 방식으로 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로 이메일을 보내 주세요. 모든 의견을 읽고 있습니다.