웹훅

비동기 생성 작업이 끝나는 순간 알림 받기 — 폴링 없이

개요

비동기 생성 작업을 제출하면 Atlas Cloud는 이를 백그라운드에서 처리하며, 결과는 얼마 뒤에 사용할 수 있게 됩니다. 작업이 끝날 때까지 예측 엔드포인트를 반복 호출하는 대신, 작업이 종료 상태에 도달하는 순간 Atlas Cloud가 여러분에게 콜백을 보내도록 요청할 수 있습니다.

작업을 제출할 때 webhook_url을 함께 전달하면 됩니다. 작업이 성공했든, 실패했든, 타임아웃되었든 완료되면 Atlas Cloud는 최종 결과가 담긴 서명된 POST 요청을 해당 URL로 한 번 전송합니다.

지원되는 작업 유형

웹훅은 비동기 동영상, 이미지, 오디오 생성에 사용할 수 있습니다. 전송 엔진은 작업 유형에 구애받지 않으며, event_type(및 X-AtlasCloud-Webhook-Event 헤더)이 모달리티를 식별합니다: video.task.terminal, image.task.terminal, audio.task.terminal.

웹훅은 폴링을 보완하는 수단이며, 폴링을 대체하지 않습니다. 예측 엔드포인트는 이전과 완전히 동일하게 동작하고, 웹훅 페이로드는 폴링으로 받았을 결과와 같은 형태를 담고 있습니다. 둘 중 하나만 쓰셔도 되고, 둘 다 쓰셔도 됩니다.

빠른 시작

기존 제출 요청에 webhook_url 필드를 추가하세요:

curl -X POST https://api.atlascloud.ai/api/v1/model/generateVideo \
  -H "Authorization: Bearer your-api-key" \
  -H "Content-Type: application/json" \
  -d '{
        "model": "bytedance/seedance-2.0/text-to-video",
        "prompt": "A calico kitten chasing a butterfly in a garden, cinematic",
        "duration": 5,
        "resolution": "1080p",
        "webhook_url": "https://your-app.example.com/hooks/atlascloud"
      }'
import requests

response = requests.post(
    "https://api.atlascloud.ai/api/v1/model/generateVideo",
    headers={
        "Authorization": "Bearer your-api-key",
        "Content-Type": "application/json",
    },
    json={
        "model": "bytedance/seedance-2.0/text-to-video",
        "prompt": "A calico kitten chasing a butterfly in a garden, cinematic",
        "duration": 5,
        "resolution": "1080p",
        "webhook_url": "https://your-app.example.com/hooks/atlascloud",
    },
)

print(response.json()["data"]["id"])  # 작업 id(session_id)
const res = await fetch("https://api.atlascloud.ai/api/v1/model/generateVideo", {
  method: "POST",
  headers: {
    Authorization: "Bearer your-api-key",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    model: "bytedance/seedance-2.0/text-to-video",
    prompt: "A calico kitten chasing a butterfly in a garden, cinematic",
    duration: 5,
    resolution: "1080p",
    webhook_url: "https://your-app.example.com/hooks/atlascloud",
  }),
});

const { data } = await res.json();
console.log(data.id); // 작업 id(session_id)

동일한 webhook_url 필드가 POST /api/v1/model/generateImage(비동기 이미지, event_type: "image.task.terminal")와 POST /api/v1/model/generateAudio(비동기 오디오, event_type: "audio.task.terminal")에서도 똑같이 동작합니다.

제출 응답은 달라지지 않습니다 — 여전히 작업 id(즉 session_id)를 즉시 돌려받습니다. webhook_urlAtlas Cloud가 소비하며, 상위 모델 제공자에게 절대 전달되지 않습니다. 작업이 완료되면 여러분의 엔드포인트가 POST 요청을 받게 됩니다.

webhook_url 요구 사항

콜백 URL은 제출 시점에 검증됩니다. 검증에 실패하면 제출 요청이 HTTP 400으로 거부되고 작업이 생성되지 않습니다(과금되지 않습니다).

규칙상세
스킴반드시 https://여야 합니다. 일반 http://는 거부됩니다.
호스트라우팅 가능한 공인 주소여야 합니다. 사설, 루프백, 링크 로컬, CGNAT(100.64.0.0/10) 대역은 거부됩니다.
길이최대 1024자.
도달 가능성Atlas Cloud가 POST를 보낼 수 있도록 공용 인터넷에서 도달 가능해야 합니다.

