웹훅 수신하기: Narwhal 대상 설정

HTTPS URL과 원하는 국가 및 계열로 웹훅 대상을 만든 다음, 모든 전송의 서명을 검증한 뒤에 신뢰하세요. 계정마다 최대 25개의 대상을 보관할 수 있으며, 전송 1회당 호출 1회 비용이 듭니다.

대상을 만들고 전송 받기

Narwhal은 모든 데이터 API에서 계정 하나와 API 키 하나를 사용하며, REST와 MCP도 같은 키를 공유합니다. 키는 NARWHAL_API_KEY 환경 변수에 보관하고 Authorization: Bearer YOUR_KEY로 https://api.narwhalapi.com에 보내세요.

  1. 대상 만들기 HTTPS URL, 국가 코드, 패밀리 슬러그를 POST /v1/webhooks/destinations에 보내세요. URL은 최대 2,048자까지 쓸 수 있고, alpha-3 국가 코드 또는 WLD를 1~60개, holidays, predictions, filings 등 패밀리 슬러그를 1~14개 고릅니다. 요청에 enabled: true도 넣을 수 있습니다. curl 요청은 prices와 labour 패밀리에 대해 USA와 MEX를 선택합니다. 예시의 HTTPS URL은 전송을 받을 자신의 수신 URL로 바꾸세요.
    웹훅 대상 만들기
    curl -s -X POST https://api.narwhalapi.com/v1/webhooks/destinations \
      -H "Authorization: Bearer $NARWHAL_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{"url":"https://example.com/narwhal",
           "countries":["USA","MEX"],"families":["prices","labour"]}'
  2. 대상 시크릿 저장하기 응답에는 destination_id와 secret이 들어 있으며, secret은 한 번만 표시됩니다. 수신기에서 NARWHAL_WEBHOOK_SECRET을 이 시크릿으로 설정하세요. destination_id는 테스트 전송 요청을 포함해 이 대상을 지정하는 요청에 쓰이므로 보관해 두세요. 전송 서명을 계산할 때는 반환된 시크릿을 사용하고, API 키는 API 요청의 authorization 헤더에 계속 사용합니다. rotate-secret으로 받은 시크릿도 한 번만 표시되며, 교체 후 24시간 동안은 서명 헤더에 이전 시크릿으로 서명한 두 번째 값이 함께 들어 있습니다.
  3. 처리 전에 서명 검증하기 아래 수신기는 서명을 상수 시간으로 비교하고 5분보다 오래된 타임스탬프를 거부합니다. 서명 값 중 하나라도 일치하면 전송을 받아들이세요. Python SDK에도 from narwhalapi.webhooks import verify 검증 함수가 있습니다. 먼저 2xx를 빠르게 반환하고 실제 처리는 그 뒤에 하세요.
    Python(Flask), 서명을 검증하는 수신기
    import hashlib, hmac, json, os, time
    from flask import Flask, abort, request
    
    app = Flask(__name__)
    SECRET = os.environ[
        "NARWHAL_WEBHOOK_SECRET"
    ]  # returned once when you create the destination
    seen = set()  # use a database table in production
    
    
    @app.post("/narwhal")
    def narwhal():
        raw = request.get_data()  # exact bytes, before any JSON parsing
        stamp = request.headers.get("x-narwhal-timestamp", "")
        if not stamp.isdigit() or abs(time.time() - int(stamp)) > 300:
            abort(400)  # missing, or older than five minutes
        expected = (
            hmac.new(SECRET.encode(), f"{stamp}.".encode() + raw, hashlib.sha256)
            .hexdigest()
            .encode()
        )
        header = request.headers.get("x-narwhal-signature", "")
        sent = [part.strip().removeprefix("sha256=").encode() for part in header.split(",")]
        if not any(hmac.compare_digest(expected, candidate) for candidate in sent):
            abort(401)  # a missing or malformed header lands here, not in a 500
        try:
            event = json.loads(raw)
        except ValueError:
            abort(400)
        if not isinstance(event, dict):
            abort(400)
        if event.get("event_id") in seen:  # retries and replays repeat the same event_id
            return "", 200
        seen.add(event.get("event_id"))
        print(event.get("type"), event.get("country"), event.get("family"))
        return "", 200
  4. 테스트 전송 요청하기 저장된 대상 ID로 POST /v1/webhooks/destinations/{destination_id}/test를 호출하세요. 테스트 전송에는 is_test: true가 있고 커서는 없습니다. 전송 상태는 pending, leased, succeeded, failed, cancelled입니다. 한도를 넘겨 대상을 만들면 409 limit_reached가 반환됩니다.

