# 웹훅

> 서명된 웹훅 페이로드, HMAC-SHA256 서명 검증, 재시도 일정, 전송 로그.

웹훅은 수락된 제출을 도착하는 즉시 여러분의 서버로 보냅니다. 전송은 [Standard Webhooks](https://www.standardwebhooks.com) 방식으로 서명되고, 약 3일간 재시도되며, 대시보드에 시도별로 기록됩니다. 바로 쓸 수 있는 핸들러는 [웹훅 받기](https://ikiform.com/ko/docs/guides/webhook-handlers.md)에 있습니다.

## 엔드포인트 추가하기

1. 대시보드에서 **Webhooks**를 열고 **Add endpoint**를 고르세요.
2. HTTPS URL을 입력하고, 폼 하나 또는 워크스페이스의 모든 폼을 고르세요.
3. 서명 시크릿을 복사하세요. `whsec_`로 시작하며 전체 값은 한 번만 표시됩니다.
4. 테스트 이벤트를 보내 핸들러를 확인하세요.

### 엔드포인트 URL

- `https:`만 가능하며, 기본 포트 443을 사용하고, URL에 사용자 이름이나 비밀번호가 없어야 하며, 최대 2,048자입니다.
- 공개 호스트 이름이어야 합니다. IP 주소, `localhost`, `.local`, `.internal`, `.intranet`, `.lan`, `.home.arpa`, `.onion`으로 끝나는 이름은 거부되며, ikiform.com, workers.dev, pages.dev도 거부됩니다.
- 호스트 이름은 공개 주소로만 resolve되어야 합니다. 사설, 루프백, 링크 로컬, 캐리어급 NAT 대역은 거부됩니다.
- URL은 저장할 때와 매번 전송하기 전에 다시 확인합니다. 사설 주소로 resolve되기 시작한 엔드포인트는 비활성화됩니다.
- 워크스페이스에는 엔드포인트를 최대 3개까지 둘 수 있습니다. Enterprise는 제한이 없습니다.

## 이벤트

| 타입 | 전송 시점 | 재시도 |
| --- | --- | --- |
| `submission.created` | 제출이 수락되거나, 스팸 제출이 스팸이 아님으로 표시될 때 | 예 |
| `endpoint.test` | 엔드포인트 페이지에서 **Send test**를 고를 때 | 아니요, 한 번만 시도 |

스팸은 웹훅을 일으키지 않습니다. 워크스페이스가 월간 한도를 넘어 보류된 제출도 마찬가지입니다.

## 요청

각 전송은 JSON 본문과 서명 헤더 세 개를 담은 `POST`이며, Ikiform 전송 서비스에서 사용자 에이전트 `Ikiform-Webhooks/1.0`으로 보냅니다.

`Headers`:

```http
POST /webhooks/ikiform HTTP/1.1
content-type: application/json
user-agent: Ikiform-Webhooks/1.0
webhook-id: dlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp: 1792300000
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
```

| 헤더 | 값 |
| --- | --- |
| `webhook-id` | 전송 ID. 같은 전송을 재시도할 때마다 같으므로 중복을 무시하는 데 사용하세요 |
| `webhook-timestamp` | 이번 시도가 서명된 Unix 초. 시도할 때마다 새로 생성 |
| `webhook-signature` | `v1,` 뒤에 base64 HMAC-SHA256. 시크릿을 교체하는 동안에는 공백으로 구분된 값이 여러 개 |

### 페이로드

**submission.created** (`Body`)

```json
{
  "id": "dlv_01k7v2d4n8r2p6s0w3y5b7c9e1",
  "type": "submission.created",
  "timestamp": "2026-10-17T12:00:00.000Z",
  "api_version": "2026-10-17",
  "data": {
    "submission_id": "sub_01k7v2c9t3e8m5q4x6z0a1b2c3",
    "form": { "id": "frm_xxxxxxxxxxxxxxxx", "name": "Contact" },
    "fields": { "name": "Ada", "email": "ada@example.com", "message": "Hello" },
    "meta": {
      "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
      "origin": "https://example.com",
      "referrer": "https://example.com/contact",
      "country": "GB",
      "subject": null,
      "reply_to": "ada@example.com"
    },
    "created_at": "2026-10-17T12:00:00.000Z",
    "truncated": false
  }
}
```

**endpoint.test** (`Body`)

```json
{
  "id": "dlv_01k7v2f0a6c8e2g4j6m8p0r2t4",
  "type": "endpoint.test",
  "timestamp": "2026-10-17T12:00:00.000Z",
  "api_version": "2026-10-17",
  "data": {
    "endpoint_id": "whk_01k7v2e1b3d5f7h9k1m3n5q7s9",
    "message": "This is a test event from Ikiform."
  }
}
```

**Truncated** (`Over 256 KB`)

```json
{
  "id": "dlv_01k7v2d4n8r2p6s0w3y5b7c9e1",
  "type": "submission.created",
  "timestamp": "2026-10-17T12:00:00.000Z",
  "api_version": "2026-10-17",
  "data": {
    "submission_id": "sub_01k7v2c9t3e8m5q4x6z0a1b2c3",
    "form": { "id": "frm_xxxxxxxxxxxxxxxx", "name": "Contact" },
    "fields": {},
    "meta": { "origin": "https://example.com", "country": "GB" },
    "created_at": "2026-10-17T12:00:00.000Z",
    "truncated": true
  }
}
```

| 필드 | 설명 |
| --- | --- |
| `id` | 전송 ID, `webhook-id`와 같음 |
| `type` | `submission.created` 또는 `endpoint.test` |
| `timestamp` | 전송이 생성된 시각, ISO 8601 UTC. 재시도해도 같음 |
| `api_version` | 엔드포인트를 만들 때의 페이로드 버전, 현재 `2026-10-17` |
| `data.submission_id` | 제출 ID. [REST API](https://ikiform.com/ko/docs/api.md#get-submission)로 조회하세요 |
| `data.form` | 폼의 `id`와 `name` |
| `data.fields` | 저장된 값. 필드가 정의되어 있으면 검증되고 변환된 값 |
| `data.meta` | REST API의 `meta`와 같은 키. 모든 키가 항상 있으며 어떤 값이든 `null`일 수 있습니다. 방문자의 IP와 그 해시는 절대 보내지 않습니다 |
| `data.meta.user_agent` | 브라우저의 사용자 에이전트, 최대 512자 |
| `data.meta.origin`, `referrer` | 브라우저가 보낸 경우, POST가 온 곳 |
| `data.meta.country` | Cloudflare가 제공하는 두 글자 국가 코드 |
| `data.meta.subject`, `reply_to` | `_subject`와 `_replyto` 필드에서 가져옴 |
| `data.created_at` | 제출이 저장된 시각 |
| `data.truncated` | 본문이 256 KB를 넘을 경우 `true`, 이때 `fields`는 비어 있음 |

`meta` 값은 모두 `null`일 수 있습니다. 필드 값은 여러분의 서버로 오는 폼 POST처럼 신뢰할 수 없는 입력으로 다루세요.

## 서명 검증하기

서명은 `{webhook-id}.{webhook-timestamp}.{raw body}`의 HMAC-SHA256이며, 시크릿에서 `whsec_` 뒷부분을 base64 디코딩한 값을 키로 사용합니다. 결과를 base64로 인코딩해 `webhook-signature`의 `v1,` 뒤에 담아 보냅니다.

- 받은 원본 요청 본문을 그대로 사용하세요. JSON을 파싱했다가 다시 직렬화하면 바이트가 바뀝니다.
- 헤더에는 공백으로 구분된 서명이 여러 개 있을 수 있습니다. `v1` 서명 중 하나라도 일치하면 요청을 받아들이세요.
- 상수 시간으로 비교하고, 현재 시각과 5분 넘게 차이 나는 타임스탬프는 거부하세요.

### SDK 사용

`verifyWebhook`은 Web Crypto로 위의 모든 과정을 수행하고 파싱된 이벤트를 반환하거나, `reason`이 담긴 `WebhookVerificationError`를 던집니다. `Headers` 객체나 Node의 `req.headers` 같은 일반 객체를 받습니다.

`app/api/webhooks/ikiform/route.ts`:

```ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

export async function POST(request: Request) {
  const payload = await request.text();
  try {
    const event = await verifyWebhook({
      payload,
      headers: request.headers,
      secret: process.env.IKIFORM_WEBHOOK_SECRET!,
    });
    if (event.type === "submission.created") {
      console.log(event.data.fields);
    }
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof WebhookVerificationError) {
      return new Response(error.reason, { status: 400 });
    }
    throw error;
  }
}
```

### SDK 없이

Standard Webhooks 라이브러리라면 무엇이든 작동합니다. 직접 구현해도 몇 줄이면 됩니다.

**Node** (`verify.js`)

```js
import { createHmac, timingSafeEqual } from "node:crypto";

const secret = Buffer.from(process.env.IKIFORM_WEBHOOK_SECRET.replace(/^whsec_/, ""), "base64");

export function verify(body, headers) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"];
  if (!id || !timestamp || !signatures) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${id}.${timestamp}.${body}`).digest();
  return signatures.split(" ").some((entry) => {
    const [version, signature] = entry.split(",");
    if (version !== "v1" || !signature) return false;
    const received = Buffer.from(signature, "base64");
    return received.length === expected.length && timingSafeEqual(received, expected);
  });
}
```

**Python** (`verify.py`)

```python
import base64
import hashlib
import hmac
import os
import time

