서명 및 검증

X-ListingsAPI-Signature 헤더를 검증하고, 엔드포인트 검증 챌린지에 응답하며, base64와 hex를 혼동하지 않도록 합니다.

웹훅 URL은 공개 HTTPS 엔드포인트이므로 인터넷에 연결된 누구나 이 URL로 POST 요청을 보낼 수 있습니다. 서명 비밀 키를 사용하면 정상 전송과 위조 전송을 구분할 수 있습니다. 서명된 모든 요청에는 수신자와 플랫폼만 보유한 비밀 키로 계산한 HMAC이 포함됩니다.

이 비밀 키는 서로 혼동하기 쉬운 2가지 별도 메커니즘에서 사용됩니다.

목적방향인코딩
요청 서명각 전송이 정상 전송임을 증명합니다헤더에 담아 수신자에게 보냅니다base64
검증 챌린지비밀 키를 보유하고 있음을 1회 증명합니다본문에 담아 회신합니다hex

서명 헤더

서명된 모든 전송에는 다음이 포함됩니다.

Text
X-ListingsAPI-Signature: sha256=<digest>   digest = base64( HMAC-SHA256(secret, raw_request_body) )

sha256= 접두사는 헤더 값의 일부입니다. 예상 문자열을 만들 때 이 접두사를 포함하거나, = 뒤의 부분만 비교합니다. 해싱 전에 본문 앞에 추가되는 값은 없습니다. 타임스탬프, 버전, 이어 붙인 서명 문자열도 추가되지 않습니다. HMAC은 원시 본문만을 대상으로 계산합니다.

단계별 검증

  1. 원시 요청 본문의 바이트를 수신한 그대로 읽습니다. JSON을 파싱한 후 다시 직렬화하지 않습니다. 키 순서, 공백, 유니코드 이스케이프는 모두 바이트를 변경하며, 어떤 변경이든 HMAC 검증 실패로 이어집니다. 대부분의 프레임워크에서는 원시 본문을 보존하도록 설정해야 합니다. 아래 예제를 참조합니다.
  2. HMAC-SHA256(your_secret, raw_body)을 계산하고 base64로 인코딩한 다음, 앞에 sha256=을 붙입니다.
  3. 상수 시간 비교(crypto.timingSafeEqual, hmac.compare_digest)를 사용하여 헤더와 비교합니다. 단순한 == 비교는 앞에서부터 몇 바이트가 일치했는지에 관한 타이밍 정보를 노출합니다.
  4. 일치하지 않으면 401을 반환하고 아무것도 처리하지 않습니다.
수신 측 검사입니다. 핵심은 2단계입니다. HMAC은 다시 인코딩한 데이터가 아니라 수신한 바이트를 대상으로 계산합니다.

엔드포인트 검증

실제 이벤트 전송을 시작하기 전에 엔드포인트가 비밀 키를 실제로 보유하고 있음을 1회 증명해야 합니다. 이렇게 하면 잘못 입력한 URL이나 서명된 페이로드를 검증할 수 없는 소유자의 URL로 서명된 페이로드가 계속 전송되는 것을 방지합니다.

대시보드의 Webhooks 페이지에서 검증을 시작합니다. 플랫폼은 서명된 챌린지를 보내고 응답을 최대 10초 동안 기다립니다. (이 10초는 핸드셰이크에만 적용되며, 실제 전송은 5초 후 시간 초과됩니다.)

핸드셰이크입니다. 서로 다른 형식에 유의합니다. 챌린지의 서명은 헤더에 base64로 포함되며, 회신은 응답 본문에 hex 다이제스트로 포함됩니다.

챌린지 규약

챌린지는 예약된 이벤트 이름을 사용하는 일반적인 서명된 요청입니다.

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

엔드포인트는 다음을 수행해야 합니다.

  1. 다른 모든 전송과 동일하게 X-ListingsAPI-Signature을 검증합니다.
  2. event == "endpoint.verification"을 인식하고 일반 이벤트 처리 전에 분기합니다.
  3. HMAC-SHA256(secret, nonce)을 소문자 16진수 문자열로 계산합니다. HMAC은 요청 본문이 아니라 nonce만을 대상으로 계산합니다.
  4. 해당 16진수 문자열을 응답 본문에 넣어 200으로 응답합니다. 별도 형식으로 감싸지 않은 16진수 문자열이 기본 형식이지만, 따옴표로 감싸거나 JSON 문자열로 표현한 16진수 값도 허용됩니다.

그 외의 경우(2xx가 아닌 상태 코드, 잘못된 다이제스트, 시간 초과, 연결할 수 없는 호스트)에는 검증에 실패합니다.

검증 상태가 초기화되는 경우

검증 상태는 영구적으로 유지되지 않습니다. 다음과 같은 경우 계정은 미검증 상태로 돌아가고 전송이 일시 중지됩니다.

  • 웹훅 URL을 변경하거나 시크릿을 다시 생성하는 경우입니다. 기존 검증 결과는 새 구성에 더 이상 적용되지 않습니다.
  • 검증 시도가 실패하는 경우입니다. 이전에는 정상적으로 작동했지만 이후 주소 확인이 되지 않거나 응답하지 않게 된 엔드포인트도 여기에 포함됩니다. 따라서 문제가 있는 엔드포인트에 대해 검증을 다시 실행하면 전송이 중지되며, 이는 의도된 동작입니다.

전송을 재개하려면 엔드포인트가 정상화된 후 다시 검증합니다.

작동하는 수신 처리 예제