이 검사는 SSRF 방어를 위한 것입니다. 로컬 개발 환경에서는 사설 주소 대신 공개 터널(웹훅 테스트 서비스, ngrok, Cloudflare 터널 등)을 사용하세요.

콜백 요청

작업이 종료 상태에 도달하면 Atlas Cloud는 Content-Type: application/json과 다음 헤더를 담아 POST를 전송합니다:

헤더설명
X-AtlasCloud-Webhook-Id작업의 session_id — 상관관계 및 멱등성 키입니다.
X-AtlasCloud-Webhook-Event이벤트 유형. 예: video.task.terminal, image.task.terminal, audio.task.terminal.
X-AtlasCloud-Webhook-Timestamp전송을 시도한 시각의 Unix epoch . Ed25519 서명에 포함됩니다.
X-AtlasCloud-Webhook-Signature원본 요청 본문의 16진수 인코딩 HMAC-SHA256(HMAC 방식). 순수 Ed25519 방식에서는 대신 Ed25519 서명이 담깁니다 — 서명 검증을 참고하세요.
X-AtlasCloud-Webhook-Signature-Ed25519<timestamp>.<raw_body>에 대한 base64url Ed25519 서명(HMAC→Ed25519 마이그레이션 기간 동안 전송됩니다).
X-AtlasCloud-Webhook-Key-IdEd25519 서명 키의 kidJWKS에 있는 키와 일치합니다.
User-AgentAtlasCloud-Webhook/1.0

페이로드

{
  "session_id": "string",       // 작업 id, 멱등성 키
  "event_type": "string",       // 예: "video.task.terminal"
  "status": "OK" | "ERROR",     // 최상위 결과 — 이 값으로 분기
  "created_at": 1782295062952,  // 작업 생성 시각(ms epoch)
  "payload": {                  // 결과, Predictions API와 동일한 형태
    "model": "string",
    "status": "completed" | "failed" | "timeout",
    "outputs": ["https://..."], // 성공 시 존재
    "error_code": 0             // 실패 시 존재
  },
  "error": "string"             // status == "ERROR"일 때만 존재
}

핸들러는 최상위 status 필드로 분기하세요. OK는 사용 가능한 결과가 payload.outputs에 있다는 뜻이고, ERROR는 작업이 결과를 만들어내지 못했으며 error가 그 이유를 설명한다는 뜻입니다.

오디오 전사 결과

텍스트 음성 변환을 비롯한 생성형 오디오 작업은 동영상, 이미지와 똑같이 payload.outputs에 오디오 파일 URL을 반환합니다. 음성 인식(전사) 모델의 경우, 완료된 payload에는 구조화된 stt_result 객체(전체 텍스트, 감지된 언어, 단어 단위 타임스탬프)가 추가로 담깁니다 — 예측 엔드포인트가 반환하는 것과 동일한 필드입니다.

성공 예시

완료된 bytedance/seedance-2.0/text-to-video 작업의 실제 전송 내용입니다:

{
  "session_id": "6a0c02cdb4b147b7bc78881eb7229ece",
  "event_type": "video.task.terminal",
  "status": "OK",
  "created_at": 1782295062952,
  "payload": {
    "model": "bytedance/seedance-2.0/text-to-video",
    "status": "completed",
    "outputs": [
      "https://atlas-media.oss-us-west-1.aliyuncs.com/videos/cgt-20260624-0.mp4"
    ]
  }
}

실패 예시

{
  "session_id": "9b2f4e7a1c0d4f5e8a6b3c2d1e0f9a8b",
  "event_type": "video.task.terminal",
  "status": "ERROR",
  "created_at": 1782200000000,
  "payload": {
    "model": "bytedance/seedance-2.0/text-to-video",
    "status": "failed",
    "error_code": 1039
  },
  "error": "the input was rejected by content moderation"
}

timeout 결과도 동일한 형태이며, payload.status: "timeout"과 일반적인 error 메시지가 담깁니다.

서명 검증

웹훅을 신뢰하기 전에 반드시 서명을 검증하세요. 서명은 그 요청이 Atlas Cloud에서 왔고 변조되지 않았음을 증명합니다.

