웹훅은 스토어에서 이벤트가 발생할 때 애플리케이션에 알림을 보냅니다 — 주문 확인, 결제 완료, 재고 부족 알림 등.
설정 방법
Admin API를 통해 웹훅을 생성합니다:
curl -X POST https://api.headlesscommerce.io/v1/admin/webhooks \
-H "Authorization: Bearer sk_test_your_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/webhooks",
"events": ["order.confirmed", "payment.completed"]
}'
응답에는 웹훅 서명을 검증하기 위한 secret이 포함됩니다.
모든 이벤트를 구독하려면 와일드카드 *를 사용하세요:
const admin = createAdminClient ({ apiKey: 'sk_live_xxx' });
const webhook = await admin . webhooks . create ({
url: 'https://my-app.com/webhooks/hc' ,
events: [ '*' ],
});
// 응답에는 서명 검증을 위한 웹훅 시크릿이 포함됩니다
console . log ( webhook . secret ); // "whsec_..."
이벤트 유형
이벤트 설명 페이로드 product.created상품이 생성됨 전체 상품 객체 product.updated상품이 수정됨 수정된 상품 객체 product.deleted상품이 삭제됨 상품 ID 및 메타데이터 order.created주문이 생성됨 전체 주문 객체 order.confirmed주문이 확인됨 (결제 완료) 결제 정보가 포함된 주문 order.completed주문이 완전히 완료됨 출고 ID가 포함된 주문 order.cancelled주문이 취소됨 취소 사유가 포함된 주문 payment.completed결제가 성공적으로 처리됨 결제 정보가 포함된 주문 payment.failed결제 시도가 실패함 오류 정보가 포함된 주문 payment.refunded결제가 환불됨 금액 및 항목이 포함된 환불 fulfillment.created출고가 생성됨 항목이 포함된 출고 fulfillment.shipped출고가 발송됨 배송 추적 정보가 포함된 출고 fulfillment.delivered출고가 배달 완료됨 배달 타임스탬프가 포함된 출고 return.requested고객이 반품을 요청함 항목 및 사유가 포함된 반품 return.completed반품이 완전히 처리됨 처리 결과가 포함된 반품 inventory.low재고가 안전 재고 아래로 떨어짐 옵션 및 재고 수준 inventory.out_of_stock가용 재고가 0이 됨 옵션 ID customer.created고객이 생성됨 고객 객체 (비밀번호 제외)
페이로드 형식
{
"id" : "evt_abc123" ,
"type" : "order.confirmed" ,
"created_at" : "2025-01-15T10:30:00Z" ,
"data" : {
"id" : "order_001" ,
"number" : 1001 ,
"status" : "confirmed" ,
"total" : { "amount" : 78000 , "currency" : "KRW" }
},
"store_id" : "store_001"
}
서명 검증
모든 웹훅 전송에는 X-Webhook-Signature 헤더(HMAC-SHA256)가 포함됩니다. 처리하기 전에 반드시 이를 검증하세요.
SDK 헬퍼 사용
import { verifyWebhookSignature } from '@headless-commerce/sdk' ;
app . post ( '/webhooks/hc' , express . raw ({ type: 'application/json' }), ( req , res ) => {
const signature = req . headers [ 'x-webhook-signature' ] as string ;
const secret = process . env . WEBHOOK_SECRET ! ;
const isValid = verifyWebhookSignature ( req . body , signature , secret );
if ( ! isValid ) {
console . error ( 'Webhook signature verification failed' );
return res . status ( 401 ). json ({ error: 'Invalid signature' });
}
// 이벤트를 안전하게 처리
const event = JSON . parse ( req . body . toString ());
// ...
});
수동 검증
SDK를 사용하지 않는 경우 직접 서명을 계산합니다:
import crypto from 'node:crypto' ;
function verifyManually ( payload : Buffer , signature : string , secret : string ) : boolean {
const expected = crypto
. createHmac ( 'sha256' , secret )
. update ( payload )
. digest ( 'hex' );
// 타이밍 공격을 방지하기 위해 타이밍 안전 비교 사용
return crypto . timingSafeEqual (
Buffer . from ( signature , 'utf8' ),
Buffer . from ( expected , 'utf8' ),
);
}
서명 비교 시 === 대신 반드시 crypto.timingSafeEqual을 사용하세요.
문자열 동등 비교는 타이밍 공격에 취약합니다.
멱등성
HTTP 멱등성 키
멱등성 키를 사용하면 중복 리소스를 생성하지 않고 POST 요청을 안전하게 재시도할 수 있습니다. 고유한 값(UUID 권장)으로 Idempotency-Key 헤더를 포함하세요.
curl -X POST https://api.headlesscommerce.io/v1/storefront/carts/{id}/checkout \
-H "Authorization: Bearer pk_test_your_key" \
-H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \
-H "Content-Type: application/json" \
-d '{"email": "customer@example.com", "payment_provider": "stripe"}'
엔드포인트 이유 POST /storefront/carts/{id}/checkout중복 주문 방지 POST /admin/orders/{id}/payments중복 결제 방지 POST /admin/orders/{id}/refunds이중 환불 방지
주요 규칙:
키는 요청마다 고유해야 합니다 — UUID를 사용하세요
키는 스토어(API 키) 범위로 제한됩니다
캐시된 응답은 24시간 후 만료됩니다
성공 응답(2xx)만 캐시됩니다
Idempotency-Key 헤더는 선택 사항입니다. 생략하면 요청이 정상적으로 처리됩니다
멱등적 웹훅 처리
웹훅은 재시도, 네트워크 문제 또는 인프라 장애 조치로 인해 두 번 이상 전달될 수 있습니다. 핸들러는 반드시 멱등적이어야 합니다.
import { db } from './database' ;
async function handleWebhook ( event : WebhookEvent ) : Promise < boolean > {
// 1. 이 이벤트가 이미 처리되었는지 확인
const existing = await db . webhookEvent . findUnique ({
where: { event_id: event . id },
});
if ( existing ) {
console . log ( `Event ${ event . id } already processed, skipping` );
return true ; // 이미 처리됨
}
// 2. 트랜잭션 내에서 이벤트 처리
await db . $transaction ( async ( tx ) => {
// 먼저 이벤트를 기록하여 선점
await tx . webhookEvent . create ({
data: {
event_id: event . id ,
type: event . type ,
processed_at: new Date (),
},
});
// 그 다음 비즈니스 로직 수행
switch ( event . type ) {
case 'order.paid' :
await tx . order . update ({
where: { id: event . data . id },
data: { payment_status: 'paid' },
});
break ;
// ... 기타 이벤트 유형
}
});
return true ;
}
중복 제거를 위한 데이터베이스 스키마:
CREATE TABLE webhook_events (
event_id VARCHAR ( 64 ) PRIMARY KEY , -- e.g., "evt_abc123"
type VARCHAR ( 128 ) NOT NULL ,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW (),
payload JSONB
);
-- 오래된 레코드를 자동 정리하기 위한 TTL 인덱스 추가 (PostgreSQL + pg_cron)
-- DELETE FROM webhook_events WHERE processed_at < NOW() - INTERVAL '30 days';
이벤트 ID 삽입과 비즈니스 로직 수행을 원자적으로 처리하는 데이터베이스 트랜잭션을 사용하세요. 이렇게 하면 처리가 실패했을 때 이벤트가 처리된 것으로 표시되지 않아 재시도됩니다.
재시도 정책
실패한 전송은 지수 백오프로 재시도됩니다:
시도 실패 후 지연 시간 1차 재시도 1분 2차 재시도 5분 3차 재시도 30분 4차 재시도 2시간 5차 재시도 24시간
5회 연속 실패 후 웹훅 엔드포인트는 비활성 상태로 표시되며 대시보드에 알림이 전송됩니다.
빠른 응답 모범 사례
app . post ( '/webhooks/hc' , express . raw ({ type: 'application/json' }), async ( req , res ) => {
// 1. 서명 검증
if ( ! verifyWebhookSignature ( req . body , req . headers [ 'x-webhook-signature' ], secret )) {
return res . status ( 401 ). send ( 'Invalid signature' );
}
// 2. 즉시 200 반환 -- 비동기로 처리
res . status ( 200 ). send ( 'OK' );
// 3. 백그라운드에서 처리 (응답 전송 후)
const event = JSON . parse ( req . body . toString ());
processEventAsync ( event ). catch (( err ) => {
console . error ( `Failed to process event ${ event . id } :` , err );
});
});
가능한 한 빨리 2xx 응답을 반환하세요. 핸들러가 30초 이상 걸리면 전송이 실패한 것으로 간주되어 재시도됩니다. 무거운 처리는 백그라운드 큐로 이동하세요.
이벤트 순서
이벤트는 순서가 뒤바뀌어 도착할 수 있습니다. 예를 들어, 네트워크 상태에 따라 order.paid가 order.created보다 먼저 도착할 수 있습니다.
전략: 도착 순서가 아닌 타임스탬프 사용
async function handleOrderUpdate ( event : WebhookEvent ) {
const order = await db . order . findUnique ({ where: { id: event . data . id } });
if ( ! order ) {
// 아직 시스템에 주문이 없음 -- 나중에 처리하기 위해 이벤트 저장
await db . pendingEvent . create ({ data: { event_id: event . id , payload: event } });
return ;
}
// 마지막 업데이트보다 새로운 이벤트인 경우에만 적용
const eventTime = new Date ( event . created_at );
if ( order . last_webhook_at && eventTime <= order . last_webhook_at ) {
console . log ( `Skipping stale event ${ event . id } ` );
return ;
}
await db . order . update ({
where: { id: event . data . id },
data: {
status: event . data . status ,
last_webhook_at: eventTime ,
},
});
}
시스템에 아직 존재하지 않는 리소스에 대한 이벤트를 수신한 경우:
이벤트를 대기 큐 에 저장합니다
선행 이벤트(예: order.created)가 도착하면 먼저 처리합니다
그런 다음 해당 리소스에 대한 대기 중인 이벤트를 재처리합니다
큐 기반 처리
프로덕션 워크로드의 경우 웹훅을 큐에 수신하고 비동기로 처리합니다.
Inngest 사용
import { Inngest } from 'inngest' ;
import { verifyWebhookSignature } from '@headless-commerce/sdk' ;
const inngest = new Inngest ({ id: 'my-store' });
// 핸들러 함수 정의
const processWebhook = inngest . createFunction (
{ id: 'process-hc-webhook' , retries: 3 },
{ event: 'hc/webhook.received' },
async ({ event }) => {
const { type , data } = event . data ;
switch ( type ) {
case 'order.created' :
await sendOrderConfirmation ( data );
await syncToERP ( data );
break ;
case 'fulfillment.shipped' :
await sendShippingNotification ( data );
break ;
case 'return.requested' :
await notifyWarehouse ( data );
break ;
}
},
);
// 웹훅 엔드포인트: 검증, 큐에 추가, 즉시 응답
app . post ( '/webhooks/hc' , express . raw ({ type: 'application/json' }), async ( req , res ) => {
const signature = req . headers [ 'x-webhook-signature' ] as string ;
if ( ! verifyWebhookSignature ( req . body , signature , process . env . WEBHOOK_SECRET ! )) {
return res . status ( 401 ). send ( 'Invalid signature' );
}
const event = JSON . parse ( req . body . toString ());
// 비동기 처리를 위해 Inngest로 전송
await inngest . send ({
name: 'hc/webhook.received' ,
data: event ,
});
res . status ( 200 ). send ( 'OK' );
});
BullMQ 사용
import { Queue , Worker } from 'bullmq' ;
import { verifyWebhookSignature } from '@headless-commerce/sdk' ;
const webhookQueue = new Queue ( 'hc-webhooks' , {
connection: { host: 'localhost' , port: 6379 },
});
// 웹훅 엔드포인트: 검증 후 큐에 추가
app . post ( '/webhooks/hc' , express . raw ({ type: 'application/json' }), async ( req , res ) => {
const signature = req . headers [ 'x-webhook-signature' ] as string ;
if ( ! verifyWebhookSignature ( req . body , signature , process . env . WEBHOOK_SECRET ! )) {
return res . status ( 401 ). send ( 'Invalid signature' );
}
const event = JSON . parse ( req . body . toString ());
await webhookQueue . add ( event . type , event , {
jobId: event . id , // 중복 처리 방지
});
res . status ( 200 ). send ( 'OK' );
});
// Worker: 큐에서 이벤트 처리
const worker = new Worker ( 'hc-webhooks' , async ( job ) => {
const event = job . data ;
console . log ( `Processing ${ event . type } : ${ event . id } ` );
switch ( event . type ) {
case 'order.paid' :
await fulfillOrder ( event . data );
break ;
case 'refund.created' :
await updateAccountingSystem ( event . data );
break ;
}
}, {
connection: { host: 'localhost' , port: 6379 },
concurrency: 5 ,
});
테스트
테스트 이벤트 전송
Admin API를 사용하여 웹훅 엔드포인트에 테스트 이벤트를 전송합니다:
curl -X POST https://api.headlesscommerce.io/v1/admin/webhooks/{id}/test \
-H "Authorization: Bearer sk_test_your_key"
또는 SDK를 사용합니다:
const admin = createAdminClient ({ apiKey: 'sk_test_xxx' });
await admin . webhooks . test ( 'wh_abc123' );
테스트 엔드포인트는 페이로드에 "test": true가 포함된 합성 이벤트를 전송하므로 테스트 이벤트와 실제 이벤트를 구분할 수 있습니다.
ngrok을 사용한 로컬 개발
개발 중 로컬 머신에서 웹훅을 수신하려면:
# 1. ngrok을 시작하여 로컬 서버를 외부에 노출
ngrok http 3000
# 2. HTTPS URL 복사 (예: https://abc123.ngrok.io)
# 3. ngrok URL로 웹훅 등록
curl -X POST https://api.headlesscommerce.io/v1/admin/webhooks \
-H "Authorization: Bearer sk_test_your_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://abc123.ngrok.io/webhooks/hc",
"events": ["order.created", "order.paid"]
}'
테스트가 끝나면 개발용 웹훅을 업데이트하거나 삭제하는 것을 잊지 마세요. 오래된 엔드포인트는 실패한 전송이 누적되어 자동으로 비활성화될 수 있습니다.
다음 단계
Stripe 연동 웹훅 기반 결제 확인으로 Stripe 결제를 설정합니다.
TossPayments 연동 웹훅 기반 결제 확인으로 TossPayments를 설정합니다.
레시피 일반적인 커머스 흐름을 다루는 실용적인 코드 레시피.
오류 처리 API 오류 코드 및 권장 처리 전략.