Webhook으로 이벤트에 대응하기

웹훅 URL을 설정하고, 검증 챌린지를 통과하고, 모든 서명을 검증하고, 5초 이내에 수신을 확인하고, 폴링으로 누락된 이벤트를 복구합니다.

웹훅은 계정에서 리뷰가 도착하거나, 등록 정보가 공개되거나, 게시물이 게시되는 등의 변경이 발생하면 서버로 HTTP POST를 전송합니다. 계정당 URL은 1개이며 이벤트별 구독은 없으므로, 하나의 수신기가 문서에 명시된 18개 이벤트를 모두 수신하고 event 필드에 따라 분기합니다.

1. 대시보드에서 URL과 비밀 키 설정하기

먼저 2단계와 3단계의 수신기를 구현합니다. 비밀 키를 생성하면 서명이 필수가 되고 엔드포인트가 검증을 통과할 때까지 전송이 일시 중지되므로, 준비되지 않은 엔드포인트는 모든 이벤트의 전송을 차단합니다.

  1. HTTPS 엔드포인트를 배포합니다. 사설 주소는 거부되며 리디렉션을 따르지 않으므로, www 리디렉션이나 후행 슬래시 리디렉션을 거치지 않는 최종 URL을 저장합니다. 2. 대시보드의 웹훅 페이지에서 URL을 저장합니다. 3. 서명 비밀 키를 생성합니다. 이 키는 1번만 표시됩니다. 서버에 LISTINGSAPI_WEBHOOK_SECRET로 저장합니다. 4. Verify endpoint(엔드포인트 검증)를 클릭합니다. 플랫폼은 2단계의 챌린지를 전송하고 최대 10초 동안 기다립니다.

URL을 변경하거나 비밀 키를 다시 생성하면 검증 상태가 초기화되므로, 먼저 새 비밀 키를 배포한 다음 다시 검증합니다. 자세한 내용은 웹훅 개요에 있습니다.

2. 검증 챌린지에 응답하기

챌린지는 예약된 이벤트 이름과 nonce를 포함하는 서명된 POST입니다. nonce만으로 계산한 HMAC-SHA256을 소문자 16진수로 표현하여 200으로 응답합니다.

JSON
{  "event": "endpoint.verification",  "nonce": "9f2c1e642b1a4b0e9f1a2c7d5e8a1b3c",  "timestamp": "2026-08-25T14:32:10Z"}

3. 전송된 모든 요청의 서명 검증하기

서명된 모든 요청에는 X-ListingsAPI-Signature: sha256=<base64 HMAC-SHA256 of the raw body>가 포함됩니다. 수신한 바이트 그대로 동일한 다이제스트를 계산하고, 상수 시간 비교를 수행하며, 일치하지 않으면 401을 반환합니다. JSON을 먼저 파싱한 뒤 다시 직렬화하지 않습니다. 이렇게 하면 바이트가 변경됩니다. 아래의 두 수신기는 모두 원본 본문을 보존하고, 서명을 검증하고, 챌린지에 응답하며, 이벤트에 대한 응답을 반환하기 전에 이벤트를 영구 저장합니다.

Flask receiver
import base64import hashlibimport hmacimport os from flask import Flask, abort, request app = Flask(__name__)SECRET = os.environ["LISTINGSAPI_WEBHOOK_SECRET"].encode()  def signature_is_valid(raw_body: bytes, header: str | None) -> bool:  digest = hmac.new(SECRET, raw_body, hashlib.sha256).digest()  expected = "sha256=" + base64.b64encode(digest).decode()  return hmac.compare_digest(header or "", expected)  @app.post("/webhooks/listingsapi")def webhooks():  raw = request.get_data()  # raw bytes, exactly as received  if not signature_is_valid(raw, request.headers.get("X-ListingsAPI-Signature")):      abort(401)   body = request.get_json(silent=True) or {}   # One-time handshake: hex digest of the nonce, not the body.  if body.get("event") == "endpoint.verification":      return hmac.new(SECRET, body["nonce"].encode(), hashlib.sha256).hexdigest(), 200   # Real event: durable write first, then acknowledge. Swap for your queue.  inbox.put(raw)  # must be committed before the 200 goes out  return {"received": True}, 200
Express receiver
import crypto from 'crypto';import express from 'express'; const app = express();const SECRET = process.env.LISTINGSAPI_WEBHOOK_SECRET as string; // express.raw keeps req.body as the untouched Buffer the HMAC covers.app.post('/webhooks/listingsapi', express.raw({ type: '*/*' }), async (req, res) => {const raw: Buffer = req.body;const digest = crypto.createHmac('sha256', SECRET).update(raw).digest('base64');const expected = Buffer.from('sha256=' + digest);const given = Buffer.from(req.get('X-ListingsAPI-Signature') ?? '');if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) {  return res.status(401).send('bad signature');} const body = JSON.parse(raw.toString('utf8')); // One-time handshake: hex digest of the nonce, not the body.if (body.event === 'endpoint.verification') {  const answer = crypto.createHmac('sha256', SECRET).update(body.nonce).digest('hex');  return res.status(200).send(answer);} // Real event: durable write first, then acknowledge. Swap for your queue.await inbox.put(raw); // must be committed before the 200 goes outreturn res.status(200).json({ received: true });});

