본문으로 건너뛰기

웹훅

엔드포인트를 등록하고, 변경 사항을 서명된 HTTP 요청으로 받는 방법과 재시도 방식.

웹훅은 변경 피드의 각 이벤트를 내 서버의 URL로 HTTPS POST 요청으로 보냅니다. 요청에는 서명이 붙어 있어 Tahoe가 보낸 것인지 확인할 수 있습니다. 본문은 피드가 반환하는 이벤트 객체와 같으므로, 핸들러 하나로 둘 다 처리할 수 있습니다.

웹훅은 더 빨리 알려 줄 뿐, 더 정확하게 만들어 주지는 않습니다. 전송은 실패할 수 있고 엔드포인트는 꺼질 수 있으니, 변경 피드 리더를 안전망으로 두고 전송이 누락되면 피드에서 복구하세요.

엔드포인트 설정하기

webhooks:write 스코프가 있는 API 호출로 엔드포인트를 직접 등록합니다. 엔드포인트는 만든 키에 속하며, 요청에 지정한 워크스페이스의 이벤트 중 목록에 적은 이벤트 유형만 받습니다.

HTTPS URL 준비하기

예를 들어 https://app.example.com/webhooks/tahoe입니다. 아래의 URL 규칙을 따라야 하고, 요청에 직접 응답해야 합니다. 리디렉션은 따라가지 않습니다.

엔드포인트 만들기

URL과 받을 이벤트 유형을 넣어 POST /webhooks/endpoints를 호출하세요. webhooks:write와, 요청하는 모든 이벤트 유형의 읽기 스코프가 있는 키가 필요합니다.

서명 시크릿 보관하기

응답에 signing_secret이 한 번만 들어 있습니다. API 키와 함께 서버에 보관하세요. Tahoe는 시크릿을 암호화해 저장하며 다시 보여 줄 수 없습니다. 잃어버렸다면 rotate_secret을 호출해 새 시크릿을 받으세요.

테스트 보내기

POST /webhooks/endpoints/{endpoint_id}/test를 호출하세요. Tahoe가 엔드포인트로 ping 이벤트를 보내도록 대기열에 넣고, 결과는 전송 로그에서 읽습니다.

전송 요청의 형태

요청 헤더
POST /webhooks/tahoe HTTP/1.1
Host: app.example.com
Content-Type: application/json
User-Agent: Tahoe-Webhooks/1.0
Tahoe-Signature: t=1788866643,v1=6d8b2f9e...c41a
Tahoe-Event-Id: evt_2Wp5nRc8Kd3x
Tahoe-Delivery-Id: 4b1d7e2a-9c3f-4e8a-b6d5-2f7c1a9e0b34
Tahoe-Delivery-Attempt: 1
Tahoe-Api-Version: 2026-09-09
헤더담긴 내용
Tahoe-Signature서명입니다. 다른 무엇보다 먼저 확인하세요.
Tahoe-Event-Id이벤트의 id입니다. 이미 처리한 이벤트를 건너뛸 때 쓰세요.
Tahoe-Delivery-Id이번 전송의 ID입니다. 전송 로그의 id와 같습니다.
Tahoe-Delivery-Attempt첫 시도는 1이고, 재시도할 때마다 1씩 늘어납니다.
Tahoe-Api-Version이벤트 형태를 정한 API 버전입니다.
요청 본문
{
  "object": "event",
  "id": "evt_2Wp5nRc8Kd3x",
  "type": "application.stage_changed",
  "api_version": "2026-09-09",
  "created_at": "2026-09-08T11:24:02.907Z",
  "sequence": 48219,
  "workspace_id": "wsp_4Kd8sPm2Qx7L",
  "data": {
    "object": {
      "object": "application",
      "id": "app_6Qm2xKd4Rp8v",
      "workspace_id": "wsp_4Kd8sPm2Qx7L",
      "updated_at": "2026-09-08T11:24:02.880Z",
      "stage_id": "stg_3Rp8vKd2mXq4",
      "status": "in_review",
      "changed": ["stage_id"]
    },
    "previous_attributes": { "stage_id": "stg_9Kd2mXq4Rp8v" }
  },
  "links": { "self": "/api/partner/v1/events/evt_2Wp5nRc8Kd3x" }
}

