콘텐츠로 이동

웹훅

웹훅은 계정에서 어떤 일이 일어났을 때 Sakuranbo AI가 내 서버로 HTTP 요청을 보내는 기능입니다. 웹훅을 사용하면 이미지나 영상 작업이 끝난 것을 폴링 없이 알 수 있고, 입금을 도착하는 대로 기록할 수 있으며, 잔액이 부족할 때 경고를 받을 수 있습니다.

전송은 Standard Webhooks 사양을 따르며 모두 서명됩니다. 서버는 요청을 신뢰하기 전에 서명을 검증해야 합니다. 서명 검증하기를 참고하세요.

엔드포인트는 다음 이벤트를 구독할 수 있습니다: job.succeeded, job.failed, job.cancelled, deposit.credited, balance.low. webhook.test도 전송되지만 테스트 이벤트를 요청했을 때만 전송되며, 구독할 수는 없습니다.

  • job.succeeded: 이미지나 영상 작업이 completed가 되었습니다. data는 GET /v1/jobs/{id} 엔드포인트가 반환하는 것과 같은 작업이며, outputs에 서명된 URL이 들어 있습니다.
  • job.failed: 작업이 failed 또는 expired가 되었습니다. data는 작업이며 data.status가 둘 중 어느 쪽인지 알려 줍니다. failed 작업이면 data.error가 이유를 알려 줍니다. expired 작업이면 data.error는 null입니다. 모델 제공업체가 내용을 이유로 거부한 작업도 여기에 해당하며, 이때 data.error.code는 content_policy_violation입니다.
  • job.cancelled: 작업이 cancelled가 되었습니다. data는 작업입니다.
  • deposit.credited: 입금이 잔액에 반영되었습니다. data에는 금액, 날짜, 잔액이 들어 있습니다.
  • balance.low: 사용 가능한 잔액이 대시보드의 설정에서 지정한 알림 기준보다 낮아졌습니다. data에는 사용 가능한 잔액과 알림 기준이 들어 있습니다.
  • webhook.test: 요청한 테스트 이벤트입니다.

작업 상태는 작업을, 잔액은 잔액과 API 키 사용 한도를 참고하세요.

모든 전송의 본문은 필드 네 개를 가진 JSON입니다.

  • id: 이벤트의 ID입니다. webhook-id 헤더의 값이기도 합니다.
  • type: 이벤트 유형입니다.
  • timestamp: 이벤트가 발생한 시각이며 UTC 기준의 ISO 8601 형식입니다.
  • data: 이벤트의 내용이며 유형에 따라 다릅니다.

모르는 이벤트 유형과 필드는 무시하세요. Sakuranbo AI는 예고 없이 새 이벤트와 새 필드를 추가할 수 있습니다.

대시보드의 설정에서 알림 기준을 지정합니다. 사용 가능한 잔액이 알림 기준 이상에서 기준 미만으로 내려갈 때마다 balance.low 이벤트가 전송됩니다. 다음 이벤트는 잔액이 알림 기준 이상으로 돌아온 뒤에만 전송됩니다. 청구, 충전한 잔액의 만료, 잔액을 줄이는 조정이 이벤트를 일으킬 수 있습니다. 금액 확보만으로는 일어나지 않습니다. 알림 기준을 지정하지 않으면 아무것도 전송되지 않습니다.

엔드포인트는 이벤트를 받는 URL입니다. 대시보드나 API로 만들 수 있습니다.

대시보드에서는 웹훅 페이지를 엽니다. 엔드포인트를 추가하고 편집하며, 전송을 중지하거나 재개하고, 테스트 이벤트를 보내고, 시크릿을 교체하고, 전송 기록을 읽을 수 있습니다.

API로 만들려면 POST /v1/webhook-endpoints 엔드포인트를 호출합니다.

터미널 창
curl -X POST "https://api.sakuranbo.ai/v1/webhook-endpoints" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $SAKURANBO_API_KEY" \
-d '{
"url": "https://example.com/webhooks/cherry",
"events": [
"job.succeeded",
"job.failed",
"job.cancelled"
]
}'

요청 본문에는 다음 필드가 있습니다.

이름타입필수기본값제약
urlstring예1~2,048자
eventsstring[]아니요다음 중 하나: job.succeeded, job.failed, job.cancelled, deposit.credited, balance.low; 1개 이상
descriptionstring아니요최대 256자

