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

# 웹훅 이벤트

> PlayCamp에서 실시간 이벤트 알림 수신

SDK API에서 이벤트 발생 시 게임 서버로 알림을 보냅니다.

## 이벤트 종류

| 이벤트                    | 설명        |
| ---------------------- | --------- |
| `coupon.redeemed`      | 쿠폰 사용됨    |
| `payment.created`      | 결제 등록됨    |
| `payment.bulk_created` | 벌크 결제 등록됨 |
| `sponsor.created`      | 부스트 등록됨   |
| `sponsor.ended`        | 부스트 해제됨   |

## 웹훅 형식

**헤더**

```
Content-Type: application/json
X-Webhook-Batch: true
X-Webhook-Signature: {HMAC-SHA256 서명}
```

**페이로드**

```json theme={null}
{
  "events": [
    {
      "event": "payment.created",
      "timestamp": "2024-01-15T10:30:00.000Z",
      "callbackId": "my-callback-001",
      "data": {
        "transactionId": "txn_abc123",
        "userId": "user_12345",
        "amount": 9900,
        "currency": "KRW",
        "creatorKey": "ABC12"
      }
    }
  ]
}
```

## 서명 검증

웹훅 요청이 PlayCamp에서 온 것인지 확인하기 위해 서명을 검증해야 합니다.

<Tabs>
  <Tab title="Node SDK">
    SDK의 `verifyWebhook()` 유틸리티로 서명 검증, 타임스탬프 검증, 페이로드 파싱을 처리합니다:

    ```typescript theme={null}
    import { verifyWebhook } from '@playcamp/node-sdk';

    app.post('/webhooks/playcamp', (req, res) => {
      const result = verifyWebhook({
        payload: req.rawBody,
        signature: req.headers['x-webhook-signature'],
        secret: process.env.WEBHOOK_SECRET,
        tolerance: 300,  // 최대 허용 시간 초 (기본값: 300)
      });

      if (!result.valid) {
        return res.status(401).json({ error: result.error });
      }

      for (const event of result.payload.events) {
        switch (event.event) {
          case 'coupon.redeemed':
            console.log('쿠폰 사용:', event.data.couponCode);
            break;
          case 'payment.created':
            console.log('결제 등록:', event.data.transactionId);
            break;
          case 'payment.bulk_created':
            console.log('벌크 결제:', event.data.totalRequested);
            break;
          case 'sponsor.created':
            console.log('부스트 등록:', event.data.userId);
            break;
          case 'sponsor.ended':
            console.log('부스트 해제:', event.data.userId);
            break;
        }
      }

      res.status(200).json({ received: true });
    });
    ```
  </Tab>

  <Tab title="Go SDK">
    SDK의 `webhookutil` 패키지로 서명 검증, 타임스탬프 검증, 페이로드 파싱을 처리합니다:

    ```go theme={null}
    import "github.com/playcamp/playcamp-go-sdk/webhookutil"

    func handleWebhook(w http.ResponseWriter, r *http.Request) {
        body, _ := io.ReadAll(r.Body)

        result := webhookutil.Verify(webhookutil.VerifyOptions{
            Payload:   body,
            Signature: r.Header.Get("X-Webhook-Signature"),
            Secret:    os.Getenv("WEBHOOK_SECRET"),
            Tolerance: 300, // 최대 허용 시간 초 (기본값: 300)
        })

        if !result.Valid {
            http.Error(w, result.Error, http.StatusUnauthorized)
            return
        }

        for _, event := range result.Payload.Events {
            switch event.Event {
            case playcamp.WebhookEventCouponRedeemed:
                fmt.Println("쿠폰 사용")
            case playcamp.WebhookEventPaymentCreated:
                fmt.Println("결제 등록")
            case playcamp.WebhookEventPaymentBulkCreated:
                fmt.Println("벌크 결제")
            case playcamp.WebhookEventSponsorCreated:
                fmt.Println("부스트 등록")
            case playcamp.WebhookEventSponsorEnded:
                fmt.Println("부스트 해제")
            }
        }

        w.WriteHeader(http.StatusOK)
        json.NewEncoder(w).Encode(map[string]bool{"received": true})
    }
    ```
  </Tab>

  <Tab title="수동 (crypto)">
    ```typescript theme={null}
    import crypto from 'crypto';

    function verifyWebhookSignature(
      payload: string,      // raw request body
      signature: string,    // X-Webhook-Signature 헤더 값
      secret: string        // 웹훅 시크릿
    ): boolean {
      const expected = crypto
        .createHmac('sha256', secret)
        .update(payload)
        .digest('hex');

      return crypto.timingSafeEqual(
        Buffer.from(signature),
        Buffer.from(expected)
      );
    }
    ```
  </Tab>