두 예제 모두 원시 본문 유지, 상수 시간으로 서명 검증, 검증 챌린지에 응답이라는 3가지 작업을 모두 수행합니다. 두 예제 모두 환경에서 시크릿 1개를 읽습니다. 계정당 유효한 시크릿은 정확히 1개이며 기존 시크릿과 새 시크릿을 동시에 사용할 수 있는 기간이 없으므로, 2개의 시크릿 중 어느 하나라도 허용하는 반복문을 구현하지 마십시오.

Node.js (Express)
const crypto = require('crypto');const express = require('express'); const app = express();const SECRET = process.env.LISTINGSAPI_WEBHOOK_SECRET; // Keep the raw bytes. express.json() would otherwise leave you// with only the parsed object, and re-serializing it changes the// bytes the HMAC covers.app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }),); function signatureIsValid(rawBody, header) {const digest = crypto  .createHmac('sha256', SECRET)  .update(rawBody)  .digest('base64');const expected = 'sha256=' + digest; const a = Buffer.from(header || '', 'utf8');const b = Buffer.from(expected, 'utf8');// timingSafeEqual throws on a length mismatch, so check first.return a.length === b.length && crypto.timingSafeEqual(a, b);} // The only work allowed before the 200: put the raw bytes somewhere durable// and wait for the write to be acknowledged. Swap the INSERT for your real// queue or table write; it must be committed before you respond, because// nothing is retried if your process dies after the 200 goes out.async function storeForProcessing(rawBody, signature) {await db.query(  'INSERT INTO webhook_inbox (signature, body, received_at) VALUES ($1, $2, now())',  [signature, rawBody.toString('utf8')],);} app.post('/webhooks/listingsapi', async (req, res) => {const header = req.get('X-ListingsAPI-Signature');if (!signatureIsValid(req.rawBody, header)) {  return res.status(401).send('bad signature');} const body = req.body; // The verification handshake: answer with HEX, not base64.if (body.event === 'endpoint.verification') {  const answer = crypto    .createHmac('sha256', SECRET)    .update(body.nonce)    .digest('hex');  return res.status(200).send(answer);} // A real event: durable write first, acknowledge second.try {  await storeForProcessing(req.rawBody, header);} catch (err) {  // Nothing was persisted, so do not claim success.  return res.status(500).send('could not persist');}return res.status(200).json({ received: true });});
Python (Flask)
import base64import hashlibimport hmacimport os from flask import Flask, abort, request app = Flask(__name__)SECRET = os.environ["LISTINGSAPI_WEBHOOK_SECRET"].encode() HEADER = "X-ListingsAPI-Signature"  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)  def store_for_processing(raw_body: bytes, signature: str) -> None:  """The only work allowed before the 200: put the raw bytes somewhere  durable and return only once the write is committed. Swap the INSERT for  your real queue or table write - nothing is retried if the process dies  after the response goes out."""  with db.begin() as tx:      tx.execute(          "INSERT INTO webhook_inbox (signature, body) VALUES (%s, %s)",          (signature, raw_body),      )  @app.post("/webhooks/listingsapi")def webhooks():  raw = request.get_data()  # raw bytes, exactly as received  header = request.headers.get(HEADER)  if not signature_is_valid(raw, header):      abort(401)   body = request.get_json(silent=True) or {}   # The verification handshake: answer with HEX, not base64.  if body.get("event") == "endpoint.verification":      nonce = body["nonce"].encode()      return hmac.new(SECRET, nonce, hashlib.sha256).hexdigest(), 200   # A real event: durable write first, acknowledge second.  try:      store_for_processing(raw, header or "")  except Exception:      # Nothing was persisted, so do not claim success.      return {"error": "could not persist"}, 500  return {"received": True}, 200

테스트 벡터

고정된 입력과 올바른 구현이 생성하는 정확한 출력입니다. 실제 전송을 엔드포인트로 보내기 전에 이를 단위 테스트에 추가합니다.

시크릿(명백한 가짜 값이며 테스트 전용입니다):

Text
whsec_test_0123456789abcdef0123456789abcdef

원시 요청 본문이며, 다음 바이트와 정확히 일치해야 합니다:

Text
{"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"}}

예상 요청 서명이며, base64 형식에 sha256= 접두사가 붙습니다:

Text
X-ListingsAPI-Signature: sha256=+FxQAwiCAfQdicZcHlv9fNOrkjf0yK99Jje97VMS6Y4=

검증 챌린지에서 동일한 시크릿과 다음 nonce를 사용하는 경우:

Text
9f2c1e642b1a4b0e9f1a2c7d5e8a1b3c

엔드포인트는 다음 소문자 16진수 본문과 정확히 일치하는 본문을 담아 200으로 응답해야 합니다:

Text
d5aff73f9739b49aaabccf264c8777a697998ba07c493fbaec646b5370354145

두 결과를 모두 재현하려면 위의 본문 1줄을 끝에 줄바꿈 없이 body.json에 저장한 다음(printf '%s' '...' > body.json), 다음을 실행합니다:

Python
import base64, hashlib, hmac secret = b"whsec_test_0123456789abcdef0123456789abcdef"body = open("body.json", "rb").read()          # the 209 bytes abovenonce = b"9f2c1e642b1a4b0e9f1a2c7d5e8a1b3c" # request signature: base64, over the raw bodyprint("sha256=" + base64.b64encode(hmac.new(secret, body, hashlib.sha256).digest()).decode())# challenge answer: lowercase hex, over the nonce aloneprint(hmac.new(secret, nonce, hashlib.sha256).hexdigest())

첫 번째 줄이 위의 헤더 값과 일치하지 않으면 HMAC이 아니라 바이트가 잘못된 것입니다. 먼저 본문 길이를 확인합니다.

다음

  • 전송 동작: 표준 페이로드, 5초 제한 시간 및 모든 전송 상태입니다.
  • 이벤트 참조: 전체 18개 이벤트와 각 이벤트의 페이로드입니다.