Webhooks
예약 확정, 배달 상태 변경 같은 이벤트를 webhook으로 수신합니다.
예약이 생성 시점에 항상 확정되는 것은 아닙니다. Create reservation은 매장이 즉시 승인하면 confirmed, 매장 승인이 필요하면 pending 상태로 응답하고, 그 이후의 확정이나 취소는 매장 사정에 따라 몇 분에서 몇 시간 뒤에 일어납니다. 이 변화를 Get reservation 폴링으로 쫓아가면 호출 수만 늘고 반영은 늦어집니다. webhook은 방향을 뒤집습니다. 상태가 바뀌는 순간 APIFuse이 등록된 endpoint로 이벤트를 전송합니다.
같은 원리가 카탈로그 전체에 적용됩니다. 배달 주문은 조리와 배차 상태를 거치고, 결제는 비동기로 승인되며, 차량 위치는 이동 중에 계속 바뀝니다. 이 모든 변화가 하나의 envelope 형식으로, 같은 방식으로 서명되어 푸시됩니다.
APIFuse 콘솔에서 HTTPS endpoint 하나를 등록하고 구독할 이벤트 타입을 선택합니다. 계정의 모든 구독 이벤트가 이 endpoint 하나로 들어오므로, 서비스마다 URL을 나누는 대신 body의 type 필드로 분기합니다. 등록이 완료되면 콘솔이 endpoint secret을 한 번만 표시합니다. whsec_ 접두사로 시작하는 이 값은 서명 검증에 쓰이므로, managed key와 같은 secret 저장소에 보관합니다.
모든 전달은 JSON body를 가진 POST 요청이며, body는 항상 같은 모양입니다.
{
"id": "evt_01J8QH5R7T",
"type": "reservation.confirmed",
"created_at": "2026-09-01T10:15:27Z",
"data": {
"object": {
"id": "rsv_01J8QDGT2K",
"status": "confirmed",
"restaurant_id": "res_01J8Q9W3TE",
"starts_at": "2026-09-02T19:00:00+09:00",
"party_size": 2
}
}
}data.object는 API가 반환하는 리소스와 정확히 같은 모양입니다. reservation.confirmed의 object는 Get reservation이 반환하는 예약 객체와 필드 단위로 동일하며, 스키마는 Catch Table 레퍼런스에 문서화되어 있습니다. 이벤트를 받은 뒤 리소스를 다시 조회할 필요가 없습니다.
이벤트 타입은 <domain>.<event> 규칙을 따릅니다. domain은 서비스가 아니라 리소스를 가리키므로, Catch Table의 reservation.confirmed와 Tabelog의 reservation.confirmed는 같은 envelope 구조를 공유합니다. 대표 이벤트는 다음과 같습니다.
| 이벤트 | 발생 시점 | 관련 서비스 |
|---|---|---|
reservation.confirmed | 매장이 예약을 확정했을 때 | , tabelog |
reservation.cancelled | 매장 또는 호출자가 예약을 취소했을 때 | , tabelog |
waitlist.called | 대기열의 일행이 입장 호출을 받았을 때 | |
order.updated | 배달 주문 상태가 바뀌었을 때 | baemin |
payment.approved | 결제가 승인되었을 때 | toss-pay |
ride.updated | 호출한 차량의 상태가 바뀌었을 때 | kakao-t |
Frism-Event-Id 헤더로 중복을 걸러냅니다.const seenEventIds = new Set<string>();
const queue: unknown[] = [];
export async function handleFrismEvent(request: Request): Promise<Response> {
const rawBody = await request.text();
// 1. 서명 검증이 항상 먼저입니다. 검증 코드는 서명 검증 가이드에 있습니다.
// 2. Frism-Event-Id로 중복을 걸러냅니다. 전달은 at-least-once입니다.
const eventId = request.headers.get("Frism-Event-Id");
if (eventId === null) return new Response(null, { status: 400 });
if (seenEventIds.has(eventId)) return new Response(null, { status: 200 });
seenEventIds.add(eventId);
// 3. 큐에 적재만 하고 바로 2xx를 반환합니다. 무거운 처리는 워커가 맡습니다.
queue.push(JSON.parse(rawBody));
return new Response(null, { status: 200 });
}위 envelope 예시를 파일로 저장하면 핸들러를 로컬에서 미리 연습해 볼 수 있습니다.
# 서명 값을 실제로 계산하는 방법은 서명 검증 가이드의 로컬 테스트 예시를 참고합니다.
curl -X POST http://localhost:3000/webhooks/frism \
-H "Content-Type: application/json" \
-H "Frism-Event-Id: evt_01J8QH5R7T" \
-H "Frism-Signature: t=1788257727,v2=d7e16839f487db479fa277fb11c2d6139225ea837c672df8a68e0452fd7ea772" \
--data @reservation-confirmed.json지금까지의 내용은 요청이 정말 APIFuse에서 왔다는 전제 위에 서 있습니다. 그 전제는 모든 payload의 할 때만 성립하므로, endpoint를 배포하기 전에 서명 검증 가이드를 먼저 읽습니다. 첫 이벤트를 만들어 볼 호출이 필요하다면 의 예약 생성 흐름이 적당합니다.