본문은 변경 피드의 행과 정확히 같습니다. 봉투, 최소한의 페이로드, sequence가 모두 같습니다. 핸들러를 하나만 작성해 웹훅 라우트와 피드 리더 양쪽에서 호출하세요.

서명 검증하기

항목값
헤더Tahoe-Signature
형식t=<unix seconds>,v1=<hex>[,v1=<hex>]
알고리즘서명 시크릿으로 {t}.{raw_body} 문자열을 계산한 HMAC-SHA256
허용 오차t와 내 서버 시계 사이 300초

세 가지 규칙이 있고, 모두 중요합니다.

  1. 원본 바이트로 서명을 계산하세요. 파싱한 뒤 다시 인코딩한 사본이 아니라, 도착한 그대로의 본문을 쓰세요. 키 순서나 공백을 바꾸는 JSON 라이브러리를 거치면 다이제스트가 달라져 모든 전송이 실패합니다.
  2. 상수 시간으로 비교하세요. 문자열을 그냥 ==로 비교하면, 응답 시간을 잴 수 있는 사람에게 올바른 다이제스트가 한 바이트씩 드러날 수 있습니다.
  3. 어느 v1이든 받아들이세요. 서명 시크릿을 교체한 뒤에는 헤더에 새 시크릿용 v1과 이전 시크릿용 v1이 함께 담깁니다. 둘 중 하나라도 일치하면 전송을 받아들이세요. 그래야 교체가 장애로 이어지지 않습니다.
verify.py
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300


def verify(raw_body: bytes, header: str, secret: str) -> bool:
    """True if this delivery came from Tahoe and is recent.

    raw_body must be the bytes exactly as received. Parsing and re-encoding
    the JSON changes the bytes, and the signature with them.
    """
    timestamp = None
    signatures = []
    # Keep every v1: after a secret rotation there are two of them, and a
    # dict would keep only the last one.
    for piece in header.split(","):
        key, _, value = piece.strip().partition("=")
        if key == "t":
            timestamp = value
        elif key == "v1":
            signatures.append(value)

    if not timestamp or not signatures:
        return False
    try:
        stamp = int(timestamp)
    except ValueError:
        return False

    # Refuse a replay of an old delivery that was validly signed.
    if abs(int(time.time()) - stamp) > TOLERANCE_SECONDS:
        return False

    expected = hmac.new(
        secret.encode("utf-8"),
        f"{stamp}.".encode("utf-8") + raw_body,
        hashlib.sha256,
    ).hexdigest()

    # compare_digest, never ==: it takes the same time whatever the input.
    return any(hmac.compare_digest(expected, candidate) for candidate in signatures)
수신 측 (Flask)
import json
import os

from flask import Flask, abort, request

from verify import verify
from queue_client import enqueue  # your own durable queue

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


@app.post("/webhooks/tahoe")
def tahoe_webhook():
    raw = request.get_data()  # the exact bytes, before any parsing
    if not verify(raw, request.headers.get("Tahoe-Signature", ""), SECRET):
        abort(400)
    enqueue(json.loads(raw))  # store it, then answer; do the work elsewhere
    return "", 204

빠르게 응답하기

이벤트를 안전한 곳에 저장하자마자 2xx로 응답하고, 실제 작업은 그다음에 하세요. 그 밖의 응답은 모두 실패한 시도로 간주되어 재시도됩니다.

  • 10초 제한. Tahoe는 응답을 10초 동안 기다립니다. 느린 핸들러는 재시도로 이어지고, 재시도는 중복으로 이어집니다.
  • 리디렉션은 실패입니다. 301이나 302는 따라가지 않습니다. 최종 URL을 등록하세요.
  • 410 Gone은 엔드포인트를 끕니다. Tahoe는 이를 “이 엔드포인트는 더 이상 쓰지 않는다”는 뜻으로 받아들이고 엔드포인트를 즉시 비활성화합니다. 정말 그런 뜻일 때만 보내세요.
  • Retry-After를 따릅니다. 응답에 초 단위 Retry-After가 있으면, Tahoe는 자체 다음 대기 시간과 그 값 중 더 긴 쪽만큼 기다립니다. 최대 24시간입니다.

