> ## Documentation Index
> Fetch the complete documentation index at: https://docs.meterengine.com/llms.txt
> Use this file to discover all available pages before exploring further.

# 웹훅 서명 검증

> 받은 웹훅이 MeterEngine이 보낸 것인지 webhook-signature 헤더로 확인하는 방법과 그대로 옮겨 쓸 수 있는 검증 코드를 설명합니다.

수신 주소는 인터넷에 열려 있어서 누구나 그 주소로 요청을 보낼 수 있습니다. MeterEngine은 모든 웹훅에 수신 주소의 서명 비밀값으로 만든 서명을 붙이므로, 수신 서버는 서명이 맞는 요청만 처리해야 합니다. 서명 방식은 [Standard Webhooks](https://www.standardwebhooks.com) 사양의 HMAC-SHA256 서명(`v1`)을 따릅니다.

MeterEngine은 SDK를 배포하지 않으므로([MeterEngine 소개](https://docs.meterengine.com/index.md)) 이 페이지의 검증 코드를 그대로 옮겨 쓰거나, 아래 절차대로 다른 언어로 구현합니다. HMAC-SHA256과 base64를 쓸 수 있으면 어떤 언어든 됩니다.

서명 비밀값을 받는 방법은 [웹훅 받기 시작하기](https://docs.meterengine.com/webhooks/overview.md)를 보세요.

## 서명에 쓰는 헤더

| 헤더 | 예시 | 뜻 |
| - | - | - |
| `webhook-id` | `9b2f4c1e-3d5a-4f6b-8c7d-1e2f3a4b5c6d` | 웹훅 하나의 고유 값. 자동 재전송과 수동 재전송에도 같습니다 |
| `webhook-timestamp` | `1793840430` | 이번 시도에서 서명한 시각(Unix 초). 시도마다 새로 정해집니다 |
| `webhook-signature` | `v1,XXysjuF6zxqVGNUPTKAgnTf3uImiR2pIvr8vemeSz4Y=` | `v1,` 뒤에 base64로 쓴 서명. MeterEngine은 서명을 하나 보내지만, 사양은 공백으로 나뉜 여러 서명을 허용하므로 아래 코드는 그 경우도 처리합니다 |

다시 보낼 때마다 `webhook-timestamp`와 `webhook-signature`는 새로 만들어지고, `webhook-id`와 본문은 처음과 같습니다.

## 검증 절차

**1. 본문을 받은 바이트 그대로 보관한다**

서명은 MeterEngine이 보낸 본문의 바이트 그대로에 대해 만듭니다. JSON으로 파싱한 뒤 다시 문자열로 만들면 키 순서와 공백이 바뀌어 서명이 맞지 않습니다.

**2. 서명 헤더를 읽는다**

`webhook-id`, `webhook-timestamp`, `webhook-signature` 가운데 하나라도 없으면 거절합니다.

**3. 서명 시각을 확인한다**

`webhook-timestamp`가 숫자인지 보고, 수신 서버의 지금 시각과 차이가 허용 범위 안인지 확인합니다. 허용 범위는 수신 서버가 정합니다. 이 페이지의 예시는 300초(5분)입니다. 시도마다 서명 시각이 새로 정해지므로 재전송된 웹훅도 이 검사를 통과합니다. 수신 서버의 시계는 NTP로 맞춰 두세요.

**4. 서명 키를 만든다**

서명 비밀값에서 앞의 `whsec_`를 떼고 나머지를 base64로 디코드한 바이트가 HMAC 키입니다. 비밀값 문자열을 그대로 키로 쓰면 맞지 않습니다.

**5. 기대하는 서명을 계산한다**

아래 문자열을 HMAC-SHA256으로 서명하고 결과를 base64로 씁니다.

```text 서명할 내용 theme={null}
{webhook-id}.{webhook-timestamp}.{받은 본문 원문}
```

세 값은 마침표(`.`) 하나로 이어 붙입니다.

**6. 헤더의 서명과 비교한다**

`webhook-signature` 값을 공백으로 나누고, `v1,`로 시작하는 것마다 `v1,` 뒤의 값을 기대하는 서명과 비교합니다. 하나라도 같으면 통과입니다. 비교는 상수 시간 비교 함수(Node의 `timingSafeEqual` 같은 것)로 합니다. 일반 문자열 비교는 응답 시간으로 서명을 추측하는 공격에 약합니다.

**7. 통과한 뒤에만 본문을 처리한다**

서명이 맞으면 본문을 JSON으로 파싱하고, 본문의 `environment`가 이 수신 주소를 등록한 업무 환경과 같은지 확인한 뒤 처리합니다. 같은 `webhook-id`를 이미 처리했으면 처리하지 않고 2xx로 응답합니다. 중복을 거르는 방법은 [전송, 재전송, 중복 처리](https://docs.meterengine.com/webhooks/delivery-and-retries.md)를 보세요.

## 검증 코드

Node 18 이상의 ES 모듈(`.mjs` 파일이나 `package.json`의 `"type": "module"`)에서 별도 패키지 없이 동작합니다. 비밀값은 환경 변수 `METERENGINE_WEBHOOK_SECRET`에 넣었다고 가정합니다.

```javascript Node (node:http) theme={null}
import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = process.env.METERENGINE_WEBHOOK_SECRET; // whsec_ 로 시작하는 값
const TOLERANCE_SECONDS = 300;

function verifyMeterEngineWebhook(rawBody, headers, secret) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatureHeader = headers["webhook-signature"];
  if (!id || !timestamp || !signatureHeader) return false;

  if (!/^\d+$/.test(timestamp)) return false;
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) return false;

  const key = Buffer.from(secret.slice("whsec_".length), "base64");
  const expected = Buffer.from(
    createHmac("sha256", key)
      .update(`${id}.${timestamp}.`)
      .update(rawBody)
      .digest("base64"),
  );

  return signatureHeader.split(" ").some((candidate) => {
    if (!candidate.startsWith("v1,")) return false;
    const actual = Buffer.from(candidate.slice("v1,".length));
    return actual.length === expected.length && timingSafeEqual(actual, expected);
  });
}

createServer((req, res) => {
  if (req.method !== "POST" || req.url !== "/webhooks/meterengine") {
    res.writeHead(404).end();
    return;
  }
  const chunks = [];
  req.on("data", (chunk) => chunks.push(chunk));
  req.on("end", () => {
    const rawBody = Buffer.concat(chunks);
    if (!verifyMeterEngineWebhook(rawBody, req.headers, SECRET)) {
      res.writeHead(400).end();
      return;
    }
    const event = JSON.parse(rawBody.toString("utf8"));
    // webhook-id로 중복을 거르고, 처리는 큐에 넘긴 뒤 바로 응답합니다.
    res.writeHead(204).end();
  });
}).listen(8080);
```

```javascript Node (Request, Response) theme={null}
// Next.js 라우트 핸들러처럼 웹 표준 Request를 받는 환경
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 300;

function verifyMeterEngineWebhook(rawBody, headers, secret) {
  const id = headers.get("webhook-id");
  const timestamp = headers.get("webhook-timestamp");
  const signatureHeader = headers.get("webhook-signature");
  if (!id || !timestamp || !signatureHeader) return false;

  if (!/^\d+$/.test(timestamp)) return false;
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) return false;

  const key = Buffer.from(secret.slice("whsec_".length), "base64");
  const expected = Buffer.from(
    createHmac("sha256", key).update(`${id}.${timestamp}.`).update(rawBody).digest("base64"),
  );
  return signatureHeader.split(" ").some((candidate) => {
    if (!candidate.startsWith("v1,")) return false;
    const actual = Buffer.from(candidate.slice("v1,".length));
    return actual.length === expected.length && timingSafeEqual(actual, expected);
  });
}

export async function POST(request) {
  const rawBody = Buffer.from(await request.arrayBuffer());
  if (!verifyMeterEngineWebhook(rawBody, request.headers, process.env.METERENGINE_WEBHOOK_SECRET)) {
    return new Response(null, { status: 400 });
  }
  const event = JSON.parse(rawBody.toString("utf8"));
  // webhook-id로 중복을 거르고, 처리는 큐에 넘긴 뒤 바로 응답합니다.
  return new Response(null, { status: 204 });
}
```

```text 의사 코드 theme={null}
id        = 헤더 webhook-id
timestamp = 헤더 webhook-timestamp
header    = 헤더 webhook-signature
이 헤더 가운데 하나라도 없으면 거절

timestamp가 숫자가 아니거나 |지금(Unix 초) - timestamp| > 허용 범위 이면 거절

key      = base64_decode(비밀값에서 "whsec_"를 뗀 나머지)
expected = base64(HMAC_SHA256(key, id + "." + timestamp + "." + 원문 바이트))

header를 공백으로 나눈 각 값 s에 대해:
  s가 "v1,"로 시작하고 상수_시간_비교(s에서 "v1,"를 뗀 값, expected)가 참이면 통과
하나도 없으면 거절
```

> [!WARNING]
>
> 프레임워크의 JSON 본문 파서(예: Express의 `express.json()`)를 거친 뒤에는 원문이 사라집니다. 웹훅 경로에서는 본문을 바이트 그대로 받는 설정(예: `express.raw({ type: "application/json" })`)을 쓰고, 서명을 확인한 다음 직접 파싱하세요. 앞단의 프록시나 게이트웨이가 본문을 다시 쓰는지도 확인하세요.

## 검증에 실패했을 때

서명이 맞지 않으면 2xx가 아닌 상태 코드(예: `400`)로 응답하고 본문을 처리하지 마세요. MeterEngine은 2xx가 아닌 응답을 실패로 보고 정해진 일정에 따라 다시 보냅니다. 비밀값을 잘못 넣어 모든 시도가 실패했다면, 비밀값을 고친 뒤 '발송 실패'로 끝난 전송을 수동으로 다시 보냅니다. 재전송 일정과 수동 재전송은 [전송, 재전송, 중복 처리](https://docs.meterengine.com/webhooks/delivery-and-retries.md)를 보세요.

> [accordion] 서명이 계속 맞지 않는다
>
> * 본문을 파싱한 뒤 다시 직렬화하지 않았는지 확인하세요. MeterEngine이 보내는 본문은 아래처럼 키 순서가 문서의 설명 순서와 다르고 `:`와 `,` 뒤에 공백이 있습니다. 이 모양을 흉내 내지 말고 받은 바이트를 그대로 쓰세요.
>
>   ```json theme={null}
>   {"data": {"message": "Webhook connection test"}, "type": "webhook_test", "timestamp": "2026-11-05T01:00:02.118374Z", "environment": "test"}
>   ```
> * `whsec_`를 떼고 base64로 디코드한 바이트를 키로 썼는지 확인하세요.
> * 서명할 내용의 순서가 `webhook-id`, `webhook-timestamp`, 본문이고 마침표로 이었는지 확인하세요.
> * 비교할 때 헤더 값의 `v1,`를 떼었는지 확인하세요.
> * 수신 주소마다 비밀값이 다릅니다. 테스트 환경 수신 주소의 비밀값으로 실제 환경 웹훅을 검증하고 있지 않은지 확인하세요.

> [accordion] 시각 검사에서 거절된다
>
> 수신 서버의 시계가 어긋나 있으면 정상 요청도 허용 범위를 벗어납니다. 서버 시각을 NTP로 맞추세요. 서명 시각은 시도마다 새로 정해지므로 오래 기다린 재전송 때문에 거절되지는 않습니다.

> [accordion] 내 구현이 맞는지 받은 요청으로 확인하고 싶다
>
> 받은 요청의 헤더 값과 본문 원문을 파일로 남겨 두었다면, 아래 명령으로 기대하는 서명을 계산해 `webhook-signature`의 `v1,` 뒤 값과 비교할 수 있습니다(`openssl`, `xxd`, `base64`가 있는 셸).
>
> ```bash theme={null}
> SECRET='whsec_xxxxxxxx'                         # 서명 비밀값
> ID='9b2f4c1e-3d5a-4f6b-8c7d-1e2f3a4b5c6d'       # webhook-id 헤더
> TS='1793840430'                                 # webhook-timestamp 헤더
> KEY_HEX=$(printf '%s' "${SECRET#whsec_}" | base64 -d | xxd -p | tr -d '\n')
> { printf '%s.%s.' "$ID" "$TS"; cat body.json; } \
>   | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$KEY_HEX" -binary | base64
> ```
>
> `body.json`은 받은 본문을 한 바이트도 바꾸지 않고 저장한 파일이어야 합니다. 편집기로 열어 저장하면 끝에 줄바꿈이 붙어 값이 달라질 수 있습니다.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.