</Tabs>

## 수신 예시 (Express)

<Tabs>
  <Tab title="Node SDK">
    ```typescript theme={null}
    import { verifyWebhook } from '@playcamp/node-sdk';

    app.post('/webhooks/playcamp', express.raw({ type: 'application/json' }), (req, res) => {
      const payload = Buffer.isBuffer(req.body) ? req.body.toString() : req.body;

      const result = verifyWebhook({
        payload,
        signature: req.headers['x-webhook-signature'] as string,
        secret: process.env.WEBHOOK_SECRET!,
      });

      if (!result.valid) {
        return res.status(401).json({ error: result.error });
      }

      for (const event of result.payload.events) {
        console.log('callbackId:', event.callbackId); // 웹훅 추적용 ID
        switch (event.event) {
          case 'coupon.redeemed':
            console.log('쿠폰 사용:', event.data);
            break;
          case 'payment.created':
            console.log('결제 등록:', event.data);
            break;
          case 'payment.bulk_created':
            console.log('벌크 결제:', event.data);
            break;
          case 'sponsor.created':
            console.log('부스트 등록:', event.data);
            break;
          case 'sponsor.ended':
            console.log('부스트 해제:', event.data);
            break;
        }
      }

      res.json({ received: true });
    });
    ```
  </Tab>

  <Tab title="수동 (crypto)">
    ```typescript theme={null}
    import crypto from 'crypto';

    app.post('/webhooks/playcamp', express.raw({ type: 'application/json' }), (req, res) => {
      const signature = req.headers['x-webhook-signature'] as string;
      const isBatch = req.headers['x-webhook-batch'] === 'true';
      const webhookSecret = process.env.WEBHOOK_SECRET;

      const payload = Buffer.isBuffer(req.body)
        ? req.body.toString()
        : req.body;

      // 1. 서명 검증
      if (webhookSecret && signature) {
        const expected = crypto
          .createHmac('sha256', webhookSecret)
          .update(payload)
          .digest('hex');

        if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
          return res.status(401).json({ error: 'Invalid signature' });
        }
      }

      // 2. JSON 파싱
      const data = JSON.parse(payload);

      // 3. 이벤트 처리
      const events = isBatch || data.events ? data.events : [data];

      for (const event of events) {
        switch (event.event) {
          case 'coupon.redeemed':
            console.log('쿠폰 사용:', event.data);
            break;
          case 'payment.created':
            console.log('결제 등록:', event.data);
            break;
        }
      }

      res.json({ received: true });
    });
    ```
  </Tab>
</Tabs>

<Warning>
  `express.json()` 대신 `express.raw({ type: 'application/json' })`를 사용해야 원본 body로 서명 검증이 가능합니다.
</Warning>

## callbackId 추적

API 요청 시 `callbackId`를 전달하면, 해당 요청으로 발생한 웹훅 이벤트에 동일한 `callbackId`가 포함됩니다. 이를 통해 요청과 웹훅 이벤트를 매칭할 수 있습니다.

```json theme={null}
{
  "events": [
    {
      "event": "payment.created",
      "timestamp": "2026-03-17T12:00:00Z",
      "callbackId": "my-callback-001",
      "data": { "transactionId": "txn_abc123", "userId": "user_12345" }
    }
  ]
}
```

