웹훅
서명된 웹훅 페이로드, HMAC-SHA256 서명 검증, 재시도 일정, 전송 로그.
웹훅은 수락된 제출을 도착하는 즉시 여러분의 서버로 보냅니다. 전송은 Standard Webhooks 방식으로 서명되고, 약 3일간 재시도되며, 대시보드에 시도별로 기록됩니다. 바로 쓸 수 있는 핸들러는 웹훅 받기에 있습니다.
엔드포인트 추가하기
- 대시보드에서 Webhooks를 열고 Add endpoint를 고르세요.
- HTTPS URL을 입력하고, 폼 하나 또는 워크스페이스의 모든 폼을 고르세요.
- 서명 시크릿을 복사하세요.
whsec_로 시작하며 전체 값은 한 번만 표시됩니다. - 테스트 이벤트를 보내 핸들러를 확인하세요.
엔드포인트 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으로 보냅니다.
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. 시크릿을 교체하는 동안에는 공백으로 구분된 값이 여러 개 |
페이로드
{
"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
}
}| 필드 | 설명 |
|---|---|
id | 전송 ID, webhook-id와 같음 |
type | submission.created 또는 endpoint.test |
timestamp | 전송이 생성된 시각, ISO 8601 UTC. 재시도해도 같음 |
api_version | 엔드포인트를 만들 때의 페이로드 버전, 현재 2026-10-17 |
data.submission_id | 제출 ID. REST API로 조회하세요 |
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처럼 신뢰할 수 없는 입력으로 다루세요.
서명 검증하기
HMAC-SHA256, whsec_ 뒤의 시크릿을 base64 디코딩한 값을 키로 사용
webhook-signature: v1,base64(signature)
- 받은 원본 요청 본문을 그대로 사용하세요. JSON을 파싱했다가 다시 직렬화하면 바이트가 바뀝니다.
- 헤더에는 공백으로 구분된 서명이 여러 개 있을 수 있습니다.
v1서명 중 하나라도 일치하면 요청을 받아들이세요. - 상수 시간으로 비교하고, 현재 시각과 5분 넘게 차이 나는 타임스탬프는 거부하세요.
SDK 사용
verifyWebhook은 Web Crypto로 위의 모든 과정을 수행하고 파싱된 이벤트를 반환하거나, reason이 담긴 WebhookVerificationError를 던집니다. Headers 객체나 Node의 req.headers 같은 일반 객체를 받습니다.
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 라이브러리라면 무엇이든 작동합니다. 직접 구현해도 몇 줄이면 됩니다.
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);
});
}서명 검증기
브라우저의 Web Crypto로 SDK의 verifyWebhook을 실행합니다. 붙여 넣은 내용은 이 페이지 밖으로 나가지 않습니다.
응답하기
| 여러분의 응답 | Ikiform의 동작 |
|---|---|
15초 안에 2xx 응답 | 전송을 성공으로 표시 |
410 Gone | 전송을 실패 처리하고 엔드포인트를 즉시 비활성화 |
Retry-After가 있는 429 또는 503 | 그 시간만큼 기다린 뒤 재시도. 단, 일정의 다음 단계보다 늦지 않게 |
| 리디렉션 | 따르지 않음. 실패로 처리 |
| 그 밖의 상태, 시간 초과, 네트워크 오류 | 아래 일정에 따라 재시도 |
오래 걸리는 작업은 큐 등을 이용해 응답한 뒤에 처리하세요. 응답 본문의 처음 4 KB는 전송 로그에 저장됩니다.
재시도
실패한 시도는 다음 일정에 따라 재시도되며, 각 지연에는 양쪽으로 최대 20%의 무작위 지터가 붙습니다. 첫 시도 후 약 3일 6시간이 지난 9번째 시도 이후에는 전송이 실패로 표시됩니다.
- 1즉시0초
- 25초5초
- 35분5분
- 430분35분
- 52시간2시간 35분
- 65시간7시간 35분
- 710시간17시간 35분
- 81일1일 18시간
- 91일 12시간3일 6시간
진한 막대는 가장 짧은 대기, 옅은 막대는 가장 긴 대기이며 양쪽으로 20% 지터가 있습니다. 9번째 시도 후에는 전송이 실패로 표시됩니다.
비활성화된 엔드포인트
엔드포인트로 가는 모든 전송이 5일 동안 실패하면 엔드포인트가 비활성화되고 워크스페이스 소유자에게 이메일이 갑니다. 410 Gone 응답을 받거나 대상이 사설 주소가 되면 즉시 비활성화됩니다. 문제를 고친 뒤 엔드포인트 페이지에서 다시 켜세요.
전송 로그
각 엔드포인트 페이지에는 전송 목록과 상태가 표시됩니다. 하나를 펼치면 모든 시도를 볼 수 있습니다. 실행 시각, 자동/수동/테스트 여부, 상태 코드, 소요 시간, 응답이나 오류의 처음 4 KB가 나옵니다. 여기서 어떤 전송이든 재시도할 수 있습니다. 시도 기록은 30일, 전송 기록은 90일간 보관합니다.
시크릿 교체
Roll signing secret은 새 시크릿을 만듭니다. 이후 24시간 동안 모든 전송이 이전 시크릿과 새 시크릿 양쪽으로 서명되므로 webhook-signature에 값이 두 개 담기며, 이벤트를 놓치지 않고 새 시크릿을 배포할 수 있습니다.