이벤트 유형에는 release.scheduled, release.rescheduled, release.cancelled, release.updated, dataset.published, dataset.revised, yield_curve.published, yield_curve.revised, holiday.calendar.revised가 있습니다. 예측은 prediction.event.closed와 prediction.event.settled, 공시는 filing.published입니다. 이벤트를 처리할 때는 페이로드의 type, country, family 필드를 사용하세요.

타임스탬프가 없거나 유효하지 않을 때, 그리고 본문을 JSON 객체로 파싱할 수 없을 때 수신기는 400을 반환합니다. 서명이 없거나 형식이 잘못되면 401을 반환합니다. 서명 비교에 성공하면 수신기는 본문을 파싱한 뒤 이벤트 필드를 읽고 이벤트 ID를 기록합니다.

관심 기업의 새 공시 받기

대상에 filings 계열과 companies 목록을 추가하세요. 그러면 해당 기업이 공시를 제출할 때마다 Narwhal이 filing.published 이벤트를 보냅니다. 13D 또는 13G 지분 공시의 경우, 목록에 있는 기업이 지분 대상 기업일 때도 이벤트를 보냅니다. 계정당 최대 1,000개 기업을 모니터링할 수 있습니다.

Apple 공시 지켜보기
curl -s -X POST https://api.narwhalapi.com/v1/webhooks/destinations \
  -H "Authorization: Bearer $NARWHAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/narwhal","countries":["USA"],
       "families":["filings"],"companies":["usa:0000320193"]}'

기업은 Filings API의 company_id로 지정합니다. 예를 들어 Apple은 usa:0000320193입니다. filings 패밀리에는 companies 목록이 필수이며, 없으면 거부됩니다. 알 수 없는 기업을 지정하면 400이 반환되고 찾지 못한 ID가 표시됩니다. 요청에는 여전히 countries가 필요하지만, 국가로 공시가 좁혀지지는 않습니다.

공시 이벤트 페이로드
{
  "schema_version": "1",
  "event_id": "...",
  "cursor": "1",
  "type": "filing.published",
  "country": "USA",
  "recorded_at": "2026-10-30T21:05:12Z",
  "filing": {
    "filing_id": "0000320193-26-000106",
    "company_id": "usa:0000320193",
    "form": "10-K",
    "type": "annual",
    "fy": 2026,
    "fp": "FY",
    "period_end": "2026-09-26",
    "filed_at": "2026-10-30T21:01:40Z"
  }
}

filing.filing_id로 Filings API에서 공시를 가져오세요. fy, fp, period_end는 대부분의 이벤트 공시와 지분 보유 공시처럼 해당 값이 없으면 생략됩니다.

코드 없이 웹훅 수신하기

Zapier Catch Hook, Make Custom webhook, n8n Webhook 노드가 대상 URL이 될 수 있습니다. 그 URL을 대상의 url 필드에 넣고 플로에 맞는 국가와 패밀리를 고르세요. Catch Hook URL은 비밀로 유지하세요. 이런 노코드 대상은 Narwhal 서명을 대신 검증해 주지 않습니다.