마이그레이션 기간의 두 가지 방식

Atlas Cloud는 웹훅 서명을 공유 HMAC 시크릿 방식에서 공개 JWKS 엔드포인트를 사용하는 Ed25519 방식으로 전환하고 있습니다. 전환 기간 동안 전송에는 HMAC 서명(X-AtlasCloud-Webhook-Signature)과 Ed25519 서명(X-AtlasCloud-Webhook-Signature-Ed25519)이 모두 담깁니다. Ed25519를 우선 사용하세요 — URL에서 가져온 공개 키로 검증하므로 저장해야 할 공유 시크릿이 없습니다.

Ed25519 + JWKS(권장)

Atlas Cloud는 각 전송을 Ed25519 개인 키로 서명하고, 대응하는 공개 키를 JWKS 엔드포인트에 게시합니다. 여러분은 그 공개 키로 검증하면 되며, 직접 발급하거나 교체해야 할 시크릿이 없습니다.

  • 공개 키(JWKS): GET https://api.atlascloud.ai/api/v1/webhooks/jwks.json(인증 불필요):
{
  "keys": [
    {
      "kty": "OKP",
      "crv": "Ed25519",
      "x": "Wn_rgZDBO6nv4-ka97PPf5z8WXbM25o75dR6YukTqqI",
      "use": "sig",
      "alg": "EdDSA",
      "kid": "cnut8IN2blKoB5zRJr9th0HoLzH-iBH3WYoUtkcJZQE"
    }
  ]
}
  • 서명 대상 메시지: "<timestamp>.<raw_body>"X-AtlasCloud-Webhook-Timestamp 값, 리터럴 ., 그리고 정확한 원본 요청 본문입니다. HMAC과 달리 타임스탬프도 서명에 포함되므로 재생(replay) 허용 시간을 강제할 수 있습니다.
  • 서명 헤더: X-AtlasCloud-Webhook-Signature-Ed25519(base64url). HMAC이 폐지되면 Ed25519 서명이 X-AtlasCloud-Webhook-Signature로 옮겨가므로, -Ed25519 헤더가 있으면 이를 우선 사용하고 없으면 -Signature로 폴백하세요.
  • 키 id: X-AtlasCloud-Webhook-Key-Id는 해당 전송을 서명한 JWK의 kid입니다.

단계

  1. X-AtlasCloud-Webhook-Timestamp(ts), X-AtlasCloud-Webhook-Key-Id(kid), 그리고 Ed25519 서명 헤더를 읽습니다.
  2. (권장) 타임스탬프가 여러분 시계 기준으로 약 5분 이상 차이 나면 거부합니다(재생 공격 방지).
  3. JWKS를 가져와 kid가 일치하는 키를 선택합니다. JWKS는 캐시하되, 모르는 kid를 만나면 한 번 다시 가져오세요(서명 키가 교체되었을 수 있습니다).
  4. JWK의 x(base64url)를 디코딩해 32바이트 Ed25519 공개 키를 얻습니다.
  5. base64url 디코딩한 서명을 ts + "." + raw_body에 대해 검증합니다.
const crypto = require("crypto");

const JWKS_URL = "https://api.atlascloud.ai/api/v1/webhooks/jwks.json";
let jwks = {}; // kid -> jwk 매핑

async function publicKey(kid) {
  if (!jwks[kid]) {
    const { keys } = await (await fetch(JWKS_URL)).json();
    jwks = Object.fromEntries(keys.map((k) => [k.kid, k]));
  }
  const jwk = jwks[kid];
  return jwk && crypto.createPublicKey({ key: jwk, format: "jwk" });
}

// req.body는 반드시 원본 본문 Buffer여야 합니다(예: express.raw()).
async function verifyEd25519(req) {
  const ts = req.get("X-AtlasCloud-Webhook-Timestamp");
  const kid = req.get("X-AtlasCloud-Webhook-Key-Id");
  const sig =
    req.get("X-AtlasCloud-Webhook-Signature-Ed25519") ||
    req.get("X-AtlasCloud-Webhook-Signature");

  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // 재생 허용 시간

  const pub = await publicKey(kid);
  if (!pub) return false;
  const msg = Buffer.concat([Buffer.from(`${ts}.`), req.body]);
  return crypto.verify(null, msg, pub, Buffer.from(sig, "base64url"));
}
import time, json, base64, urllib.request
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.exceptions import InvalidSignature