events를 생략하면 엔드포인트는 만드는 시점에 존재하는 모든 이벤트 유형을 구독합니다. 나중에 추가되는 이벤트 유형은 엔드포인트에 추가되지 않습니다. 그 이벤트를 받으려면 새 이벤트 유형을 포함한 이벤트 유형 전체 목록을 events에 담아 PATCH /v1/webhook-endpoints/{id} 엔드포인트를 호출하세요. events는 목록 전체를 대체합니다.

응답에는 엔드포인트의 ID와 서명용 secret이 들어 있습니다. 시크릿은 whsec_로 시작합니다. 전체 시크릿은 이 요청의 응답과 시크릿 교체의 응답에만 나타납니다. 나중에 다시 읽을 수 없으므로 바로 저장하세요. 잃어버렸다면 시크릿을 교체하세요.

URL은 다음 규칙을 따라야 합니다.

  • https와 기본 포트를 사용합니다.
  • 호스트는 공개 DNS 이름입니다. IP 주소, localhost, 점이 없는 이름, 내부 이름은 거부됩니다. 사용자 이름이나 비밀번호가 들어 있는 URL도 거부됩니다.
  • 경로와 쿼리 문자열을 가질 수 있습니다.

규칙을 어기는 URL은 400과 invalid_request를 반환합니다. error.param은 url이고, error.details.reason이 어느 규칙을 어겼는지 알려 줍니다. 내 컴퓨터에서 테스트하려면 공개 HTTPS 터널로 로컬 서버를 외부에 노출하세요.

계정에는 엔드포인트를 최대 10개 만들 수 있습니다. 더 추가하려면 하나를 삭제하세요.

Sakuranbo AI는 각 이벤트를 JSON 본문과 다음 헤더를 가진 POST 요청으로 보냅니다.

  • webhook-id: 이벤트의 ID입니다.
  • webhook-timestamp: 이번 시도의 시각이며 Unix 초 단위입니다.
  • webhook-signature: 서명입니다. 서명 검증하기를 참고하세요.

서버는 다음 규칙으로 응답합니다.

  • 15초 안에 2xx 상태를 응답하면 이벤트를 받았다는 뜻입니다. 시간이 오래 걸리는 작업은 응답한 뒤에 하세요.
  • Sakuranbo AI는 리다이렉트를 따라가지 않습니다. 3xx 상태는 실패로 처리됩니다.
  • 410 Gone 상태는 엔드포인트를 중지시킵니다. 엔드포인트가 중지된 동안 발생한 이벤트는 대기열에 쌓이지 않으며, 재개해도 다시 전송되지 않습니다. 재개하려면 enabled를 true로 설정해 PATCH /v1/webhook-endpoints/{id} 엔드포인트를 호출하거나 대시보드를 사용하세요. 그런 다음 실패한 전송을 직접 다시 보내세요.
  • 그 밖의 상태, 시간 초과, 연결 실패는 실패로 처리됩니다. Sakuranbo AI는 다시 시도하며, 약 31시간에 걸쳐 첫 시도를 포함해 총 8번까지 시도합니다. 마지막 시도 뒤에 전송은 failed가 됩니다.

수신 측은 다음 사실을 염두에 두고 작성하세요.

  • 이벤트는 여러 번 도착할 수 있습니다. 한 이벤트의 모든 시도는 같은 webhook-id 헤더 값과 같은 본문을 가지므로, 처리한 ID를 저장해 두었다가 반복된 것은 건너뛰세요.
  • 이벤트의 순서는 보장되지 않습니다. timestamp와 data의 상태를 사용하세요.
  • 처리하지 않는 이벤트 유형에도 2xx를 반환하세요. 그래야 다시 시도되지 않습니다.