재시도

이벤트마다 최대 10번 시도합니다. 시도가 실패하면 Tahoe는 아래 표만큼 기다렸다가 다음 시도를 합니다. 첫 시도부터 마지막 시도까지 약 2.8일이 걸립니다. 열 번째 시도까지 실패하면 전송은 failed로 표시되고 더 이상 시도하지 않습니다.

시도전송 시점
1이벤트가 기록된 직후
21번째 시도가 실패하고 10초 후
330초 후
42분 후
510분 후
61시간 후
76시간 후
812시간 후
924시간 후
1024시간 후

전송은 최소 한 번 이루어집니다. 핸들러는 성공했지만 응답이 제때 Tahoe에 도착하지 않으면, 그 시도는 실패로 간주되어 이벤트가 다시 옵니다. 이미 처리한 이벤트 id는 건너뛰고, 핸들러는 두 번 실행해도 안전하게 만드세요.

엔드포인트가 비활성화되면

엔드포인트 조회

GET/webhooks/endpointswebhooks:read

호출한 키에 등록된 엔드포인트를 최신순으로 보여 줍니다.

요청
curl https://tahoe.workonward.com/api/partner/v1/webhooks/endpoints \
  -H "Authorization: Bearer $TAHOE_API_KEY"
응답
{
  "object": "list",
  "data": [
    {
      "object": "webhook_endpoint",
      "id": "8f2c1a94-5d3e-4b7a-9c61-0e2f4d8b7a13",
      "url": "https://app.example.com/webhooks/tahoe",
      "description": "Acme HRIS sync",
      "event_types": [],
      "enabled": true,
      "disabled_at": null,
      "disabled_reason": null,
      "signing_secret_version": 2,
      "signing_secret_rotated_at": "2026-08-30T09:00:00.000Z",
      "consecutive_failures": 0,
      "last_success_at": "2026-09-09T09:58:12.004Z",
      "last_failure_at": "2026-08-14T02:11:40.882Z",
      "created_at": "2026-07-01T09:00:00.000Z",
      "signature_scheme": {
        "header": "Tahoe-Signature",
        "format": "t=<unix seconds>,v1=<hex>[,v1=<hex>]",
        "algorithm": "HMAC-SHA256 over \"{t}.{raw_body}\"",
        "tolerance_seconds": 300,
        "note": "Two v1 values appear during a secret rotation. Accept the delivery if EITHER verifies."
      }
    }
  ],
  "has_more": false,
  "next_cursor": null
}
  • event_types가 비어 있으면 모든 유형을 뜻합니다. 나중에 추가되는 유형도 포함됩니다. 직접 만든 엔드포인트에는 유형이 항상 하나 이상 있으므로, 고르지 않은 유형을 받기 시작하는 일은 없습니다.
  • 조회로는 서명 시크릿이 반환되지 않습니다. 대신 교체할 때마다 1씩 늘어나는 signing_secret_version과 signing_secret_rotated_at이 보입니다. 시크릿은 엔드포인트를 만들 때와 시크릿을 교체할 때 한 번만 보여 줍니다.

GET/webhooks/endpoints/{endpoint_id}webhooks:read

엔드포인트 하나를 같은 형태로 가져옵니다. 다른 키에 속한 엔드포인트는 404로 응답합니다.

엔드포인트 관리하기

POST 호출 다섯 가지로 엔드포인트를 만들고, 바꾸고, 시크릿을 교체하고, 끄고, 테스트합니다. 모두 webhooks:write와 Tahoe 사용자가 만든 API 키가 필요합니다. Sign in with Tahoe 토큰은 403 write_requires_api_key를 받습니다. 연결된 앱의 쓰기가 켜져 있으면 예외입니다. webhooks:write는 유료 플랜 스코프입니다. 이 호출들은 이벤트를 보내지 않으며, 후보자나 리크루터에게 아무것도 보내지 않습니다. 웹훅은 등록한 URL로만 갑니다. 형식이 잘못된 엔드포인트 ID, 다른 키의 엔드포인트, 다른 워크스페이스의 엔드포인트는 모두 404 not_found를 반환합니다.

