PopNowAPI 문서
API로 만들기
본문

웹훅

완료된 작업을 내 URL로 받고, 서명을 검증하고, 재시도와 중복을 처리하는 방법.

웹훅을 쓰면 작업이 끝났을 때 PopNow가 알려 주므로 계속 물어볼 필요가 없습니다. 종류는 두 가지입니다.

작업 콜백계정 엔드포인트
설정createTask마다 callBackUrl 지정API 콘솔에서 한 번 등록
보내는 대상그 작업 하나고른 이벤트에 맞는 계정의 모든 작업
서명X-Webhook-Signature(아래 참고)Standard Webhooks 헤더(아래 참고)

대부분의 연동은 작업 콜백만 있으면 됩니다.

1. 서명 시크릿 준비

모든 전송은 계정의 웹훅 서명 시크릿으로 서명됩니다. API 콘솔의 Webhooks 페이지에서 시크릿을 생성(또는 교체)하고 서버에 POPNOW_WEBHOOK_SECRET으로 저장하세요. 한 번만 보여 줍니다. 교체는 재시도 중인 전송까지 포함해 즉시 적용되니, 바로 받는 쪽을 업데이트하세요.

2. callBackUrl 넘기기

{
  "model": "alibaba/wan-3.0/image-to-video",
  "input": { "image_url": "https://media.popnow.ai/movie/lighthouse.webp" },
  "callBackUrl": "https://your-app.example.com/hooks/popnow"
}

URL은 공개 HTTPS여야 하고 포트는 443(또는 생략), 사용자 이름이나 비밀번호가 없어야 하며, 공개 주소로만 해석돼야 합니다. 작업을 만들 때와 연결할 때 두 번 확인합니다. http://localhost로는 받을 수 없으니 개발 중에는 Cloudflare Tunnel이나 ngrok 같은 터널을 쓰거나 폴링하세요. 잘못된 URL은 400(msg: "Invalid callback URL")을 돌려줍니다.

3. 도착하는 내용

작업이 종료 상태가 되면 PopNow가 POST를 한 번 보냅니다.

POST /hooks/popnow HTTP/1.1
Content-Type: application/json
User-Agent: PopNow-Webhook/1
X-Webhook-Timestamp: 1787045531
X-Webhook-Signature: 9tXk3…=
X-Webhook-Payload-Version: 2

본문은 그 작업에 대해 recordInfo가 돌려주는 내용과 똑같습니다.

{
  "code": 200,
  "msg": "success",
  "data": {
    "taskId": "job_01JH8Z4Q5W2N7R3K9M6P1C0VXD",
    "model": "alibaba/wan-3.0/image-to-video",
    "state": "succeeded",
    "output": { "assets": [{ "url": "https://…", "mime": "video/mp4", "durationSeconds": 5 }] },
    "credits": 34,
    "cost": "0.34",
    "settled": true
  },
  "request_id": "whd_01JH8Z…"
}
  • 작업이 실패해도 code는 200입니다. 이 값은 전송에 대한 것이지 작업 결과가 아닙니다. data.state를 보세요.
  • request_id는 전송 ID입니다. 재시도해도 바뀌지 않으니 중복을 거르는 데 쓰세요.
  • output의 서명 링크는 약 20분 뒤 만료됩니다. 링크가 아니라 taskId를 저장하세요.

4. 서명 검증

signature = base64( HMAC-SHA256( secret, "<taskId>.<timestamp>.<sha256_hex(raw_body)>" ) )

taskId는 본문의 data.taskId, timestamp는 X-Webhook-Timestamp 헤더, 마지막 부분은 받은 그대로의 본문을 SHA-256으로 해시한 소문자 16진수입니다. 받은 바이트를 그대로 해시하세요. 파싱했다가 다시 직렬화하면 키 순서와 공백이 바뀌어 결과가 달라집니다.

Node.js (Express)
import crypto from 'node:crypto';
import express from 'express';

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