각 전송은 엔드포인트의 시크릿으로 서명됩니다. Sakuranbo AI가 요청을 보냈고 아무도 변경하지 않았음을 확인하려면 서명을 검증하세요. 방식은 HMAC-SHA256을 사용하는 Standard Webhooks 버전 v1입니다.

  1. webhook-id, webhook-timestamp, webhook-signature 헤더를 읽습니다. 하나라도 없으면 요청을 거부하세요.
  2. 타임스탬프가 현재 시각에서 5분 이내(과거든 미래든)인지 확인하세요. 가로챈 요청을 다시 보내는 재생(replay) 공격을 막기 위한 절차입니다.
  3. 키를 만듭니다. 시크릿에서 whsec_ 접두사를 제거하고 나머지를 base64로 디코딩합니다. 키는 디코딩한 바이트이며, 시크릿의 텍스트가 아닙니다.
  4. 서명 대상 내용을 만듭니다. ID 헤더의 값, 점, 타임스탬프 헤더의 값, 점, 원본 요청 본문을 이어 붙입니다.
  5. 키로 서명 대상 내용의 HMAC-SHA256을 계산하고, 결과를 표준 base64로 인코딩합니다.
  6. 서명 헤더에는 공백으로 구분된 항목이 하나 이상 있습니다. 각 항목은 버전, 쉼표, 서명으로 이루어집니다. v1 버전이 아닌 항목은 건너뜁니다. 항목 중 하나라도 계산한 결과와 일치하면 유효한 요청입니다. 비교는 상수 시간으로 하세요.
  7. 서명이 유효한지 확인한 뒤에만 JSON을 파싱하세요.

받은 바이트 그대로를 검증하세요. JSON을 파싱한 뒤 다시 직렬화하는 웹 프레임워크는 바이트를 바꿀 수 있으며, 그러면 서명이 일치하지 않습니다. 파서가 실행되기 전에 원본 본문을 읽으세요.

TypeScript:

import { createHmac, timingSafeEqual } from "node:crypto";
/**
* Verifies a Sakuranbo AI webhook (Standard Webhooks) and returns the parsed event.
* rawBody must be the request body exactly as received, before any JSON parsing.
*/
export function verifyWebhook(
secret: string, // "whsec_..." as shown when the endpoint was created or its secret rotated
rawBody: string,
headers: Record<string, string | undefined>, // header names in lower case
toleranceSeconds = 300,
) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (!id || !timestamp || !signatures) throw new Error("missing webhook headers");
const sentAt = Number(timestamp);
const now = Math.floor(Date.now() / 1000);
if (!Number.isInteger(sentAt) || Math.abs(now - sentAt) > toleranceSeconds) {
throw new Error("timestamp outside the tolerance");
}
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();
// During a secret rotation the header holds two signatures, separated by a space.
for (const entry of signatures.split(" ")) {
const [version, signature] = entry.split(",");
if (version !== "v1" || !signature) continue;
const received = Buffer.from(signature, "base64");
if (received.length === expected.length && timingSafeEqual(received, expected)) {
return JSON.parse(rawBody);
}
}
throw new Error("no matching signature");
}

Python:

import base64
import hashlib
import hmac
import json
import time
def verify_webhook(secret: str, raw_body: bytes, headers: dict, tolerance_seconds: int = 300):
"""Verify a Sakuranbo AI webhook (Standard Webhooks) and return the parsed event.
raw_body must be the request body exactly as received, before any JSON parsing.
"""
headers = {name.lower(): value for name, value in headers.items()}
message_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature")
if not (message_id and timestamp and signatures):
raise ValueError("missing webhook headers")
try:
sent_at = int(timestamp)
except ValueError:
raise ValueError("invalid timestamp") from None
if abs(int(time.time()) - sent_at) > tolerance_seconds:
raise ValueError("timestamp outside the tolerance")
if secret.startswith("whsec_"):
secret = secret[len("whsec_"):]
key = base64.b64decode(secret)
signed = f"{message_id}.{timestamp}.".encode() + raw_body
expected = hmac.new(key, signed, hashlib.sha256).digest()
# During a secret rotation the header holds two signatures, separated by a space.
for entry in signatures.split(" "):
version, _, signature = entry.partition(",")
if version != "v1" or not signature:
continue
if hmac.compare_digest(base64.b64decode(signature), expected):
return json.loads(raw_body)
raise ValueError("no matching signature")

Standard Webhooks 라이브러리도 같은 검사를 합니다. 직접 작성한 코드보다 라이브러리를 선호한다면 npm 또는 PyPI의 standardwebhooks 패키지를 사용하세요.

시크릿을 잃어버렸거나 유출되었을 수 있다면 시크릿을 교체하세요. 대시보드나 POST /v1/webhook-endpoints/{id}/rotate-secret 엔드포인트를 사용합니다. 응답에 새 시크릿이 들어 있으며, 이때만 읽을 수 있습니다.