URL 규칙

웹훅은 Tahoe의 네트워크가 내가 고른 주소로 서명된 요청을 보내게 합니다. 그래서 주소는 등록할 때와 매 전송 전에 엄격하게 검사합니다.

  • https여야 하고, 최대 2,048자이며, 사용자 이름이나 비밀번호와 프래그먼트가 없어야 합니다. 포트는 443(기본값) 또는 8443입니다.
  • 호스트는 DNS 이름이어야 하며 IP 주소나 localhost는 안 됩니다. 이름이 해석되어야 하고, 해석된 모든 주소가 공개 주소여야 합니다. 사설, 루프백, 링크 로컬, 통신사급 NAT, 멀티캐스트, 예약, 문서용, 고유 로컬 주소가 하나라도 있으면 이름 전체가 거부되며, IPv6 형식으로 쓴 IPv4 주소도 마찬가지입니다.
  • Tahoe는 매 전송 전에 이름을 다시 해석하고, 방금 검사한 주소로 연결하면서 TLS 인증서를 내 호스트 이름과 대조합니다. 이름이 비공개 주소로 해석되기 시작하면 전송은 url_refused: blocked_address로 실패하고 일반 재시도 일정을 따릅니다.
  • 리디렉션은 따라가지 않습니다. 3xx 응답은 실패한 전송입니다.

한도

키 하나는 활성 엔드포인트를 10개, 워크스페이스는 모든 키를 합쳐 활성 엔드포인트를 20개 가질 수 있습니다. 키 하나의 엔드포인트는 비활성 포함 총 100개까지입니다. 비활성 엔드포인트는 삭제되지 않고 목록에 남으며, 앞의 두 한도에는 포함되지 않습니다. 한 키가 한 워크스페이스에서 같은 URL의 엔드포인트를 둘 가질 수는 없습니다.

엔드포인트가 요청할 수 있는 이벤트 유형

event_types는 필수이며 1~40개의 이름을 넣습니다. 각 이름은 이벤트 카탈로그에 있어야 합니다. 키가 읽기 스코프를 가진 이벤트 유형만 구독할 수 있습니다. job.*는 jobs:read, application.*는 applications:read, list.*와 list_membership.*는 lists:read가 필요한 식입니다. 목록은 비워 둘 수 없으므로, 엔드포인트를 만든 뒤에 추가된 이벤트 유형을 받기 시작하는 일은 없습니다.

엔드포인트가 받을 수 있는 스코프는 만들 때, 또는 이벤트 유형을 바꿀 때 정해집니다. 나중에 키에서 스코프를 빼도 기존 엔드포인트가 좁아지지는 않습니다. 키의 스코프가 줄었다면 엔드포인트를 끄고 다시 만드세요.

POST/webhooks/endpointswebhooks:write

엔드포인트를 등록하고, 201과 함께 엔드포인트와 서명 시크릿을 반환합니다.

필드타입설명
urlstring, 필수최대 2,048자. 위의 규칙을 따르세요.
event_typesstring[], 필수이벤트 유형 1~40개.
descriptionstring선택. 최대 200자.
요청
curl -X POST https://tahoe.workonward.com/api/partner/v1/webhooks/endpoints \
  -H "Authorization: Bearer $TAHOE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.acme.example/tahoe",
    "event_types": ["job.published", "job.closed"],
    "description": "Production sync"
  }'
