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

# 웹훅

> 실시간 이벤트 알림을 수신하고 안정적으로 처리하기

# 웹훅

웹훅은 스토어에서 이벤트가 발생할 때 애플리케이션에 알림을 보냅니다 — 주문 확인, 결제 완료, 재고 부족 알림 등.

## 설정 방법

Admin API를 통해 웹훅을 생성합니다:

```bash theme={null}
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`이 포함됩니다.

모든 이벤트를 구독하려면 와일드카드 `*`를 사용하세요:

```typescript theme={null}
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` | 고객이 생성됨 | 고객 객체 (비밀번호 제외) |

## 페이로드 형식

```json theme={null}
{
  "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 헬퍼 사용

```typescript theme={null}
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를 사용하지 않는 경우 직접 서명을 계산합니다:

```typescript theme={null}
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'),
  );
}
```

<Warning>
  서명 비교 시 `===` 대신 반드시 `crypto.timingSafeEqual`을 사용하세요.
  문자열 동등 비교는 타이밍 공격에 취약합니다.
</Warning>

## 멱등성

### HTTP 멱등성 키

멱등성 키를 사용하면 중복 리소스를 생성하지 않고 POST 요청을 안전하게 재시도할 수 있습니다. 고유한 값(UUID 권장)으로 `Idempotency-Key` 헤더를 포함하세요.

```bash theme={null}
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` 헤더는 선택 사항입니다. 생략하면 요청이 정상적으로 처리됩니다

### 멱등적 웹훅 처리

웹훅은 재시도, 네트워크 문제 또는 인프라 장애 조치로 인해 두 번 이상 전달될 수 있습니다. 핸들러는 반드시 멱등적이어야 합니다.

```typescript theme={null}
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;
}
```

중복 제거를 위한 데이터베이스 스키마:

```sql theme={null}
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';
```

<Tip>
  이벤트 ID 삽입과 비즈니스 로직 수행을 **원자적으로** 처리하는 데이터베이스 트랜잭션을 사용하세요. 이렇게 하면 처리가 실패했을 때 이벤트가 처리된 것으로 표시되지 않아 재시도됩니다.
</Tip>

## 재시도 정책

실패한 전송은 지수 백오프로 재시도됩니다:

| 시도 | 실패 후 지연 시간 |
| - | - |
| 1차 재시도 | 1분 |
| 2차 재시도 | 5분 |
| 3차 재시도 | 30분 |
| 4차 재시도 | 2시간 |
| 5차 재시도 | 24시간 |

5회 연속 실패 후 웹훅 엔드포인트는 **비활성** 상태로 표시되며 대시보드에 알림이 전송됩니다.

### 빠른 응답 모범 사례

```typescript theme={null}
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);
  });
});
```

<Warning>
  가능한 한 빨리 `2xx` 응답을 반환하세요. 핸들러가 30초 이상 걸리면 전송이 실패한 것으로 간주되어 재시도됩니다. 무거운 처리는 백그라운드 큐로 이동하세요.
</Warning>

## 이벤트 순서

이벤트는 순서가 뒤바뀌어 도착할 수 있습니다. 예를 들어, 네트워크 상태에 따라 `order.paid`가 `order.created`보다 먼저 도착할 수 있습니다.

### 전략: 도착 순서가 아닌 타임스탬프 사용

```typescript theme={null}
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,
    },
  });
}
```

시스템에 아직 존재하지 않는 리소스에 대한 이벤트를 수신한 경우:

1. 이벤트를 **대기 큐**에 저장합니다
2. 선행 이벤트(예: `order.created`)가 도착하면 먼저 처리합니다
3. 그런 다음 해당 리소스에 대한 대기 중인 이벤트를 재처리합니다

## 큐 기반 처리

프로덕션 워크로드의 경우 웹훅을 큐에 수신하고 비동기로 처리합니다.

### Inngest 사용

```typescript theme={null}
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 사용

```typescript theme={null}
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를 사용하여 웹훅 엔드포인트에 테스트 이벤트를 전송합니다:

```bash theme={null}
curl -X POST https://api.headlesscommerce.io/v1/admin/webhooks/{id}/test \
  -H "Authorization: Bearer sk_test_your_key"
```

또는 SDK를 사용합니다:

```typescript theme={null}
const admin = createAdminClient({ apiKey: 'sk_test_xxx' });
await admin.webhooks.test('wh_abc123');
```

테스트 엔드포인트는 페이로드에 `"test": true`가 포함된 합성 이벤트를 전송하므로 테스트 이벤트와 실제 이벤트를 구분할 수 있습니다.

### ngrok을 사용한 로컬 개발

개발 중 로컬 머신에서 웹훅을 수신하려면:

```bash theme={null}
# 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"]
  }'
```

<Tip>
  테스트가 끝나면 개발용 웹훅을 업데이트하거나 삭제하는 것을 잊지 마세요. 오래된 엔드포인트는 실패한 전송이 누적되어 자동으로 비활성화될 수 있습니다.
</Tip>

## 다음 단계

<CardGroup cols={2}>
  <Card title="Stripe 연동" icon="stripe" href="/ko/guides/stripe-integration">
    웹훅 기반 결제 확인으로 Stripe 결제를 설정합니다.
  </Card>

  <Card title="TossPayments 연동" icon="credit-card" href="/ko/guides/tosspayments-integration">
    웹훅 기반 결제 확인으로 TossPayments를 설정합니다.
  </Card>

  <Card title="레시피" icon="book" href="/ko/guides/recipes">
    일반적인 커머스 흐름을 다루는 실용적인 코드 레시피.
  </Card>

  <Card title="오류 처리" icon="triangle-exclamation" href="/ko/errors">
    API 오류 코드 및 권장 처리 전략.
  </Card>
</CardGroup>


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