계정에서 비밀 키를 생성한 적이 없으면 서명되지 않은 요청이 전송됩니다. 이 모드를 전제로 구현하지 말고, 위의 수신기가 가동되면 비밀 키를 생성합니다.

4. 수신을 빠르게 확인하고 비동기로 처리하기

이벤트 전송은 연결 타임아웃 5초와 읽기 타임아웃 5초가 적용되는 POST 요청 1개이며, 정확히 1번만 시도됩니다. 재시도, 백오프, 재전송은 없습니다. 핸들러 처리에 6초가 걸리거나 서버가 2분 동안 중단되면 해당 이벤트는 유실됩니다. 2xx만 전송 완료로 간주되며, 3xx, 4xx, 5xx는 실패로 간주됩니다.

따라서 요청 처리 경로에서는 검증, 원본 본문을 큐나 테이블에 기록하고 커밋 대기, 200 반환이라는 3가지 작업만 수행합니다. 데이터 보강, REST API에 대한 후속 호출, 외부 서비스를 호출하는 모든 작업은 큐에서 실행합니다. listing.submission을 제외한 모든 이벤트는 동일한 페이로드 구조를 사용합니다:

JSON
{  "event": "connection.location_connected",  "timestamp": "2026-08-25T14:32:10Z",  "account_id": 11073,  "location_id": "279381",  "data": { "platform": "google", "connected_account_id": "ba03bc4b-9f8c-4d3b-8e93-9628034c63cc" }}

location_id는 숫자로 이루어진 문자열이지만, interaction.* 이벤트에서는 정규화되지 않은 상태로 전달되며 null일 수 있습니다. listing.submission에는 timestamp나 account_id가 없으며, status와 live_link가 최상위 수준에 배치됩니다. data의 선택적 필드는 null로 전송되지 않고 생략됩니다. 문서에 명시되지 않은 이벤트 계열도 동일한 URL을 사용하므로, 인식하지 못하는 모든 event에 대해 200을 반환합니다.

5. 중복을 제거하고 순서가 뒤바뀐 전달 처리하기

같은 이벤트를 2번 수신할 수 있으며, 같은 리소스 id는 여러 번 수신하게 됩니다. 리뷰가 변경될 때마다 interaction.review가 동일한 상호작용 id로 다시 발생하며, 어떤 페이로드에도 버전은 포함되지 않습니다. 따라서 id와 이벤트 이름의 조합을 기준으로 이벤트를 무시해서는 안 됩니다. 그러면 실제 수정 사항이 누락됩니다. 원본 본문을 해싱하고 짧은 시간 안에 반복 수신된 본문을 무시하여 완전히 동일한 중복을 감지한 다음, 리소스 id를 쓰기 작업의 키로 사용하여 반복 처리해도 같은 결과가 나오도록 합니다.

Exact-duplicate check
import hashlibimport time SEEN: dict[str, float] = {}  # use Redis with a TTL in productionWINDOW_SECONDS = 600 def is_duplicate(raw_body: bytes) -> bool:  key = hashlib.sha256(raw_body).hexdigest()  now = time.time()  for k, seen_at in list(SEEN.items()):      if now - seen_at > WINDOW_SECONDS:          del SEEN[k]  if key in SEEN:      return True  SEEN[key] = now  return False

순서도 보장되지 않습니다. 이벤트는 서로 독립적인 파이프라인에서 발생하므로 local_post.published가 local_post.created보다 먼저 도착할 수 있으며, timestamp에는 변경이 발생한 시점이 아니라 전달 데이터가 구성된 시점이 기록됩니다. 순서가 중요하다면 REST API에서 리소스를 다시 조회하고 그 조회 결과를 신뢰합니다. 또한 profile.updated에는 디바운스가 적용되어 사업장별로 60초당 이벤트가 1개로 제한되므로, 이벤트가 도착했을 때 다시 조회하면 첫 번째 수정 사항만 확인할 수 있습니다. 해당 시간 구간이 끝난 후 다시 조회합니다.

