Webhook
v1·v2 페이로드 구분, 정확한 작업 ID 선택과 Node.js·Python·curl 서명 검증 예시입니다.
Webhook(콜백)은 작업 완료 시 SpicyAPI가 이용자 서버로 보내는 요청으로 반복 조회를 줄입니다. createTask에 callBackUrl을 넣으면 종료 상태에서 POST로 전달합니다. 성공 작업은 파일이 다운로드 준비될 때까지 기다려 사용 가능한 링크를 포함합니다. 전달 실패는 아래 일정으로 재시도합니다.
종료 상태에서만 콜백을 보내며 queued → running 알림은 없습니다.
시작 전
- 공개 HTTPS 서버 주소가 필요합니다. 인터넷에서 접근 가능하고
https://로 시작해야 합니다.http://localhost나 사내·가정망 주소는 안 됩니다. 로컬 개발은recordInfo폴링 또는 ngrok·Cloudflare Tunnel 같은 공개 HTTPS 터널을 사용하세요. 콜백 URL 제약 참조. - 이벤트 수신 방식 두 가지가 공존합니다. 작업의
callBackUrl이 가장 빠른 시작이며 계정 엔드포인트를 등록하면 URL 반복 없이 모든 작업을 받습니다. 방식 선택 참조. - ‘테스트 이벤트 전송’ 버튼은 없습니다. 먼저 아래 curl로 서명을 시험하고 저렴한 실제 작업 하나에
callBackUrl을 설정하세요. 일반 작업처럼 과금됩니다. 콘솔 → Webhook → 전달 내역에서 상태를 확인합니다.
방식 선택
작업의 callBackUrl | 계정 엔드포인트 | |
|---|---|---|
| 설정 위치 | createTask마다 필드 하나 | API에서 한 번(콘솔 화면은 아직 없음) |
| 전달 대상 | 해당 작업의 종료 상태 | 모든 작업의 구독 이벤트 |
| 헤더 | X-Webhook-* | webhook-id / webhook-timestamp / webhook-signature |
| 본문 | recordInfo와 같은 {code, msg, data} 형식 | id, type, data.object를 가진 이벤트 객체 |
| 서명 비밀키 | 계정당 하나, Webhook 서명 키 | 엔드포인트당 하나, 생성·교체 시 한 번 표시 |
| 중단 없는 교체 | 불가. 이전 키 즉시 무효 | 가능. 유예 기간 두 서명 전송 |
서로 대체하지 않습니다. callBackUrl을 가진 작업에 계정 구독도 일치하면 각 형식으로 두 번 전달하며 정상 구성입니다.
처음 통합하고 특별한 이유가 없다면 계정 엔드포인트를 등록하세요. URL은 한 번만 적고 일괄 전환 없이 키를 교체하며 일반 webhook 라이브러리의 익숙한 페이로드를 사용합니다.
별도 언급이 없으면 이하 내용은 callBackUrl 전달입니다. 다른 점은 계정 엔드포인트에서 설명합니다.
서명 비밀키부터 설정
모든 전달은 계정 서명 비밀키로 서명하여 SpicyAPI 발신을 확인할 수 있습니다. 계정의 모든 API 키가 하나를 공유합니다.
비밀키는 한 번만 표시됩니다
보통 계정에는 이미 비밀키가 설정되어 있지만 값을 표시한 적이 없으므로 볼 수 있는 키를 만들어야 합니다.
- 콘솔 → Webhook을 여세요.
- Webhook 서명 키 카드에서 미설정이면 생성, 설정됨이면 교체를 누르세요.
- 즉시 서버
SPICY_WEBHOOK_SECRET에 복사하고 저장했습니다를 누르세요. 다시 표시할 수 없으며 분실하면 다시 교체합니다.
교체는 즉시 적용됩니다. 이전 키는 바로 무효가 되고 재시도 중인 전달도 새 키로 서명합니다. 교체 직후 모든 수신 서버를 갱신하세요.
비밀키 없이는 서명할 수 없어 매 시도가 실패로 집계되고 재시도 일정을 따릅니다. 키를 사용할 수 없어 콜백을 놓친 작업은 recordInfo로 조회하세요.
SDK 검증기로 시작
새 v2 수신기는 @spicyapi/sdk/webhooks의 verifyWebhook을 사용하세요. 원본 본문 서명과 5분 타임스탬프 범위를 검사하고 taskId, deliveryId를 반환합니다. Node.js crypto를 사용하므로 서버에 두세요.
예시는 Express와 PostgreSQL입니다(npm install @spicyapi/sdk express pg). 일반 PG* 환경 변수로 DB를 설정하고 콘솔 서명 키를 SPICY_WEBHOOK_SECRET에 넣으세요. HTTPS 뒤에 경로를 제공하세요. 전체 input(프롬프트 포함)이 있어 예시는 10 MB까지 받습니다. 자체 크기 제한도 적절히 정하세요. 앱 테이블 두 개를 한 번 만듭니다.
CREATE TABLE spicy_tasks (task_id text PRIMARY KEY);
CREATE TABLE spicy_webhook_inbox (
delivery_id text PRIMARY KEY,
task_id text NOT NULL REFERENCES spicy_tasks(task_id),
payload jsonb NOT NULL,
received_at timestamptz NOT NULL DEFAULT now()
);자체 제출에서 받은 모든 taskId를 spicy_tasks에 저장하세요. 수신기는 해당 작업만 허용합니다. 매우 빠른 콜백이 삽입 전에 오면 non-2xx 응답으로 재시도됩니다. 공유 앱은 테넌트의 작업 소유권 규칙도 적용하세요.
import express from "express";
import pg from "pg";
import { verifyWebhook } from "@spicyapi/sdk/webhooks";
const secret = process.env.SPICY_WEBHOOK_SECRET;
if (!secret) throw new Error("Set SPICY_WEBHOOK_SECRET");
const db = new pg.Pool();
const app = express();
app.post("/hooks/spicy", express.raw({ type: "application/json", limit: "10mb" }), async (req, res) => {
if (req.get("X-Webhook-Payload-Version") !== "2") return res.sendStatus(400);
let event;
try {
event = verifyWebhook({
rawBody: req.body,
timestamp: req.get("X-Webhook-Timestamp") ?? "",
signature: req.get("X-Webhook-Signature") ?? "",
payloadVersion: 2,
secret,
maxBodyBytes: 10 * 1024 * 1024,
});
if (!event.deliveryId) return res.sendStatus(400);
} catch { return res.sendStatus(401); }
try {
const owned = await db.query("SELECT 1 FROM spicy_tasks WHERE task_id=$1", [event.taskId]);
if (!owned.rowCount) return res.sendStatus(404);
await db.query(
"INSERT INTO spicy_webhook_inbox(delivery_id,task_id,payload) VALUES($1,$2,$3) ON CONFLICT(delivery_id) DO NOTHING",
[event.deliveryId, event.taskId, JSON.stringify(event.payload)],
);
return res.sendStatus(204);
} catch { return res.sendStatus(503); }
});
app.listen(8080, "127.0.0.1");전역 express.json() 이전에 원본 본문 경로를 등록하세요. 수신함 기록이 커밋된 뒤 2xx를 반환하고 백그라운드 worker가 이벤트를 처리하게 하세요. 기본 키가 중복을 무해하게 합니다. 결과 성공·최종 비용 판단 전에 data.state, data.settled를 확인하세요. 저장 URL이 만료되면 보관 기간 내 재조회합니다.
이 새 통합 예시는 v2입니다. 과거 v1 수신기는 아래 버전별 검증을 유지하세요. SDK도 payloadVersion: 1을 지원하지만 v1에는 deliveryId가 없습니다.
요청
POST /hooks/spicy HTTP/1.1
Host: your-app.example.com
Content-Type: application/json
User-Agent: SpicyAPI-Webhook/1
X-Webhook-Timestamp: 1787045531
X-Webhook-Signature: 9tXk… (base64)
X-Webhook-Payload-Version: 2| 헤더 | 설명 |
|---|---|
X-Webhook-Timestamp | 이번 전달의 Unix 초. 재전송 공격 방지 |
X-Webhook-Signature | Base64 HMAC-SHA256 |
X-Webhook-Payload-Version | 본문 버전. 새 통합 기본 2, 과거 전달은 1 가능. 항상 헤더로 분기. 페이로드 참조 |
User-Agent | 항상 SpicyAPI-Webhook/1 |
리디렉션을 따르지 않습니다. 엔드포인트 3xx는 실패이며 시간 초과는 15초입니다.
페이로드
v2는 recordInfo와 같고 v1은 기존의 평면 구조입니다
버전 2는 GET /jobs/recordInfo와 동일한 {code, msg, data, request_id} 및 data 안의 camelCase입니다. 1은 바깥 형식 없이 snake_case 작업 필드를 최상위에 둡니다.
새 통합은 v2로 만들되 X-Webhook-Payload-Version을 읽어 ID를 선택하세요. v1은 최상위 task_id, v2는 data.taskId입니다.
아래 fixture의 MODEL_ID_FROM_CATALOG는 처음 인증된 카탈로그에서 선택한 정확한 ID를 대신합니다. 그대로 쓰는 ID가 아닙니다.
{
"code": 200,
"msg": "success",
"request_id": "whd_01k3m8x9q2z4v7n5p6r8s0t1w4",
"data": {
"taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
"model": "MODEL_ID_FROM_CATALOG",
"state": "succeeded",
"input": { "prompt": "a folded paper lantern, hard side light" },
"output": {
"assets": [
{
"key": "tasks/2026/08/28/job_01k3m8x9q2z4v7n5p6r8s0t1w3/9f3c1d0a7b4e2f68c5a1d3e9b0472fa1.png",
"url": "https://example.r2.cloudflarestorage.com/results/result.png?X-Amz-Signature=SIGNATURE_FROM_RESPONSE",
"expiresAt": "2026-08-28T09:32:11Z",
"mime": "image/png",
"width": 1024,
"height": 1024,
"bytes": 1483920
}
]
},
"cost": "0.008",
"settled": true,
"createdAt": "2026-08-28T09:12:04Z",
"completedAt": "2026-08-28T09:12:11Z"
}
}위는 v2입니다. data 참조는 비동기 작업에 있으며 recordInfo와 v2 콜백 모두에 적용되므로 반복하지 않습니다.
준비된 data.output.assets[]는 recordInfo처럼 url, expiresAt과 미디어 정보를 담습니다. API 인증 없이 직접 다운로드하며 티켓 요청이 필요 없습니다. cost 같은 금액은 $ 없는 십진 문자열입니다.
준비되면 작업당 콜백 하나
작업당 이벤트 하나입니다. 실패는 즉시, 성공은 파일을 자체 저장소에 복사한 뒤 만들어 보통 모든 결과에 url이 있습니다. 재시도는 같은 이벤트를 보내고 임시 미디어 링크만 갱신합니다.
나중에 바뀔 수 있으나 추가 콜백을 유발하지 않는 항목이 있습니다.
data.settled가false→ 과금 미확정입니다. 나중에recordInfo에서 최종cost를 읽으세요.
드물게 파일을 전혀 저장하지 못해도 콜백은 옵니다. 해당 결과는 unavailable: true이며 url이 없습니다. 파일을 하나도 저장하지 못하면 과금하지 않습니다(cost는 "0").
state: "succeeded", settled: true이고 모든 결과에 url이 있으면 완전한 콜백이므로 추가 조회 없이 사용합니다.
v2의 code는 실패 작업도 항상 200
code로 작업 성공을 판단하지 마세요
응답 code는 전달 형식의 유효성이지 생성 성공 여부가 아닙니다. 실패 콜백도 200입니다.
{
"code": 200,
"msg": "success",
"request_id": "whd_01k3m8x9q2z4v7n5p6r8s0t1w5",
"data": {
"taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
"model": "MODEL_ID_FROM_CATALOG",
"state": "failed",
"errorCode": "upstream_failed",
"errorMessage": "Generation failed; the charge has been refunded",
"cost": "0",
"settled": true,
"createdAt": "2026-08-28T09:12:04Z",
"completedAt": "2026-08-28T09:12:39Z"
}
}if (body.code === 200) markSucceeded()는 모든 실패를 성공으로 표시하며 겉으로 이상이 드러나지 않습니다. 결과는 data.state, 이유는 data.errorCode, data.errorMessage입니다. 콜백에는 요청 언어 헤더가 없으므로 errorMessage는 계정 언어 설정을 따르며 기본 영어입니다. 오류 언어 참조.
실패 작업을 읽어도 200인 recordInfo와 의도적으로 일치합니다. 서로 다르면 같은 데이터를 두 방식으로 처리해야 하는 문제가 다시 생기기 때문입니다.
v2의 request_id는 전달 ID이며 멱등성 키로 사용
응답 request_id는 whd_… 형식이며 작업이 아니라 전달 기록을 식별합니다.
- 재시도해도 바뀌지 않습니다. 자동·수동 재전달은 같은 이벤트와
request_id를 사용합니다. 매 전송 전 임시url,expiresAt만 갱신되므로 v2를 본문 해시로 중복 제거하지 마세요. - ‘처리한 콜백인가?’는 DB 고유 인덱스로 간단히 판단하고 충돌 시 2xx를 반환하세요.
recordInfo의 같은 필드는 HTTP 요청 IDreq_…입니다. 둘 다 이번 전달을 식별한다는 의미입니다.
v2의 data.taskId는 어느 작업인지, request_id는 어느 전달인지 나타냅니다. v1에는 request_id가 없어 task_id, 상태와 본문 해시에서 키를 만드세요.
버전 헤더는 실제 본문을 따릅니다
X-Webhook-Payload-Version은 현재 설정이 아니라 이 본문을 만든 버전입니다.
이벤트와 버전은 대기열에 들어갈 때 확정됩니다. 재시도는 임시 URL만 갱신하고 실제 전송 본문을 서명하며 최신 작업 사본으로 대체하지 않습니다. 헤더도 이를 따라야 합니다. 그렇지 않으면 수시간 대기한 재전달의 버전이 본문과 모순되어 헤더 분기가 실패합니다.
v1은 동결된 호환 계층입니다. 새 통합에 사용하지 마세요
페이로드 버전은 SpicyAPI가 운영하는 플랫폼 공통 호환 설정이며 이용자가 선택할 수 없습니다. 새 전달은 2입니다. 설정으로 기존 1에 고정할 수 있으며 snake_case, 바깥 응답 형식 없음, 최상위 평면 필드, input·settled·completedAt 없음입니다. 기존 통합을 새 형식 출시 당일 깨뜨리지 않기 위한 용도입니다.
v1에는 새 필드를 추가하지 않습니다. 이후 기능은 v2만 적용하며 v1 이용자가 없음을 확인하면 설정과 함께 제거합니다.
새 통합은 v2를 사용하세요. 과거 v1 전달이 재시도될 수 있으므로 JSON 구조로 추측하지 말고 X-Webhook-Payload-Version으로 아래처럼 서명 검증·파싱을 분기하세요.
서명
signature = base64( HMAC-SHA256( secret, "<task_id>.<timestamp>.<sha256_hex(raw_body)>" ) )세 부분을 .으로 연결합니다.
X-Webhook-Payload-Version으로 선택한 작업 ID: v1은 최상위task_id, v2는data.taskId(request_id가 아님)X-Webhook-Timestamp값- 원본 요청 본문의 SHA-256 해시, 소문자 16진수
서명에 본문 해시를 넣고 검증기에서 실제로 계산하세요
ID와 시각만 서명하면 정상 전달을 가로챈 사람이 두 값을 자신의 본문과 결합하여 재전송할 수 있습니다. 공개 알고리즘으로 검증해도 서명이 통과합니다.
그 결과 위조된 ‘작업 완료·결과물 위치’를 플랫폼이 보증하는 것처럼 받아 고객 과금이나 전달에 사용하게 됩니다. 해시는 선택 사항이 아닙니다.
원본 바이트를 해시하세요
먼저 JSON을 파싱한 뒤 다시 직렬화하지 마세요. 라이브러리별 키 순서·공백·Unicode 이스케이프가 달라 서명한 바이트와 달라지고 검증이 실패합니다.
원본 본문은 Express의 express.raw(), Fastify의 rawBody, Flask의 request.get_data(), Django의 request.body, FastAPI의 await request.body()로 읽습니다.
검증 코드
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.SPICY_WEBHOOK_SECRET;
// The raw bytes are required. express.json() consumes them.
// express.raw() defaults to a 100 KB limit; the body carries your full input.
app.post('/hooks/spicy', express.raw({ type: 'application/json', limit: '10mb' }), (req, res) => {
if (!verify(req.body, req.headers, SECRET)) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString('utf8'));
const version = req.headers['x-webhook-payload-version'];
const task = version === '1' ? event : event.data;
// Acknowledge first, work later. Do not make us wait on your pipeline.
res.status(200).end();
enqueue(task);
});
function verify(rawBody, headers, secret) {
const ts = headers['x-webhook-timestamp'];
const sig = headers['x-webhook-signature'];
const version = headers['x-webhook-payload-version'];
if (!ts || !sig || (version !== '1' && version !== '2')) return false;
// Replay protection: reject anything outside the tolerance window,
// however valid its signature
const timestamp = Number(ts);
if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
// v1 keeps the task ID at top-level task_id; v2 uses data.taskId.
// Nothing parsed out of an unverified body can be trusted yet.
let event;
try {
event = JSON.parse(rawBody.toString('utf8'));
} catch {
return false;
}
const taskId = version === '1' ? event.task_id : event.data?.taskId;
if (typeof taskId !== 'string' || !taskId) return false;
const digest = crypto.createHash('sha256').update(rawBody).digest('hex');
const expected = crypto
.createHmac('sha256', secret)
.update(`${taskId}.${ts}.${digest}`)
.digest('base64');
const a = Buffer.from(expected);
const b = Buffer.from(sig);
// timingSafeEqual throws on a length mismatch, so check that first
if (a.length !== b.length) return false;
// Constant time — a plain === leaks information through timing
return crypto.timingSafeEqual(a, b);
}import base64, hashlib, hmac, json, os, time
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["SPICY_WEBHOOK_SECRET"]
@app.post("/hooks/spicy")
def hook():
# get_data() returns the raw bytes; request.json does not
raw = request.get_data()
if not verify(raw, request.headers, SECRET):
return "", 401
event = json.loads(raw)
version = request.headers["X-Webhook-Payload-Version"]
task = event if version == "1" else event["data"]
enqueue(task) # acknowledge first, work later
return "", 200
def verify(raw_body: bytes, headers, secret: str) -> bool:
ts = headers.get("X-Webhook-Timestamp")
sig = headers.get("X-Webhook-Signature")
version = headers.get("X-Webhook-Payload-Version")
if not ts or not sig or version not in ("1", "2"):
return False
# Replay protection: reject anything outside the tolerance window
try:
timestamp = int(ts)
event = json.loads(raw_body)
except (ValueError, TypeError, json.JSONDecodeError):
return False
if abs(time.time() - timestamp) > 300:
return False
# v1 keeps the task ID at top-level task_id; v2 uses data.taskId.
# Nothing in an unverified body can be trusted yet.
task_id = event.get("task_id") if version == "1" else event.get("data", {}).get("taskId")
if not isinstance(task_id, str) or not task_id:
return False
digest = hashlib.sha256(raw_body).hexdigest()
expected = base64.b64encode(
hmac.new(secret.encode(), f"{task_id}.{ts}.{digest}".encode(), hashlib.sha256).digest()
).decode()
# Constant time
return hmac.compare_digest(expected, sig)package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"io"
"math"
"net/http"
"os"
"strconv"
"time"
)
var secret = []byte(os.Getenv("SPICY_WEBHOOK_SECRET"))
// v1 keeps the task ID at top-level task_id; v2 uses data.taskId.
type event struct {
TaskID string `json:"task_id"`
Data struct {
TaskID string `json:"taskId"`
} `json:"data"`
}
func hook(w http.ResponseWriter, r *http.Request) {
// The raw bytes are required. Read them once, and never re-encode
// the parsed JSON: key order and escaping would not survive.
// The body contains the full input, so allow up to 10 MB.
rawBody, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 10<<20))
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
if !verify(rawBody, r.Header) {
w.WriteHeader(http.StatusUnauthorized)
return
}
var parsed event
if err := json.Unmarshal(rawBody, &parsed); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
taskID := parsed.Data.TaskID
if r.Header.Get("X-Webhook-Payload-Version") == "1" {
taskID = parsed.TaskID
}
// Acknowledge first, work later. Do not make us wait on your pipeline.
w.WriteHeader(http.StatusOK)
enqueue(taskID)
}
func verify(rawBody []byte, headers http.Header) bool {
ts := headers.Get("X-Webhook-Timestamp")
signature := headers.Get("X-Webhook-Signature")
version := headers.Get("X-Webhook-Payload-Version")
if ts == "" || signature == "" || (version != "1" && version != "2") {
return false
}
// Replay protection: reject anything outside the tolerance window,
// however valid its signature.
timestamp, err := strconv.ParseInt(ts, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-timestamp)) > 300 {
return false
}
// Nothing parsed out of an unverified body can be trusted yet.
var parsed event
if err := json.Unmarshal(rawBody, &parsed); err != nil {
return false
}
taskID := parsed.Data.TaskID
if version == "1" {
taskID = parsed.TaskID
}
if taskID == "" {
return false
}
digest := sha256.Sum256(rawBody)
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(taskID + "." + ts + "." + hex.EncodeToString(digest[:])))
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
// Constant time — a plain == leaks information through timing.
return hmac.Equal([]byte(expected), []byte(signature))
}
func enqueue(taskID string) {
// Your own queue. Return from the handler before doing any real work.
_ = taskID
}
func main() {
http.HandleFunc("/hooks/spicy", hook)
_ = http.ListenAndServe(":8080", nil)
}curl로 검증기 테스트
페이로드의 성공 v2를 payload.json으로 저장하고 수신기와 같은 SPICY_WEBHOOK_SECRET을 설정한 뒤 마지막 URL을 자신의 엔드포인트로 바꾸세요.
# Sign a payload file yourself and post it at your own endpoint. This is the
# only way to confirm your verification code actually passes before go-live.
BODY_FILE=payload.json
VERSION=2
TS=$(date +%s)
TASK_ID=$(jq -r --arg version "$VERSION" \
'if $version == "1" then .task_id else .data.taskId end' "$BODY_FILE")
DIGEST=$(openssl dgst -sha256 -hex "$BODY_FILE" | awk '{print $NF}')
SIG=$(printf '%s.%s.%s' "$TASK_ID" "$TS" "$DIGEST" \
| openssl dgst -sha256 -hmac "$SPICY_WEBHOOK_SECRET" -binary \
| base64)
curl -X POST https://your-app.example.com/hooks/spicy \
-H "Content-Type: application/json" \
-H "X-Webhook-Timestamp: $TS" \
-H "X-Webhook-Signature: $SIG" \
-H "X-Webhook-Payload-Version: $VERSION" \
--data-binary "@$BODY_FILE"2xx가 예상 결과입니다. SPICY_WEBHOOK_SECRET 한 글자를 바꿔 다시 실행하면 반드시 401이어야 합니다. 여전히 2xx면 서명을 검사하지 않습니다. 위 SDK 예시는 spicy_tasks에 없는 ID에 404를 반환하므로 예시 taskId를 먼저 삽입하거나 일반 검증 코드로 시험하세요.
계정 엔드포인트
계정에 URL과 원하는 이벤트 목록을 등록합니다. 이후 모든 일치 이벤트가 전달되어 작업마다 URL을 보낼 필요가 없습니다.
등록
API로 등록합니다.
curl -X POST 'https://api.spicyapi.ai/console/v1/webhook-endpoints' \
-H 'Content-Type: application/json' \
-d '{
"url": "https://your-app.example.com/hooks/spicy",
"enabledEvents": ["task.completed"],
"description": "production"
}'아직 콘솔 등록 화면은 없습니다
현재 엔드포인트는 API 전용입니다. 콘솔 → Webhook은 callBackUrl의 계정 공통 키와 전달 목록을 관리하지만 등록 페이지는 없습니다. 여기의 네 호출로 수행하며 sk-spicy- 키가 아닌 브라우저의 콘솔 세션 쿠키가 필요합니다.
응답의 secret은 whsec_… 형식이며 한 번만 표시됩니다. 이후 목록·상세에서도 반환하지 않습니다. 분실하면 교체해야 합니다.
계정당 16개까지 등록할 수 있습니다. 공개 HTTPS URL이며 저장 시와 전달 시 실제로 해석된 주소를 확인합니다.
구독 이벤트
| 이벤트 | 발생 시점 |
|---|---|
task.completed | 아래 네 개 전체. 꼭 구분할 필요가 없다면 선택 |
task.succeeded | 작업 완료 |
task.failed | 작업 실패. 보류 금액은 보통 해제되지만 모델 페이지에 예외가 명시되어 있으면 콘텐츠 거부에도 과금할 수 있음 |
task.canceled | 과거 기록 호환용. 현재 승인 작업은 취소 불가 |
task.expired | 기한 경과, 잔액 선점 해제 |
GET /console/v1/webhook-events가 런타임 목록을 반환하므로 하드코딩 없이 선택 UI를 만듭니다.
네 개 중 세 개를 나열하기보다 task.completed를 구독하세요
succeeded, failed, canceled만 적고 expired를 빠뜨리면 작업을 영원히 기다릴 수 있습니다. task.completed는 이런 누락을 방지합니다.
알 수 없는 이벤트는 조용히 무시하지 않고 저장 시 거절합니다. 오타는 등록할 때 발견해야지 6주 뒤 미수신으로 알아서는 안 됩니다. 같은 이유로 "*" 와일드카드도 받지 않습니다.
전달
POST /hooks/spicy HTTP/1.1
Host: your-app.example.com
Content-Type: application/json
User-Agent: SpicyAPI-Webhook/2
webhook-id: msg_01k3m8x9q2z4v7n5p6r8s0t1w3
webhook-timestamp: 1787045531
webhook-signature: v1,9tXk…{
"id": "msg_01k3m8x9q2z4v7n5p6r8s0t1w3",
"type": "task.succeeded",
"created": 1787045531,
"api_version": "2026-09-20",
"data": {
"object": {
"taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
"state": "succeeded",
"settled": true,
"cost": "0.0300"
}
}
}data.object는 recordInfo와 같은 작업 기록이지만 현재 상태가 아니라 이벤트 생성 시 사본입니다. 3일 뒤 처리해도 그 시점 값입니다.
settled가 최종 비용 여부를 알려 줍니다
정산 지연 시 과금 확정 전에 작업이 종료되어 settled: false와 선점액인 cost로 발송됩니다. 수정 이벤트는 오지 않습니다. 정확한 금액이 필요하면 settled가 false일 때 recordInfo를 읽으세요.
검증
signature = "v1," + base64( HMAC-SHA256( key, "<webhook-id>.<webhook-timestamp>.<raw_body>" ) )Standard Webhooks 규격이므로 Stripe·Replicate용 라이브러리에서 헤더명만 바꿀 수 있습니다. 위 callBackUrl 서명과 다른 두 부분을 틀리면 오류 없이 서명이 계속 실패합니다.
- 해시가 아니라 본문 자체를 서명합니다. 내부
sha256단계가 없습니다. - HMAC 키는
whsec_뒤를 Base64 디코딩한 바이트이지 문자열이 아닙니다. 문자열 사용이 가장 흔한 실수이며 예외 없이 항상 잘못된 서명입니다.
import crypto from 'node:crypto';
// req.body must be the raw bytes, not a parsed object
function verify(req, secret) {
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const id = req.headers['webhook-id'];
const ts = req.headers['webhook-timestamp'];
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected =
'v1,' +
crypto.createHmac('sha256', key).update(`${id}.${ts}.`).update(req.body).digest('base64');
// The header may hold several space-separated signatures during a rotation
return String(req.headers['webhook-signature'])
.split(' ')
.some((got) => got.length === expected.length && crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected)));
}webhook-id는 같은 메시지 재시도에서도 유지되므로 멱등성 키로 사용하세요. webhook-timestamp는 중복이 아닌 재전송 공격을 방지하므로 시도마다 갱신됩니다.
중단 없는 비밀키 교체
curl -X POST 'https://api.spicyapi.ai/console/v1/webhook-endpoints/whe_…/rotate-secret' \
-H 'Content-Type: application/json' \
-d '{"gracePeriodSeconds": 86400}'유예 기간에는 한 헤더에 공백으로 구분된 두 서명이 오며 하나만 맞아도 충분합니다. 위 .some(…)의 목적입니다. 두 서명을 받는 수신기 배포 → 교체 → 새 비밀키 적용 → 기간 종료 순서면 중단이 없습니다.
기본 24시간, 최대 7일입니다. 본문을 생략하면 기본값입니다.
엔드포인트 장애
아래 일정으로 재시도하며 추가로:
410 Gone이면 즉시 비활성화하고 대기 메시지를 제거합니다. 소유자가 없다고 알린 주소에 반복하는 것은 도움이 되지 않습니다.GET /console/v1/webhook-endpoints/{id}는status: "disabled",disabledReason: "gone_410"을 반환합니다.- 다른 실패는
consecutiveFailures를 증가시키고 같은 호출에서 조회합니다. 현재 자동 비활성화하지 않습니다.
장애 엔드포인트도 구독을 유지합니다
수일 지속 실패 후 자동 비활성화는 아직 구현되지 않았습니다. 410 외 응답의 장애 주소도 이벤트를 계속 받고 각 재시도 횟수를 소진합니다. 직접 consecutiveFailures를 확인하고 수리 동안 PATCH의 {"enabled": false}로 중지하세요.
balance.low, webhook.endpoint.disabled도 아직 제공하지 않아 위 표에 없습니다.
다시 활성화하면 실패 카운터가 초기화되어 수리 후 이전 실패를 이어받지 않습니다.
삭제
삭제는 미래 전달을 중지합니다. 이미 대기한 메시지는 내역을 유지하여 결과를 볼 수 있지만 엔드포인트가 없어 서명할 수 없으므로 로그에서 사라지지 않고 실패로 끝납니다.
응답 방법
모든 2xx는 수신 완료입니다. 다른 상태, 연결 실패나 15초 초과는 실패이며 재시도합니다.
처리 전에 수신을 확인하세요. 자체 큐에 작업 ID를 넣고 반환하세요. 콜백 안에서 다운로드·인코딩·알림까지 하면 시간 초과로 다시 전달되어 이미 일부 처리한 일을 반복하게 됩니다.
재시도 일정
| 재시도 | 이전 시도 후 지연 |
|---|---|
| 1 | 10초 |
| 2 | 30초 |
| 3 | 2분 |
| 4 | 10분 |
| 5 | 30분 |
| 6 | 2시간 |
| 7 | 6시간 |
첫 전달 포함 자동 시도는 8회입니다. 지연 합계는 8시간 42분 40초이며 요청 시간·스케줄링으로 더 길어질 수 있습니다. 소진되면 자동 재시도 중단이며 콘솔 → Webhook → 전달 내역에 실패로 표시합니다. 재시도로 다시 대기열에 넣을 수 있습니다(분당 최대 6회 수동 재시도). 자동 재시도 중, 이미 전달 완료, 보관 설정으로 콘텐츠가 제거된 전달은 수동 재전송할 수 없으므로 recordInfo를 사용하세요.
재시도마다 다시 서명합니다
타임스탬프는 최초가 아닌 이번 전달 시점입니다. 그렇지 않으면 6시간 뒤 재시도가 6시간 전 시각을 가져 올바른 재전송 방지 검증에 걸려 가장 필요할 때 작동하지 않습니다.
따라서 전달 간 타임스탬프를 캐시·비교하지 마세요. 각각 현재 시각으로 검증합니다.
멱등성
같은 작업 콜백이 재시도·네트워크 중복·수동 재전달로 여러 번 올 수 있습니다. 정확히 한 번이라고 가정하지 마세요.
v2는 응답 request_id(whd_…)로 중복 제거합니다. 전달 기록을 식별하고 재시도에도 유지되어 멱등성 키에 적합합니다. DB 고유 인덱스가 가장 간단하며 충돌이면 2xx를 반환하고 넘어갑니다.
v1은 request_id가 없습니다. task_id, 상태와 원본 본문 해시로 키를 만드세요. 작업 ID만 사용하면 미래에 같은 작업의 별도 종료 기록을 억제할 수 있습니다.
콜백 URL 제약
https만 허용.http://는 프롬프트·서명 링크를 담으므로 거절합니다.- 명시 포트는
443·80만 허용.https://기본 포트는 가능합니다. - URL에 사용자 이름·비밀번호를 넣지 마세요.
- 호스트를 해석할 수 있어야 하며 모든 해석 주소가 공개여야 합니다. 루프백·사설망·링크 로컬·클라우드 메타데이터 주소는 거절합니다.
- DNS 재바인딩을 막도록 연결 시에도 IP를 재확인합니다.
생성·스트리밍·견적의 잘못된 callBackUrl은 HTTP 400, 비즈니스 code: 400, msg: "Invalid callback URL"과 request_id입니다. URL·호스트·IP·DNS 진단은 되돌려 주지 않습니다.
두 확인은 자체 서버가 사설망의 경유 서버로 악용되지 않도록 합니다. 따라서 로컬 개발의 http://localhost:3000은 작동하지 않습니다. ngrok·Cloudflare Tunnel 등 공개 HTTPS 터널이나 로컬 폴링을 사용하세요.