JWKS_URL = "https://api.atlascloud.ai/api/v1/webhooks/jwks.json"
_jwks = {}  # kid -> jwk 매핑

def _b64u(s: str) -> bytes:
    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))

def _public_key(kid: str):
    if kid not in _jwks:
        keys = json.load(urllib.request.urlopen(JWKS_URL, timeout=5))["keys"]
        _jwks.clear()
        _jwks.update({k["kid"]: k for k in keys})
    jwk = _jwks.get(kid)
    return Ed25519PublicKey.from_public_bytes(_b64u(jwk["x"])) if jwk else None

def verify_ed25519(headers, raw_body: bytes) -> bool:
    ts = headers["X-AtlasCloud-Webhook-Timestamp"]
    kid = headers["X-AtlasCloud-Webhook-Key-Id"]
    sig = headers.get("X-AtlasCloud-Webhook-Signature-Ed25519") \
        or headers["X-AtlasCloud-Webhook-Signature"]

    if abs(time.time() - int(ts)) > 300:  # 재생 허용 시간
        return False
    pub = _public_key(kid)
    if pub is None:
        return False
    try:
        pub.verify(_b64u(sig), ts.encode() + b"." + raw_body)
        return True
    except InvalidSignature:
        return False
const jwksURL = "https://api.atlascloud.ai/api/v1/webhooks/jwks.json"

// fetchKey는 kid에 해당하는 base64url 공개 키를 반환합니다. 실제 코드에서는
// 캐싱과 미스 시 1회 재조회(키 교체 대응)를 추가하세요.
func fetchKey(kid string) (string, bool) {
    resp, err := http.Get(jwksURL)
    if err != nil {
        return "", false
    }
    defer resp.Body.Close()
    var set struct {
        Keys []struct{ Kid, X string } `json:"keys"`
    }
    if json.NewDecoder(resp.Body).Decode(&set) != nil {
        return "", false
    }
    for _, k := range set.Keys {
        if k.Kid == kid {
            return k.X, true
        }
    }
    return "", false
}

func verifyEd25519(h http.Header, body []byte) bool {
    ts := h.Get("X-AtlasCloud-Webhook-Timestamp")
    sigB64 := h.Get("X-AtlasCloud-Webhook-Signature-Ed25519")
    if sigB64 == "" {
        sigB64 = h.Get("X-AtlasCloud-Webhook-Signature")
    }
    t, _ := strconv.ParseInt(ts, 10, 64)
    if math.Abs(float64(time.Now().Unix()-t)) > 300 { // 재생 허용 시간
        return false
    }
    xB64, ok := fetchKey(h.Get("X-AtlasCloud-Webhook-Key-Id"))
    if !ok {
        return false
    }
    pub, e1 := base64.RawURLEncoding.DecodeString(xB64)
    sig, e2 := base64.RawURLEncoding.DecodeString(sigB64)
    if e1 != nil || e2 != nil || len(pub) != ed25519.PublicKeySize {
        return false
    }
    return ed25519.Verify(ed25519.PublicKey(pub), append([]byte(ts+"."), body...), sig)
}

서명은 콜백이 Atlas Cloud에서 왔다는 사실을 증명할 뿐, 해당 작업이 어느 계정의 것인지는 증명하지 않습니다. webhook_url은 요청마다 지정되므로, 결과를 처리하기 전에 session_id가 실제로 여러분이 생성한 작업의 것인지도 함께 확인하세요.

HMAC(레거시)

HMAC 서명은 위의 Ed25519/JWKS 방식으로 대체되며 폐지될 예정입니다. 신규 연동은 Ed25519를 사용하세요. 마이그레이션 기간 동안에는 HMAC X-AtlasCloud-Webhook-Signature도 계속 전송됩니다.

서명은 다음과 같이 계산됩니다:

HMAC-SHA256( signing_secret, raw_request_body )   →   lowercase hex
  • 키는 여러분의 공유 서명 시크릿입니다(계정에 발급됨).
  • 메시지는 요청 본문의 정확한 원본 바이트입니다 — JSON을 다시 직렬화하면 바이트 순서나 공백이 바뀔 수 있으므로 재직렬화 이전에 검증하세요.
  • 계산 결과를 X-AtlasCloud-Webhook-Signature 헤더와 상수 시간 비교로 대조하세요.