## 이벤트별 페이로드

### sponsor.ended (부스트 해제)

```json theme={null}
{
  "events": [
    {
      "event": "sponsor.ended",
      "timestamp": "2024-01-15T10:30:00.000Z",
      "callbackId": "game-session-abc123",
      "data": {
        "userId": "user_12345",
        "campaignId": "campaign_001",
        "creatorKey": "ABC12"
      }
    }
  ]
}
```

### payment.bulk\_created (벌크 결제)

벌크 결제 완료 시 요약 이벤트가 1회 발생합니다.

```json theme={null}
{
  "events": [
    {
      "event": "payment.bulk_created",
      "timestamp": "2026-03-17T12:00:00Z",
      "callbackId": "bulk-001",
      "data": {
        "totalRequested": 100,
        "successful": 98,
        "failed": 1,
        "skipped": 1,
        "transactionIds": ["txn_001", "txn_002"]
      }
    }
  ]
}
```

## 웹훅 테스트

로컬 개발을 위해 테스트 서명을 생성할 수 있습니다:

```typescript theme={null}
import { constructWebhookSignature } from '@playcamp/node-sdk';

const payload = JSON.stringify({
  events: [{
    event: 'coupon.redeemed',
    timestamp: new Date().toISOString(),
    data: { couponCode: 'TEST', userId: 'user_123', usageId: 1, reward: [] },
  }]
});

const signature = constructWebhookSignature(payload, 'your_webhook_secret');
```

## 웹훅 관리

<Tabs>
  <Tab title="cURL">
    | 기능     | Method | Endpoint                       |
    | ------ | ------ | ------------------------------ |
    | 웹훅 목록  | GET    | `/v1/server/webhooks`          |
    | 웹훅 등록  | POST   | `/v1/server/webhooks`          |
    | 웹훅 수정  | PUT    | `/v1/server/webhooks/:id`      |
    | 웹훅 삭제  | DELETE | `/v1/server/webhooks/:id`      |
    | 웹훅 로그  | GET    | `/v1/server/webhooks/:id/logs` |
    | 웹훅 테스트 | POST   | `/v1/server/webhooks/:id/test` |
  </Tab>

  <Tab title="Node SDK">
    ```typescript theme={null}
    // 웹훅 등록
    const webhook = await server.webhooks.create({
      eventType: 'coupon.redeemed',
      url: 'https://your-server.com/webhooks/playcamp',
    });

    // 웹훅 목록
    const webhooks = await server.webhooks.listWebhooks();

    // 웹훅 수정
    await server.webhooks.update(webhook.id, {
      url: 'https://your-server.com/webhooks/v2',
    });

    // 웹훅 삭제
    await server.webhooks.remove(webhook.id);

    // 웹훅 전송 로그
    const logs = await server.webhooks.getLogs(webhook.id);

    // 테스트 이벤트 전송
    await server.webhooks.test(webhook.id);
    ```
  </Tab>

  <Tab title="Go SDK">
    ```go theme={null}
    // 웹훅 등록
    webhook, err := server.Webhooks.Create(ctx, playcamp.CreateWebhookParams{
        EventType: playcamp.WebhookEventCouponRedeemed,
        URL:       "https://your-server.com/webhooks/playcamp",
    })

    // 웹훅 목록
    webhooks, err := server.Webhooks.List(ctx)

    // 웹훅 수정
    updated, err := server.Webhooks.Update(ctx, webhook.ID, playcamp.UpdateWebhookParams{
        URL: playcamp.String("https://your-server.com/webhooks/v2"),
    })

    // 웹훅 삭제
    err := server.Webhooks.Delete(ctx, webhook.ID)

    // 웹훅 전송 로그
    logs, err := server.Webhooks.GetLogs(ctx, webhook.ID)

    // 테스트 이벤트 전송
    result, err := server.Webhooks.Test(ctx, webhook.ID)
    ```
  </Tab>
</Tabs>