응답: 201
{
  "object": "webhook_endpoint",
  "id": "6f1c1c1e-3b7a-4d52-8c0e-9a1f5e2d7b44",
  "url": "https://hooks.acme.example/tahoe",
  "description": "Production sync",
  "event_types": ["job.published", "job.closed"],
  "enabled": true,
  "disabled_at": null,
  "disabled_reason": null,
  "signing_secret_version": 1,
  "signing_secret_rotated_at": "2026-10-08T12:00:00.000Z",
  "consecutive_failures": 0,
  "last_success_at": null,
  "last_failure_at": null,
  "created_at": "2026-10-08T12:00:00.000Z",
  "signature_scheme": { "header": "Tahoe-Signature", "...": "..." },
  "workspace_id": "wsp_4Kd8sPm2Qx7L",
  "signing_secret": "whsec_..."
}
  • signing_secret은 whsec_ 뒤에 URL에 안전한 문자 43자가 붙은 값입니다. 한 번만 나타납니다. 응답에는 Cache-Control: no-store가 붙고 재생용으로 저장되지 않으므로 Idempotency-Key를 써도 도움이 되지 않습니다. 재시도하면 호출이 다시 실행되며, 첫 호출이 성공했다면 409 webhook_endpoint_exists를 받습니다. 그럴 때는 rotate_secret을 호출해 시크릿을 받으세요.
  • 서명은 서명 검증하기에 설명한 헤더를 씁니다.

POST/webhooks/endpoints/{endpoint_id}/updatewebhooks:write

엔드포인트를 바꿉니다. endpoint_id는 생성 응답의 id입니다. url, event_types, description, enabled 중 하나 이상을 보내세요. url, event_types, enabled는 null일 수 없고, "description": null은 설명을 지웁니다. 응답은 시크릿이 없는 엔드포인트입니다.

요청
curl -X POST https://tahoe.workonward.com/api/partner/v1/webhooks/endpoints/6f1c1c1e-3b7a-4d52-8c0e-9a1f5e2d7b44/update \
  -H "Authorization: Bearer $TAHOE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "event_types": ["job.published"], "enabled": true }'
  • 새 url은 생성과 같은 검사를 거치며 consecutive_failures를 0으로 되돌립니다. 새 event_types 목록은 이전 목록을 대체하고 키의 스코프와 대조해 검사합니다.
  • "enabled": false는 disable과 같습니다. "enabled": true는 엔드포인트를 다시 켭니다. 연속 실패나 410 응답으로 Tahoe가 끈 엔드포인트도 켤 수 있습니다. disabled_at과 disabled_reason을 지우고, consecutive_failures를 0으로 되돌리고, URL을 다시 검사하며, 한도에 다시 포함됩니다.
  • 서명 시크릿은 여기서 바뀌지 않습니다.

POST/webhooks/endpoints/{endpoint_id}/rotate_secretwebhooks:write

새 서명 시크릿을 발급합니다. 본문은 비어 있습니다. 응답은 signing_secret_version이 1 늘어난 엔드포인트이며, 새 signing_secret과 previous_secret_valid_until이 들어 있습니다.

요청
curl -X POST https://tahoe.workonward.com/api/partner/v1/webhooks/endpoints/6f1c1c1e-3b7a-4d52-8c0e-9a1f5e2d7b44/rotate_secret \
  -H "Authorization: Bearer $TAHOE_API_KEY"
응답 (일부)
{
  "object": "webhook_endpoint",
  "id": "6f1c1c1e-3b7a-4d52-8c0e-9a1f5e2d7b44",
  "signing_secret_version": 2,
  "...": "...",
  "signing_secret": "whsec_...",
  "previous_secret_valid_until": "2026-10-09T12:00:00.000Z"
}
  • 이전 시크릿은 24시간 더 서명에 쓰이므로 전송에 v1 값이 둘 들어가고, 수신 측은 이벤트를 놓치지 않고 새 시크릿을 배포할 수 있습니다. 그 사이에 다시 교체하면 앞선 시크릿의 겹침이 일찍 끝나므로, 다시 교체하기 전에 새 시크릿을 배포하세요.
  • Tahoe가 등록해 준 엔드포인트는 첫 교체 때 저장되는 시크릿으로 옮겨 가며, 이전 시크릿도 같은 24시간 동안 겹칩니다.
  • 생성과 마찬가지로 응답은 재생용으로 저장되지 않습니다. 호출을 반복하면 또 새 시크릿이 발급됩니다.

POST/webhooks/endpoints/{endpoint_id}/disablewebhooks:write

엔드포인트로 이벤트 보내기를 멈춥니다. 본문은 비어 있습니다. 응답은 enabled: false, disabled_at, disabled_reason: "disabled_by_customer"가 들어 있는 엔드포인트입니다.