X-AtlasCloud-Webhook-Timestamp 헤더는 참고용입니다(선택적인 재생 허용 시간 검사에 유용합니다). 이 헤더는 서명 대상에 포함되지 않으며, 원본 본문만 서명됩니다.

const crypto = require("crypto");
const express = require("express");
const app = express();

const SIGNING_SECRET = process.env.ATLASCLOUD_WEBHOOK_SECRET;

// 원본 본문을 확보하세요 — JSON 파서가 먼저 실행되지 않도록 합니다.
app.use("/hooks/atlascloud", express.raw({ type: "*/*" }));

app.post("/hooks/atlascloud", (req, res) => {
  const sig = req.get("X-AtlasCloud-Webhook-Signature") || "";
  const expected = crypto
    .createHmac("sha256", SIGNING_SECRET)
    .update(req.body)               // req.body는 Buffer(원본 바이트)입니다
    .digest("hex");

  // Buffer로 비교하고 바이트 길이를 확인하세요. timingSafeEqual은 길이가 다르면
  // 예외를 던지며, 잘못된 멀티바이트 헤더는 JS 문자열 길이는 같아도
  // 바이트 길이가 다를 수 있습니다.
  const sigBuf = Buffer.from(sig);
  const expBuf = Buffer.from(expected);
  const ok =
    sigBuf.length === expBuf.length &&
    crypto.timingSafeEqual(sigBuf, expBuf);
  if (!ok) return res.status(401).send("invalid signature");

  const event = JSON.parse(req.body.toString("utf8"));
  // ... event.session_id 기준으로 큐에 넣고, 즉시 응답합니다 ...
  res.status(200).send("ok");
});
import hmac, hashlib, os
from flask import Flask, request, abort

SIGNING_SECRET = os.environ["ATLASCLOUD_WEBHOOK_SECRET"].encode()
app = Flask(__name__)

@app.post("/hooks/atlascloud")
def atlascloud_webhook():
    raw = request.get_data()  # JSON 파싱 이전의 원본 바이트
    expected = hmac.new(SIGNING_SECRET, raw, hashlib.sha256).hexdigest()
    received = request.headers.get("X-AtlasCloud-Webhook-Signature", "")
    # 바이트로 비교하세요. compare_digest는 비 ASCII 문자열에서 예외를 던집니다.
    if not hmac.compare_digest(expected.encode(), received.encode()):
        abort(401)

    event = request.get_json()
    # ... event["session_id"] 기준으로 큐에 넣고, 200을 빠르게 반환합니다 ...
    return "ok", 200
func verify(secret string, body []byte, sigHeader string) bool {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(body)
    expected := hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(sigHeader))
}

전송 시맨틱

전송 확인 응답

수신을 확인하려면 아무 2xx 상태 코드로 응답하세요. 그 외의 상태 코드나 연결 타임아웃은 실패로 간주되어 전송이 재시도됩니다.

빠르게 응답하세요(넉넉히 잡아도 수 초 이내). 실제 작업은 비동기로 처리하세요 — 서명을 검증하고, session_id를 키로 이벤트를 큐에 넣은 뒤 즉시 200을 반환하면 됩니다. 응답이 느리면 타임아웃이 발생해 불필요한 재시도를 유발할 수 있습니다.

재시도와 백오프

전송이 확인되지 않으면 Atlas Cloud는 지수 백오프로 재시도합니다(대략 10s → 20s → 40s → …, 최대 약 30분에서 상한). 최대 약 10회까지 시도하며, 시도를 모두 소진하면 해당 전송은 전달 불가로 표시되고 더 이상 재시도되지 않습니다.

최소 1회 전송 — session_id로 중복 제거

전송은 최소 1회(at-least-once) 보장 방식입니다. 드물게 동일한 웹훅을 두 번 이상 받을 수 있습니다. 핸들러를 멱등하게 만들고 session_id로 중복을 제거하세요.

session_id는 재시도 전반에 걸쳐 동일하게 유지됩니다. 이미 완전히 처리한 session_id의 웹훅은 아무 동작도 하지 않고 200을 반환하세요.

