영상과 이미지 생성은 비동기 방식입니다. 요청을 제출하면 작업 ID를 받고, 결과는 잠시 뒤에 나옵니다. 계속 폴링하는 대신 작업이 끝날 때 결과를 서버로 보내 드리고, 잔액이 부족해지면 알려 드릴 수도 있습니다.
어디에 쓰면 좋은지, 3단계 설정 방법: 웹훅 가이드 보기 →
이벤트를 받는 두 가지 방법
- 계정 기본 주소: 콘솔의 ‘설정 → 웹훅’에서 엔드포인트 URL을 입력하고 받을 이벤트를 선택합니다. 이후 API로 제출하는 모든 작업은 끝날 때 선택한 항목에 따라 알림이 갑니다. 웹훅 설정 열기 →
- 요청별 callback_url: 영상·이미지 작업을 제출할 때 callback_url 필드를 추가합니다. 그 작업의 결과(성공·실패 모두)는 이 URL로만 보내며, ‘설정’에서 선택한 항목과는 관계없습니다.
callback_url은 외부에서 접근할 수 있는 공개 http(s) 주소여야 하며 최대 2,000자입니다. callBackUrl로 써도 됩니다. 두 방법 모두 같은 서명 시크릿을 쓰며, ‘설정 → 웹훅’에서 확인할 수 있습니다.
curl https://nezhagate.com/v1/videos/generations -H 'Authorization: Bearer YOUR_API_KEY' -H 'Content-Type: application/json' -d '{"model": "seedance-2.5", "prompt": "a paper boat drifting down a rainy street", "duration": 5, "callback_url": "https://example.com/webhooks/nezhagate"}'이벤트
| type | 보내는 시점 | data 내용 |
|---|---|---|
video.completed | 영상 생성이 완료되었을 때 | 작업 조회 엔드포인트(GET /v1/videos/jobs/{id}, GET /v1/images/jobs/{id})가 반환하는 것과 똑같은 JSON |
video.failed | 영상 생성에 실패했을 때(전액 환불됨) | |
image.completed | 이미지 생성이 완료되었을 때 | |
image.failed | 이미지 생성에 실패했을 때(전액 환불됨) | |
balance.low | 잔액이 설정한 크레딧 수 아래로 내려갔을 때(내려갈 때마다 한 번만 보내며, 충전하면 다시 활성화됩니다) | balance_credits(현재 잔액, 크레딧 단위)와 threshold_credits(설정한 알림 기준) |
webhook.test | ‘설정’에서 ‘테스트 이벤트 보내기’를 눌렀을 때 | 이벤트를 받고 서명을 검증할 수 있는지 확인하기 위한 짧은 메시지 |
요청 형식
지정한 URL로 JSON 본문을 담은 POST 요청을 보내며, 다음 헤더가 포함됩니다:
webhook-id | 이벤트 ID입니다. 재시도해도 바뀌지 않으므로 중복 제거에 사용하세요. |
webhook-timestamp | 이번 전송 시각(Unix 초)입니다. 전송할 때마다 새로 바뀝니다. |
webhook-signature | 서명입니다. v1, 뒤에 Base64로 인코딩한 HMAC-SHA256이 붙습니다. |
Content-Type | 항상 application/json입니다. |
요청 본문 예시
영상 생성 완료 시:
{
"id": "evt_5f0c3d2e8a9b4c71d2e3f405",
"object": "event",
"type": "video.completed",
"created_at": 1791200000,
"data": {
"id": "img_3f9a...c2",
"object": "video.generation.job",
"status": "succeeded",
"created": 1791199400,
"model": "seedance-2.5",
"data": [{ "url": "https://img.nezhagate.com/i/9f86d081a8....mp4" }],
"usage": { "clips": 1, "model": "seedance-2.5" }
}
}작업이 실패하면 작업 조회 엔드포인트와 마찬가지로 data에 error가 담기며, 요금은 이미 전액 환불되었습니다:
{
"id": "evt_8b1e47c09d3a2f6e5b7c1d90",
"object": "event",
"type": "image.failed",
"created_at": 1791200120,
"data": {
"id": "img_7c21...e4",
"object": "image.generation.job",
"status": "failed",
"created": 1791200050,
"model": "gpt-image-2",
"error": { "message": "...", "type": "upstream_error", "code": "upstream_error" }
}
}{
"id": "evt_1a2b3c4d5e6f708192a3b4c5",
"object": "event",
"type": "balance.low",
"created_at": 1791203000,
"data": { "balance_credits": 412.5, "threshold_credits": 500 }
}서명 검증
이벤트는 Standard Webhooks 규격에 따라 서명합니다(OpenAI 웹훅도 같은 규격을 씁니다). 검증 방법:
- 요청 헤더 webhook-id, webhook-timestamp, webhook-signature와 원본 요청 본문을 읽습니다(JSON으로 파싱했다가 다시 직렬화하지 마세요).
- 서명 대상 문자열을 만듭니다: webhook-id + "." + webhook-timestamp + "." + 원본 본문.
- 키는 whsec_ 뒤의 부분을 Base64로 디코딩한 값입니다. 이 키로 HMAC-SHA256을 계산하고 결과를 Base64로 인코딩합니다.
- webhook-signature에는 서명이 여러 개 들어 있을 수 있습니다(공백으로 구분되며 각각 v1,로 시작). 그중 하나라도 일치하면 통과입니다. 비교할 때는 상수 시간 비교를 사용하세요.
- 재전송 공격을 막으려면 타임스탬프가 현재 시각과 5분 넘게 차이 나는 요청은 거부하세요.
import base64, hashlib, hmac, time
from flask import Flask, abort, request
SECRET = "whsec_..." # Settings -> Webhook -> Signing secret
KEY = base64.b64decode(SECRET[len("whsec_"):])
def verify(headers, body: bytes) -> bool:
msg_id = headers.get("webhook-id", "")
ts = headers.get("webhook-timestamp", "")
if not msg_id or not ts.isdigit() or abs(time.time() - int(ts)) > 300:
return False
signed = f"{msg_id}.{ts}.".encode() + body
expected = base64.b64encode(hmac.new(KEY, signed, hashlib.sha256).digest()).decode()
return any(sig.startswith("v1,") and hmac.compare_digest(sig[3:], expected)
for sig in headers.get("webhook-signature", "").split())
app = Flask(__name__)
@app.post("/webhooks/nezhagate")
def webhook():
if not verify(request.headers, request.get_data()):
abort(401)
event = request.get_json()
if event["type"] == "video.completed":
print(event["data"]["id"], event["data"]["data"][0]["url"])
return "", 204import crypto from "node:crypto";
import express from "express";
const SECRET = "whsec_..."; // Settings -> Webhook -> Signing secret
const KEY = Buffer.from(SECRET.slice("whsec_".length), "base64");
const app = express();
app.post("/webhooks/nezhagate", express.raw({ type: "application/json" }), (req, res) => {
const id = req.header("webhook-id"), ts = req.header("webhook-timestamp");
if (!id || !ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(401);
const expected = Buffer.from(
crypto.createHmac("sha256", KEY).update(`${id}.${ts}.`).update(req.body).digest("base64"));
const ok = (req.header("webhook-signature") || "").split(" ").some((sig) => {
const got = Buffer.from(sig.slice(3));
return sig.startsWith("v1,") && got.length === expected.length && crypto.timingSafeEqual(got, expected);
});
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body.toString("utf8"));
console.log(event.type, event.data.id);
res.sendStatus(204);
});
app.listen(3000);각 언어용 Standard Webhooks 공식 라이브러리를 써도 됩니다(Python: pip install standardwebhooks, Node: npm install standardwebhooks).
응답과 재시도
- 10초 안에 2xx 상태 코드를 반환하면 전달된 것으로 봅니다. 응답 본문은 상관없습니다. 먼저 2xx로 응답한 뒤 처리하세요.
- 2xx를 받지 못하면(타임아웃, 연결 오류, 3xx, 4xx, 5xx) 1분, 5분, 30분, 2시간, 6시간 후에 한 번씩 다시 보내고, 그 뒤에는 실패로 표시합니다.
- 같은 이벤트가 두 번 이상 도착할 수 있고 순서도 보장되지 않습니다. webhook-id로 중복을 제거하고 data.status를 기준으로 판단하세요.
- ‘설정 → 웹훅’에서 최근 30일간의 전송 내역과 각 결과를 확인할 수 있고, 직접 다시 보낼 수도 있습니다.
보안
- 공개 주소로만 보냅니다. 사설, 로컬, 예약된 주소는 모두 거부합니다.
- 리디렉션은 따르지 않습니다. 3xx 응답은 실패로 처리합니다.
- 서명 시크릿은 API Key처럼 안전하게 보관하세요. 유출되었다면 ‘설정’에서 재발급하세요. 이전 시크릿은 즉시 무효화됩니다.
테스트
‘설정 → 웹훅’에서 URL을 저장한 뒤 ‘테스트 이벤트 보내기’를 누르세요. 바로 webhook.test 이벤트를 보내고, 서버가 응답한 상태 코드와 응답 시간을 보여 드립니다.