서명 검증에는 위 Python 수신기 같은 코드가 필요합니다. 검증이 필요하다면 들어오는 전송에 그 수신기를 사용하세요. 웹훅 레퍼런스에는 대상, 전송 기록, 재전송 작업이 있고, 이벤트 레퍼런스에는 커서부터 이벤트를 읽는 REST 저널이 설명되어 있습니다.

수신기와 폴링 중 고르기

상황적합한 방법이유
직접 운영하는 서비스Python 수신기서명을 검증하고 반복된 이벤트 ID를 처리합니다
노코드 플로Zapier, Make 또는 n8n catch hook대상 URL을 제공합니다. 이 URL은 비밀로 유지하세요
전송 대신 폴링GET /v1/events 또는 경제 캘린더저널은 커서를 기준으로 읽고 캘린더는 날짜 단위로 다룹니다

웹훅 원본 본문을 검증하는 이유는 무엇인가요?

서명은 본문의 정확한 바이트를 대상으로 하므로, 파싱한 뒤 다시 직렬화하면 비교하는 내용이 바뀝니다. x-narwhal-timestamp를 Unix 초로 읽고, 그 값과 마침표, 원본 본문을 이어 붙입니다. UTF-8로 인코딩한 시크릿으로 소문자 16진수 HMAC-SHA256을 계산하세요.

x-narwhal-signature 헤더에는 sha256=<hex>가 들어 있습니다. 수신기처럼 JSON을 파싱하기 전에 계산한 값과 전달된 서명을 상수 시간으로 비교하세요.

웹훅 서버가 중단되어 있었다면 어떻게 하나요?

전송은 재시도되며, 전송 기록에서 실패를 확인하고 재전송할 수 있습니다. 대상의 기록은 GET .../deliveries로 조회하고, 다시 받고 싶은 전송에는 POST .../deliveries/{delivery_id}/replay를 사용하세요. 재전송은 같은 이벤트를 반복합니다.

저장해 둔 위치부터 따라잡으려면 마지막 커서부터 GET /v1/events를 읽으세요. 이 커서는 저널 경로에서 쓰도록 보관해 두세요. 테스트 전송 작업은 커서를 제공하지 않습니다.

같은 이벤트를 두 번 받을 수 있나요?

네. 재시도와 재전송은 같은 event_id를 반복합니다. 이를 멱등성 키로 쓰고, 이벤트를 처리하기 전에 저장하세요. 운영 환경에서는 수신기 코드의 주석처럼 저장된 ID에 데이터베이스 테이블을 사용하세요.

예시 수신기는 ID를 seen에 보관합니다. ID가 이미 있으면 이벤트를 다시 출력하지 않고 200을 반환합니다. 처음 보는 ID라면 ID를 추가한 뒤 이벤트의 type, country, family를 출력합니다.

실제 예시: 전송 한 건 수신하기

2026-09-25T15:44:56Z에 기록된 이벤트에서 페이로드는 dataset.published, 국가 MEX, 패밀리 labour를 나타냅니다. 스키마 버전, 이벤트 ID, 커서, 리소스 ID, 리소스 버전도 함께 들어 있습니다.

경제 이벤트 페이로드
{
  "schema_version": "1",
  "event_id": "...",
  "cursor": "1",
  "type": "dataset.published",
  "country": "MEX",
  "family": "labour",
  "resource_id": "...",
  "resource_version": "...",
  "recorded_at": "2026-09-25T15:44:56Z"
}

전송은 타임스탬프와 서명 헤더와 함께 도착합니다. 수신기는 타임스탬프를 읽어 경과 시간이 300초 미만인지 확인한 뒤 <timestamp>.<body>의 HMAC을 다시 계산합니다. 서명이 일치하면 페이로드를 파싱하고 event_id를 조회합니다. 처음 보는 이벤트라면 ID를 저장하고 아래 필드를 출력한 뒤 200을 반환합니다.

수신기 출력
dataset.published MEX labour

에 게시되었습니다.

다음 단계