// express.raw keeps the exact bytes; express.json would throw them away.
app.post('/hooks/popnow', express.raw({ type: 'application/json', limit: '10mb' }), (req, res) => {
  const ts = Number(req.headers['x-webhook-timestamp']);
  const sig = String(req.headers['x-webhook-signature'] ?? '');
  // Refuse anything older than five minutes, however valid its signature.
  if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return res.status(401).end();

  let event;
  try { event = JSON.parse(req.body.toString('utf8')); } catch { return res.status(400).end(); }
  const digest = crypto.createHash('sha256').update(req.body).digest('hex');
  const expected = crypto.createHmac('sha256', SECRET)
    .update(`${event.data?.taskId}.${ts}.${digest}`).digest('base64');
  const ok = expected.length === sig.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
  if (!ok) return res.status(401).end();

  res.status(200).end();          // acknowledge first…
  enqueue(event.request_id, event.data); // …then do the work
});
Python (Flask)
import hashlib, hmac, base64, json, os, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["POPNOW_WEBHOOK_SECRET"].encode()

@app.post("/hooks/popnow")
def popnow_hook():
    raw = request.get_data()  # the exact bytes
    ts = request.headers.get("X-Webhook-Timestamp", "")
    sig = request.headers.get("X-Webhook-Signature", "")
    if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
        abort(401)
    event = json.loads(raw)
    digest = hashlib.sha256(raw).hexdigest()
    message = f'{event["data"]["taskId"]}.{ts}.{digest}'.encode()
    expected = base64.b64encode(hmac.new(SECRET, message, hashlib.sha256).digest()).decode()
    if not hmac.compare_digest(expected, sig):
        abort(401)
    enqueue(event["request_id"], event["data"])  # do the slow work elsewhere
    return "", 200

새 연동에서 X-Webhook-Payload-Version은 항상 2입니다. 버전 1은 기존 수신 측을 위해 남겨 둔 예전 형식이며, 혹시 받는다면 작업 ID는 최상위 task_id에 있습니다.

빨리 응답하기

15초 안에 2xx를 돌려주면 받은 것으로 봅니다. 그 밖의 경우(다른 상태 코드, 타임아웃, 연결 거부)는 재시도합니다. 먼저 받았다고 응답하고, 오래 걸리는 일(다운로드, 변환, 사용자 알림)은 백그라운드 작업으로 처리하세요.

재시도와 중복

전송에 실패하면 10초, 30초, 2분, 10분, 30분, 2시간, 6시간 간격으로 7번 더 시도합니다. 재시도마다 새 타임스탬프로 다시 서명하므로, 타임스탬프는 첫 시도가 아니라 항상 현재 시각과 비교하세요. 마지막 재시도까지 실패하면 그 전송은 API 콘솔에 실패로 표시되고, 거기서 직접 다시 보낼 수 있습니다.

그래서 같은 작업이 여러 번 도착할 수 있습니다. 처리 로직을 멱등하게 만드세요. request_id에 고유 인덱스를 두고, 이미 본 값이면 아무것도 하지 않고 200을 돌려주면 됩니다.

서버가 재시도 기간보다 오래 멈춰 있었다면 GET /api/v1/jobs로 최근 작업을 나열하고 놓친 작업을 recordInfo로 읽으세요.

계정 엔드포인트

계정 엔드포인트는 받고 싶은 이벤트와 함께 API 콘솔에 한 번 등록하는 URL입니다. 계정의 모든 작업 이벤트가 오므로 callBackUrl을 매번 넘기지 않아도 됩니다.

이벤트보내는 때
task.completed아래 네 가지 중 하나. 구분할 필요가 없다면 이것을 구독하세요
task.succeeded작업이 성공함
task.failed작업이 실패함. 잡아 둔 크레딧은 해제됨
task.expired작업이 마감 시간을 넘김. 잡아 둔 크레딧은 해제됨
task.canceled예전 기록을 위해서만 남아 있음

전송은 Standard Webhooks 형식을 따르므로 기존 라이브러리를 그대로 쓸 수 있습니다.

webhook-id: msg_01k3m8x9q2z4v7n5p6r8s0t1w3
webhook-timestamp: 1787045531
webhook-signature: v1,base64( HMAC-SHA256( key, "<webhook-id>.<webhook-timestamp>.<raw_body>" ) )

작업 콜백과 다른 점은 두 가지입니다. 본문 자체에 서명하고(SHA-256 단계가 없음), HMAC 키는 시크릿에서 whsec_ 뒤 부분을 Base64 디코딩한 바이트이지 문자열 자체가 아닙니다. 시크릿을 교체하는 동안에는 헤더에 공백으로 구분된 서명이 두 개 있을 수 있으며, 둘 중 하나만 맞아도 받아들입니다. webhook-id는 재시도에도 바뀌지 않으니 중복을 거르는 데 쓰세요. 이벤트의 data.object는 이벤트가 만들어진 시점의 작업 기록입니다.

이 페이지에서