적시성

대부분의 웹훅은 작업이 끝난 뒤 수 초 이내에 전송됩니다. 내장된 정합성 보정 안전망이 빠른 경로를 놓친 경우(예: 서비스 배포 중)에도 전송을 보장하지만, 그런 드문 상황에서는 최대 약 30분의 지연이 발생할 수 있습니다. 즉각적이고 정확히 1회인 전송이 아니라, 최종적이고 최소 1회인 전송을 전제로 설계하세요.

모범 사례

  • 콜백은 공개적으로 접근 가능한 호스트에서 HTTPS로 제공하세요.
  • 이벤트를 신뢰하기 전에 웹훅 서명을 검증하세요 — 가능하면 Ed25519/JWKS를 사용하세요(저장할 시크릿이 없고, kid로 가져온 공개 키로 검증합니다). HMAC은 마이그레이션 기간 동안의 레거시 폴백입니다.
  • Ed25519로 검증할 때는 JWKS를 캐시하고 모르는 kid가 오면 다시 가져오세요. 서명 검사는 "<timestamp>.<raw_body>"에 대해 수행하고 재생 허용 시간을 강제하세요.
  • 2xx를 빠르게 응답하고, 실제 처리는 백그라운드 큐로 넘기세요.
  • session_id로 중복을 제거하세요 — 핸들러는 반드시 멱등해야 합니다.
  • 최상위 status로 분기하고(OKERROR), 결과는 payload.outputs에서 읽으세요.
  • 순서나 정확히 1회 전송을 가정하지 마시고, 최소 1회 전송을 전제로 설계하세요.
  • 예측 엔드포인트를 폴백 및 정합성 보정 경로로 유지하세요.
  • 레거시 HMAC 사용 시에만: 서명 시크릿을 기밀로 유지하고 노출되면 교체하세요. (Ed25519/JWKS는 여러분 쪽에 시크릿이 없습니다.)

문제 해결

증상예상 원인 / 조치
제출이 400 webhook_url ... is not a routable public address를 반환호스트가 사설/루프백/CGNAT입니다. 공개 HTTPS URL이나 터널을 사용하세요.
제출이 400 webhook_url must use https를 반환스킴을 https://로 바꾸세요.
제출이 400 webhook_url exceeds the 1024-character limit을 반환URL을 줄이세요(상태 정보는 session_id를 키로 여러분 저장소로 옮기세요).
웹훅이 오지 않음엔드포인트가 공개적으로 도달 가능하고 2xx를 반환하는지 확인하고, 예측 엔드포인트로 작업이 실제로 종료 상태에 도달했는지 확인하세요.
서명 불일치(Ed25519)본문만이 아니라 "<timestamp>.<raw_body>"에 대해 서명을 검증하고, 서명을 base64url로 디코딩하며, JWKS에서 kid로 키를 조회하세요.
서명 불일치(HMAC)재직렬화된 JSON 객체가 아니라 원본 본문 바이트로 HMAC을 계산하고, 올바른 서명 시크릿을 사용하세요.
kid가 JWKS에 없음서명 키가 교체되었습니다 — JWKS를 다시 가져오세요(단일 키를 영구히 캐시하지 마세요).
동일한 이벤트를 두 번 받음최소 1회 전송에서는 정상입니다 — session_id로 중복을 제거하세요.

레퍼런스

  • 제출(웹훅 포함): POST /api/v1/model/generateVideo, POST /api/v1/model/generateImage, POST /api/v1/model/generateAudiowebhook_url을 추가하세요.
  • 이벤트 유형: video.task.terminal, image.task.terminal, audio.task.terminal.
  • JWKS(공개 키): GET /api/v1/webhooks/jwks.json.
  • 서명(Ed25519, 권장): "<timestamp>.<raw_body>"에 대한 base64url Ed25519 서명, X-AtlasCloud-Webhook-Signature-Ed25519 헤더에 담김(키 id는 X-AtlasCloud-Webhook-Key-Id).
  • 서명(HMAC, 레거시): HMAC-SHA256(signing_secret, raw_body), 16진수, X-AtlasCloud-Webhook-Signature 헤더에 담김.
  • 멱등성 키: session_id(X-AtlasCloud-Webhook-Id에도 담김).
  • 폴링 대안: 예측.