secret = base64.b64decode(os.environ["IKIFORM_WEBHOOK_SECRET"].removeprefix("whsec_"))


def verify(body: bytes, headers) -> bool:
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature")
    if not msg_id or not timestamp or not signatures or not timestamp.isdigit():
        return False
    if abs(time.time() - int(timestamp)) > 300:
        return False
    content = f"{msg_id}.{timestamp}.".encode() + body
    expected = base64.b64encode(hmac.new(secret, content, hashlib.sha256).digest()).decode()
    for entry in signatures.split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature, expected):
            return True
    return False
```

### 서명 검증기

이 페이지의 인터랙티브 검증기는 SDK의 `verifyWebhook`으로 시크릿, 웹훅 헤더 세 개, 원본 본문을 모두 브라우저 안에서 확인합니다. 올바르게 서명된 예제 전송도 생성할 수 있습니다. 붙여 넣은 내용은 페이지 밖으로 나가지 않습니다.

## 응답하기

| 여러분의 응답 | Ikiform의 동작 |
| --- | --- |
| 15초 안에 `2xx` 응답 | 전송을 성공으로 표시 |
| `410 Gone` | 전송을 실패 처리하고 엔드포인트를 즉시 비활성화 |
| `Retry-After`가 있는 `429` 또는 `503` | 그 시간만큼 기다린 뒤 재시도. 단, 일정의 다음 단계보다 늦지 않게 |
| 리디렉션 | 따르지 않음. 실패로 처리 |
| 그 밖의 상태, 시간 초과, 네트워크 오류 | 아래 일정에 따라 재시도 |

오래 걸리는 작업은 큐 등을 이용해 응답한 뒤에 처리하세요. 응답 본문의 처음 4 KB는 전송 로그에 저장됩니다.

## 재시도

실패한 시도는 다음 일정에 따라 재시도되며, 각 지연에는 양쪽으로 최대 20%의 무작위 지터가 붙습니다. 첫 시도 후 약 3일 6시간이 지난 9번째 시도 이후에는 전송이 실패로 표시됩니다.

| 시도 | 이전 시도 후 지연 | 첫 시도부터 경과 시간 |
| --- | --- | --- |
| 1 | 즉시 | 0 |
| 2 | 5초 | 5초 |
| 3 | 5분 | 5분 |
| 4 | 30분 | 35분 |
| 5 | 2시간 | 2시간 35분 |
| 6 | 5시간 | 7시간 35분 |
| 7 | 10시간 | 17시간 35분 |
| 8 | 1일 | 1일 18시간 |
| 9 | 1일 12시간 | 3일 6시간 |

### 비활성화된 엔드포인트

엔드포인트로 가는 모든 전송이 5일 동안 실패하면 엔드포인트가 비활성화되고 워크스페이스 소유자에게 이메일이 갑니다. `410 Gone` 응답을 받거나 대상이 사설 주소가 되면 즉시 비활성화됩니다. 문제를 고친 뒤 엔드포인트 페이지에서 다시 켜세요.

## 전송 로그

각 엔드포인트 페이지에는 전송 목록과 상태가 표시됩니다. 하나를 펼치면 모든 시도를 볼 수 있습니다. 실행 시각, 자동/수동/테스트 여부, 상태 코드, 소요 시간, 응답이나 오류의 처음 4 KB가 나옵니다. 여기서 어떤 전송이든 재시도할 수 있습니다. 시도 기록은 30일, 전송 기록은 90일간 보관합니다.

## 시크릿 교체

**Roll signing secret**은 새 시크릿을 만듭니다. 이후 24시간 동안 모든 전송이 이전 시크릿과 새 시크릿 양쪽으로 서명되므로 `webhook-signature`에 값이 두 개 담기며, 이벤트를 놓치지 않고 새 시크릿을 배포할 수 있습니다.