event를 기준으로 분기하고 각 이벤트를 해당 가이드에 따라 작성한 코드로 전달합니다. 웹훅은 언제 발생했는지 알려 주고, 해당 가이드의 엔드포인트는 무엇이 발생했는지 알려 줍니다.

이벤트발생한 일참고할 가이드
profile.created사업장이 생성되었습니다. data.source는 user, api 또는 bulk_edit입니다.사업장 추가하기
profile.updated사업장이 수정되었습니다. data.changed_fields는 참고 정보이며, 전체 변경 내역은 아닙니다.사업장 업데이트하기
profile.deleted사업장이 보관 처리되었습니다.사업장 검색 및 보관 처리
listing.submission게시 플랫폼 제출 결과가 확정되었습니다. status는 success, incomplete 또는 canceled입니다.등록 정보 상태 및 실제 게시 링크 확인하기
connection.location_connected, connection.location_disconnectedGoogle 또는 Facebook 등록 정보가 연결되거나 연결 해제되었습니다.Google 연결 및 사업장 가져오기, Facebook 페이지 연결하기
connection.reauth_required, connection.listing_inaccessible자격 증명이 만료되었거나 등록 정보에 대한 접근 권한을 잃었습니다.Google 연결 및 사업장 가져오기
connection.google_verification_verified, connection.google_verification_failedGoogle 인증이 완료되었습니다.Google Business Profile 생성하기
interaction.review리뷰가 도착하거나 변경되었습니다. 필드는 data.interaction 아래에 있습니다.리뷰 가져오기
interaction.response소유자 답글이 게시, 수정 또는 보관 처리되었습니다. parent_id는 리뷰입니다.리뷰에 답글 작성하기
local_post.created, local_post.published, local_post.rejected, local_post.deleted게시물 항목의 상태가 변경되었습니다. 사업장마다 1개씩 있는 data.post_item_id를 키로 사용합니다.게시물 게시하기
review_analytics.daily_snapshot, review_analytics.weekly_snapshot하나의 사업장과 기간에 대해 리뷰 집계가 실행되었습니다.리뷰 분석

모든 이벤트의 필드 표와 샘플 페이로드는 이벤트 참조에 있습니다.

7. 폴링으로 누락된 이벤트 복구하기

재시도가 없으므로 모든 이벤트 유형에는 대체 수단으로 사용할 수 있는 신뢰할 수 있는 조회 방법이 필요합니다. 문제가 있어 보일 때만이 아니라 정해진 일정에 따라 데이터를 대조하고 일치시키며, 이벤트가 도착하지 않았다는 이유로 0이나 "변경 없음"을 기록해서는 안 됩니다.

이벤트 유형다시 조회하는 방법주기
listing.submissionsyncStatus 및 listingUrl에는 GET /locations/{locationId}/listings/premium를 사용합니다.사업장별로 매일 실행합니다.
profile.*GET /locations 또는 GET /locations/search를 사용합니다. updatedAt은 무엇이 변경되었는지 보여 줍니다.매시간 실행하며, 각 profile.updated의 60초 후에도 실행합니다.
connection.*GET /connected-accounts와 사업장 조회 시 포함되는 googleVerificationStatus를 사용합니다.매시간 실행하며, 인증 중에는 몇 분마다 실행합니다.
interaction.*계정 전체를 대상으로는 GET /rollup_interactions를 사용하고, id별로는 GET /reviewDetails를 사용합니다.새 리뷰는 15분마다 확인하고, 전체 확인은 매일 실행합니다.
local_post.*GET /posts/{postId} 또는 GET /locations/{locationId}/posts를 사용합니다.게시물이 처리 중인 동안 매시간 실행합니다.
review_analytics.*해당 기간의 날짜를 지정하여 GET /locations/{locationId}/review-analytics-overview를 사용합니다.집계 후 매일 실행합니다.

대시보드는 모든 시도를 상태와 함께 기록합니다. success는 전달되었음을 의미합니다. HTTP 코드, timeout, network_error 또는 error는 요청이 엔드포인트에 도달했지만 실패했음을 의미합니다. blocked_url, endpoint_unverified 및 rate_limited는 요청 자체가 이루어지지 않았음을 의미하므로, 코드가 아니라 설정이나 요금제를 수정해야 합니다. 전체 목록은 전달 동작에 있습니다.

다음 단계