교체한 뒤 24시간 동안 서명 헤더에는 서명이 두 개 들어 있으며, 새 서명이 먼저이고 이전 서명이 두 번째입니다. 그동안에는 둘 중 하나의 시크릿을 가진 수신 측이 요청을 검증할 수 있습니다. 서버를 새 시크릿으로 바꾸세요. 그 시간이 지나면 이전 시크릿은 더 이상 작동하지 않습니다. 엔드포인트의 previous_secret_expires_at이 그 시각을 알려 줍니다.

그 시간 안에 다시 교체하면 첫 번째 교체 이전의 시크릿은 즉시 작동하지 않게 됩니다. 전송에는 가장 최근의 시크릿 두 개로만 서명합니다.

POST /v1/webhook-endpoints/{id}/test 엔드포인트는 엔드포인트가 구독하는 이벤트와 관계없이 엔드포인트 하나에 webhook.test 이벤트를 보냅니다. 엔드포인트가 사용 중이어야 합니다. 응답은 상태가 pending인 새 전송입니다. 대시보드에도 같은 동작을 하는 버튼이 있습니다.

터미널 창
curl -X POST "https://api.sakuranbo.ai/v1/webhook-endpoints/we_0123456789abcdef0123456789abcdef/test" \
-H "Authorization: Bearer $SAKURANBO_API_KEY"

GET /v1/webhook-endpoints/{id}/deliveries 엔드포인트와 대시보드는 엔드포인트의 모든 전송을 최신순으로 보여 줍니다. 각 항목에는 상태, 시도 횟수, 마지막 HTTP 상태, 다음 시도 시각, 내 서버가 보낸 응답의 앞부분이 있습니다. 기록에는 이벤트 본문이 포함되지 않습니다. Sakuranbo AI는 기록을 30일 동안 보관합니다.

전송을 다시 보내려면 POST /v1/webhook-endpoints/{id}/deliveries/{deliveryId}/retry 엔드포인트를 호출하거나 대시보드를 사용하세요. 같은 이벤트 ID와 같은 본문으로 전송되며, 시도가 처음부터 새로 시작됩니다. 끝난 전송, 즉 succeeded 또는 failed 상태의 전송만 다시 보낼 수 있으며, 엔드포인트가 사용 중이어야 합니다.

이 엔드포인트는 모두 API 키가 필요합니다. 인증을 참고하세요.

  • POST /v1/webhook-endpoints: 엔드포인트를 만듭니다.
  • GET /v1/webhook-endpoints: 엔드포인트를 나열합니다.
  • GET /v1/webhook-endpoints/{id}: 엔드포인트 하나를 읽습니다.
  • PATCH /v1/webhook-endpoints/{id}: URL, 이벤트, 설명, 엔드포인트의 사용 여부를 변경합니다.
  • DELETE /v1/webhook-endpoints/{id}: 엔드포인트와 그 전송 기록을 삭제합니다.
  • POST /v1/webhook-endpoints/{id}/test: 테스트 이벤트를 보냅니다.
  • POST /v1/webhook-endpoints/{id}/rotate-secret: 새 시크릿을 만듭니다.
  • GET /v1/webhook-endpoints/{id}/deliveries: 전송 기록을 읽습니다.
  • POST /v1/webhook-endpoints/{id}/deliveries/{deliveryId}/retry: 전송을 다시 보냅니다.
코드HTTP 상태유형설명
invalid_request400invalid_request_error요청 형식이 올바르지 않거나 값이 허용 범위를 벗어났습니다. 유효한 값을 알 수 있는 경우 details.allowed에 담깁니다.
unauthorized401authentication_errorAPI 키가 없거나, 형식이 올바르지 않거나, 존재하지 않거나, 폐기되었거나, 만료되었습니다. 응답은 그중 어느 경우인지 알려 주지 않습니다.
webhooks_unavailable503api_error이 환경에서는 아직 웹훅을 사용할 수 없습니다. 잠시 후 다시 시도하세요.

나머지 엔드포인트는 엔드포인트나 전송이 내 계정에 없으면 404와 not_found를 반환합니다. 중지된 엔드포인트에 테스트 이벤트나 재전송을 요청하면 400과 invalid_request를 반환합니다. 전체 코드 목록은 오류를 참고하세요.