본문으로 건너뛰기

API 문서 · 작업

Webhook 및 서명 검증

이벤트 형식, X-Signature 서명 검증 및 전송 재시도.

작업을 만들 때 callback_url을 전달하면, 작업이 최종 상태가 되었을 때 플랫폼이 task.succeeded 또는 task.failed 이벤트를 POST로 보냅니다. 이벤트의 data는 조회 엔드포인트와 동일한 전체 작업 객체입니다.

서명 시크릿

콘솔 · 계정 설정에서 Webhook 서명 시크릿을 생성하세요. 시크릿은 한 번만 표시됩니다. 교체 후에는 기존 시크릿과 새 시크릿이 24시간 동안 함께 유효하므로 중단 없이 전환할 수 있습니다.

서명 검증

서명 시크릿을 생성하면 모든 콜백 요청에 서명 헤더가 포함됩니다. 시크릿을 생성하지 않으면 콜백에 서명이 포함되지 않으므로, 프로덕션 환경에서는 반드시 시크릿을 생성하고 서명을 검증하세요.

X-Signature: t=<unix 초>,v1=<hex>

여기서 v1 = hex(HMAC_SHA256(secret, t + "." + 원본 요청 본문))입니다. 검증 절차는 다음과 같습니다.

  1. 헤더에서 t와 모든 v1을 파싱합니다. 시크릿 교체 후 24시간 동안은 새 시크릿과 기존 시크릿의 v1이 함께 포함되며, 하나라도 일치하면 통과입니다.
  2. 파싱하지 않은 원본 요청 본문으로 t + "." + body를 만들고 HMAC-SHA256을 계산합니다.
  3. 상수 시간 비교로 각 v1과 대조합니다.
  4. 재전송 공격을 막기 위해 t와 현재 시각의 차이가 5분 이내인지 확인합니다.

전송과 재시도

  • 2xx 응답이면 전송 성공으로 간주합니다. 요청당 타임아웃은 10초이므로 빠르게 응답하고, 오래 걸리는 처리는 큐에 넣어 비동기로 진행하세요. 리디렉션(3xx)은 따라가지 않으며 실패로 간주합니다.
  • 첫 전송에 실패하면(2xx가 아니거나 타임아웃) 1분, 5분, 30분, 2시간, 12시간 후에 재시도하고 그 이후에는 중단합니다. 작업 결과에는 영향이 없으며 계속 조회할 수 있습니다.
  • callback_url은 공개적으로 접근 가능한 http / https 주소여야 합니다.
  • 같은 이벤트가 여러 번 전송될 수 있으므로 id(evt_…)로 중복을 제거하세요.
  • 이벤트는 순서가 바뀌어 도착할 수 있습니다. 작업 객체의 상태를 기준으로 판단하세요.
import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.AITOKEN_WEBHOOK_SECRET;

// 반드시 원본 요청 본문으로 서명을 검증하세요. JSON.parse 후 다시 직렬화하면 안 됩니다
app.post("/hooks/video", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("X-Signature") ?? ""; // t=1730259000,v1=5f2c...[,v1=이전 키 서명]
  const pairs = header.split(",").map((kv) => kv.split("="));
  const t = Number(pairs.find(([k]) => k === "t")?.[1]);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return res.status(400).end(); // 5분 시간 창

  const expected = Buffer.from(
    crypto.createHmac("sha256", SECRET).update(`${t}.${req.body.toString("utf8")}`).digest("hex"),
  );
  // 키 교체 후 24시간 동안은 v1이 두 개(새 키, 이전 키) 붙으며, 하나만 일치해도 통과
  const ok = pairs
    .filter(([k, v]) => k === "v1" && v?.length === expected.length)
    .some(([, v]) => crypto.timingSafeEqual(Buffer.from(v), expected));
  if (!ok) return res.status(400).end();

  const event = JSON.parse(req.body.toString("utf8"));
  // event.id 기준으로 중복 제거, 2xx를 빨리 반환하고 오래 걸리는 처리는 큐로
  res.status(204).end();
});