요청
curl -X POST https://tahoe.workonward.com/api/partner/v1/webhooks/endpoints/6f1c1c1e-3b7a-4d52-8c0e-9a1f5e2d7b44/disable \
  -H "Authorization: Bearer $TAHOE_API_KEY"

대기 중이거나 전송 중인 전송은 endpoint_disabled 오류로 실패 처리되어, 엔드포인트를 다시 켜도 늦게 도착하지 않습니다. 아무것도 삭제되지 않습니다. 엔드포인트, 시크릿, 전송 로그가 남고, 놓친 이벤트는 변경 피드에서 읽을 수 있습니다. 이미 꺼진 엔드포인트를 끄면 성공하고 아무것도 바뀌지 않습니다.

POST/webhooks/endpoints/{endpoint_id}/testwebhooks:write

이 엔드포인트에만 ping 이벤트 하나를 대기열에 넣고, 그것을 담은 전송을 반환합니다. 본문은 비어 있습니다. 바깥으로 나가는 요청을 일으키므로 expensive 속도 제한 등급에 속합니다.

요청
curl -X POST https://tahoe.workonward.com/api/partner/v1/webhooks/endpoints/6f1c1c1e-3b7a-4d52-8c0e-9a1f5e2d7b44/test \
  -H "Authorization: Bearer $TAHOE_API_KEY"
응답: 200
{
  "object": "webhook_test",
  "endpoint_id": "6f1c1c1e-3b7a-4d52-8c0e-9a1f5e2d7b44",
  "delivery_id": "0b5d9a0e-6c2f-4e1b-9d73-4a8e1f6c2b90",
  "event_type": "ping",
  "status": "pending"
}
  • ping은 실제 전송 경로를 그대로 거칩니다. 서명하고, 주소를 검사하고, 보내고, 실패하면 재시도하고, 로그에 남깁니다. 그래서 요청 중이 아니라 1분쯤 뒤에 전송되며, 수신 측이 200을 돌려주면 전체 경로가 동작한다는 뜻입니다. 결과는 GET /webhooks/deliveries?endpoint_id=...에서 읽으세요. delivery_id가 그 전송의 id입니다.
  • 본문은 type: "ping"이고 data.object가 {"endpoint_id": "..."}인 일반 이벤트 형식입니다. 4일 동안 보관되며, 같은 워크스페이스에서 webhooks:write가 있는 키에게만 GET /events에 나타납니다.
  • 엔드포인트당 10초에 한 번만 테스트할 수 있습니다. 같은 Idempotency-Key로 반복하면 같은 delivery_id가 돌아오고 새로 대기열에 넣지 않습니다.

이 호출들의 오류

상태코드조치
400invalid_request본문 검증에 실패했습니다. 빠졌거나 빈 필드, 너무 긴 값, 40개를 넘는 이벤트 유형, 알 수 없는 필드, 빈 update가 원인입니다.
400invalid_event_type이름이 이벤트 카탈로그에 없습니다. param은 event_types입니다.
400event_type_not_permitted키에 그 이벤트 유형의 읽기 스코프가 없습니다. 메시지에 유형과 스코프가 표시됩니다.
400webhook_url_not_allowedURL이 위 규칙을 어기거나, 해석되지 않거나, 비공개 주소로 해석됩니다. param은 url입니다.
403webhooks_self_serve_disabledAPI로 엔드포인트를 등록하는 기능이 아직 켜져 있지 않습니다. [email protected]으로 문의하세요.
403insufficient_scope키에 webhooks:write가 없습니다.
403write_requires_api_keySign in with Tahoe 토큰이거나, Tahoe 사용자와 연결되지 않은 자격 증명입니다.
404not_found이 키와 워크스페이스에 그런 엔드포인트가 없습니다.
409webhook_endpoint_exists이 키가 이 워크스페이스에 같은 URL의 엔드포인트를 이미 가지고 있습니다. 그것을 바꾸거나 켜세요.
409webhook_endpoint_limit_reached위의 세 한도 중 하나입니다. 먼저 엔드포인트를 끄세요.
409webhook_endpoint_disabledtest에서만 발생합니다. 먼저 엔드포인트를 켜세요.
429webhook_test_throttledtest에서만 발생합니다. 엔드포인트당 10초에 한 번입니다. Retry-After만큼 기다리세요.
429webhook_secrets_unavailableTahoe가 지금은 서명 시크릿을 만들거나 읽을 수 없습니다. 아무것도 바뀌지 않았습니다. 나중에 다시 시도하세요.
429webhooks_delivery_disabledtest에서만 발생합니다. 웹훅 전송이 꺼져 있어 ping이 전송되지 않습니다.

이벤트가 흐르려면 웹훅 전송 자체가 켜져 있어야 합니다. 엔드포인트 생성은 켜져 있지 않아도 됩니다.

전송 로그

GET/webhooks/deliverieswebhooks:read

무엇을 보냈고, 어떤 응답이 왔고, 무엇이 다음 시도를 기다리는지 보여 줍니다. “그 이벤트를 받은 적이 없다”는 말이 나오면 가장 먼저 여기를 확인하세요. 로그는 최근 전송을 최신순으로 반환합니다. 페이지가 나뉘지 않으므로 필터로 범위를 좁히세요.

파라미터설명
endpoint_id이 엔드포인트로 보낸 전송만.
event_id이 이벤트(evt_ 핸들)의 전송만.
statuspending, sending, delivered, failed 중 하나.
limit기본값 25, 최대 100.
요청
curl "https://tahoe.workonward.com/api/partner/v1/webhooks/deliveries?status=pending&limit=50" \
  -H "Authorization: Bearer $TAHOE_API_KEY"
응답
{
  "object": "list",
  "data": [
    {
      "object": "webhook_delivery",
      "id": "4b1d7e2a-9c3f-4e8a-b6d5-2f7c1a9e0b34",
      "endpoint_id": "8f2c1a94-5d3e-4b7a-9c61-0e2f4d8b7a13",
      "event_id": "evt_2Wp5nRc8Kd3x",
      "event_type": "application.stage_changed",
      "status": "pending",
      "attempt": 3,
      "response_status": 502,
      "error": null,
      "next_attempt_at": "2026-09-08T11:27:12.480Z",
      "delivered_at": null,
      "created_at": "2026-09-08T11:24:03.112Z"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
status의미
pending첫 시도나 다음 재시도를 기다리는 중입니다. 시도 시각은 next_attempt_at입니다.
sending지금 보내는 중입니다.
delivered엔드포인트가 2xx로 응답했습니다. 응답 시각은 delivered_at입니다.
failed더 이상 시도하지 않습니다.

response_status는 가장 최근 시도에서 엔드포인트가 반환한 HTTP 상태입니다. error는 시간 초과처럼 HTTP 응답이 없었던 실패를 설명합니다. error가 event_expired이면, 이벤트가 전송되기 전에 변경 피드의 30일 보관 기간이 지났다는 뜻입니다. 그보다 최근의 실패한 전송은 아직 변경 피드에 있습니다. 푸시는 실패했지만 이벤트가 사라진 것은 아닙니다.

수신 측 보안 강화

  • 파싱하기 전에 검증하세요. 서명이 확인될 때까지 본문은 믿을 수 없는 바이트로 다루세요.
  • 페이로드를 데이터로 믿지 말고 다시 읽으세요. 페이로드는 최소한의 정보만 담습니다. 핸들로 리소스를 읽어야 저장하는 내용이 지금의 스코프와 일치하고, 다시 재생된 예전 전송이 그사이 삭제(소거)된 데이터를 되살리지 못합니다.
  • 이벤트가 몰려올 수 있습니다. 고객 워크스페이스에서 대량 변경이 일어나면 이벤트가 한꺼번에 많이 생깁니다. 큐에 넣고, 응답은 빠르게 유지하세요.
  • 변경 피드를 안전망으로 유지하세요. 순서가 보장된 피드가 기준 데이터입니다. 푸시만 받는 리더는 빠진 부분을 복구할 방법이 없습니다.

관련 문서