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

# 캠페인 예약 Webhook

> 프로젝트에 예약된 캠페인의 변경을 Webhook으로 받고, 예약된 캠페인을 API로 조회하는 방법을 안내합니다.

이 문서에서 예약된 캠페인은 광고주가 예약한 캠페인 중 승인을 받아 매체사의 집행 기간과 광고 유닛을 확보한 캠페인을 말합니다. 캠페인 예약 Webhook을 등록하면 예약된 캠페인이 승인·변경·일시정지·재개·취소·시작·종료될 때마다 Adrop이 등록한 URL로 이벤트를 보냅니다. 예약된 캠페인은 API로 직접 조회할 수도 있습니다.

| 구분 | 방향 | 설명 |
| - | - | - |
| [Webhook 등록](#webhook-등록) | 매체사 → Adrop | API 키로 이벤트를 받을 URL을 등록·조회·삭제합니다. |
| [이벤트 수신](#이벤트-수신) | Adrop → 매체사 | 예약된 캠페인이 바뀔 때마다 이벤트를 받습니다. |
| [예약된 캠페인 조회](#예약된-캠페인-조회) | 매체사 → Adrop | 예약된 캠페인을 조회합니다. 처음 연동할 때와 이벤트를 놓쳤을 때 상태를 맞추는 데 씁니다. |

이벤트와 조회 결과에는 캠페인 ID·제목, 상태, 광고 상품 ID·이름, 판매 방식, 예산, 광고주 이름·URL, 집행 기간, 광고 유닛·앱·타겟팅·타겟팅 옵션의 ID와 이름, 타겟팅 정책, 문맥 타겟팅에서 선택한 옵션의 컨텍스트 ID가 담깁니다. 광고주 이메일 같은 연락처, 소재, 단가·할증, 결제, 심사 내용은 담기지 않습니다.

***

## 사전 준비

* 다음 두 권한(scope)이 있는 API 키가 필요합니다. 키는 프로젝트 소유자가 **\[관리]** > **\[연동]** 메뉴에서 발급합니다. 키 발급 방법은 [연동 & API 키](/ko/integrations/overview)를 참고하세요.

  | 권한 | 용도 |
  | - | - |
  | `campaign:subscribe` | Webhook 등록·조회·삭제 |
  | `campaign:read` | 예약된 캠페인 조회 |

* `campaign:subscribe`는 API 키를 만들 때 기본으로 선택되어 있지 않으므로 직접 선택하세요. 이미 발급한 키의 권한은 바꿀 수 없습니다.

* 이벤트를 받을 서버에는 외부에서 접근할 수 있는 `https` URL이 필요합니다.

<Warning>
  API 키는 서버에만 보관하고 앱이나 웹 페이지에 넣지 마세요. 이벤트 서명도 이 키로 만들어지므로, 키가 노출되면 바로 폐기하고 새 키로 Webhook을 다시 등록하세요.
</Warning>

***

## 기본 정보

| 항목 | 값 |
| - | - |
| Base URL | `https://lake.adrop.io` |
| 인증 | `x-api-key` 헤더에 API 키를 넣습니다. 키로 프로젝트가 정해집니다. |
| Content-Type | `application/json` |
| ID 형식 | 26자 ULID입니다(예: `01K6Y8X9Z0A1B2C3D4E5F6G7H8`). |

| 메서드 | 경로 | 권한 | 설명 |
| - | - | - | - |
| `PUT` | `/webhook` | `campaign:subscribe` | [Webhook 등록·수정](#webhook-등록) |
| `GET` | `/webhook` | `campaign:subscribe` | [Webhook 설정 조회](#webhook-설정-조회) |
| `DELETE` | `/webhook` | `campaign:subscribe` | [Webhook 삭제](#webhook-삭제) |
| `GET` | `/campaigns` | `campaign:read` | [예약된 캠페인 목록 조회](#예약된-캠페인-목록-조회) |
| `GET` | `/campaigns/{campaignId}` | `campaign:read` | [예약된 캠페인 단건 조회](#예약된-캠페인-단건-조회) |

***

## Webhook 등록

이벤트를 받을 URL을 등록합니다. 프로젝트당 하나만 등록할 수 있으며, 이미 등록한 설정이 있으면 이 요청으로 수정합니다.

### 요청

```http theme={null}
PUT /webhook
```

### 요청 본문

| 필드 | 타입 | 필수 | 설명 |
| - | - | - | - |
| `url` | string | 예 | 이벤트를 받을 URL. `https`로 시작해야 하며 최대 2,048자입니다. |
| `events` | string\[] | 아니요 | 받을 [이벤트 종류](#이벤트-종류). 생략하거나 빈 배열을 보내면 모든 이벤트를 받습니다. |

* 이미 등록한 설정은 **통째로 바뀝니다**. 일부만 바꾸더라도 원하는 설정 전체를 보내세요. 예를 들어 `events`를 생략하면 모든 이벤트를 받는 설정으로 돌아갑니다.
* 위 두 필드 외의 필드를 보내면 `400`으로 실패합니다.
* `localhost`, 사설·내부 주소, 사설 IP로 해석되는 도메인, 해석할 수 없는 도메인, 사용자 정보가 들어간 URL(`https://user:pw@host`)은 등록할 수 없습니다. 이때는 `400 WEBHOOK_CONFIG_URL_NOT_ALLOWED`로 실패합니다.
* 다른 API 키로 다시 등록하면 이후 이벤트는 그 키로 서명됩니다. [키 교체와 폐기](#키-교체와-폐기)를 참고하세요.
* 등록하기 전에 예약된 캠페인의 이벤트는 따로 전송되지 않습니다. 등록한 뒤 [예약된 캠페인 조회](#예약된-캠페인-조회)로 현재 상태를 한 번 맞추세요.

### 요청 예시

```bash theme={null}
curl -X PUT https://lake.adrop.io/webhook \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://partner.example.com/adrop/booking",
    "events": ["booking.confirmed", "booking.updated", "booking.cancelled"]
  }'
```

### 응답 예시

`200`과 함께 저장된 설정을 반환합니다.

```json theme={null}
{
  "_id": "01J8K2M3N4P5Q6R7S8T9V0W1X2",
  "keyId": "01JRX7AAAAAAAAAAAAAAAAAAAA",
  "url": "https://partner.example.com/adrop/booking",
  "events": ["booking.confirmed", "booking.updated", "booking.cancelled"],
  "active": true,
  "createdAt": "2026-10-02T00:00:00.000Z",
  "updatedAt": "2026-10-02T00:00:00.000Z"
}
```

| 필드 | 설명 |
| - | - |
| `_id` | Webhook 설정 ID |
| `keyId` | 이벤트 서명에 쓰이는 API 키의 ID. 이벤트 요청의 `x-adrop-key-id` 헤더와 같은 값입니다. |
| `url` | 등록한 URL |
| `events` | 받을 이벤트 종류. 빈 배열이면 모든 이벤트를 받습니다. |
| `active` | 이벤트 전송 여부. 등록에 쓴 API 키를 폐기하면 `false`가 됩니다. |
| `createdAt`, `updatedAt` | 생성·수정 시각(ISO 8601) |

## Webhook 설정 조회

```bash theme={null}
curl https://lake.adrop.io/webhook \
  -H "x-api-key: YOUR_API_KEY"
```

* 응답은 [Webhook 등록](#webhook-등록)의 응답과 같은 형식입니다.
* 등록한 설정이 없으면 `404 WEBHOOK_NOT_FOUND`를 반환합니다.
* `active`가 `false`이면 이벤트 전송이 멈춘 상태입니다. 등록에 쓴 API 키가 폐기된 경우이며, 새 키로 다시 등록하면 전송이 재개됩니다.

## Webhook 삭제

```bash theme={null}
curl -X DELETE https://lake.adrop.io/webhook \
  -H "x-api-key: YOUR_API_KEY"
```

| 응답 | 의미 |
| - | - |
| `{ "result": true }` | 설정을 삭제했습니다. 이후에는 이벤트가 전송되지 않습니다. |
| `{ "result": false }` | 삭제할 설정이 없습니다. |

***

## 이벤트 수신

예약된 캠페인이 바뀌면 Adrop이 등록한 URL로 이벤트를 `POST`로 보냅니다.

### 이벤트 종류

| `eventType` | 보내는 시점 |
| - | - |
| `booking.confirmed` | 캠페인이 승인되어 예약이 확정될 때. 승인이 해제(`booking.cancelled`)된 뒤 다시 승인되면 또 보냅니다. |
| `booking.updated` | 예약된 캠페인의 제목, 광고 상품, 판매 방식, 예산, 광고주 이름·URL, 집행 기간, 타겟팅, 광고 유닛이 바뀔 때 |
| `booking.paused` | 예약된 캠페인이 일시정지될 때 |
| `booking.resumed` | 일시정지된 캠페인이 재개될 때 |
| `booking.cancelled` | 예약이 해제될 때. 캠페인 취소·삭제와, 광고주가 승인된 캠페인을 다시 심사 요청해 승인이 해제되는 경우를 포함합니다. 승인된 적 없는 캠페인이 취소되면 보내지 않습니다. |
| `booking.started` | 캠페인 시작 시각이 지난 뒤. 일시정지 중인 캠페인에도 보냅니다. |
| `booking.finished` | 캠페인 종료 시각이 지난 뒤 |

* 변경 하나에 이벤트는 최대 한 건입니다. 함께 바뀐 다른 값은 같은 이벤트의 `payload`에 담깁니다.
* `booking.updated`는 매체사에게 보이는 값이 실제로 바뀐 경우에만 보냅니다. 타겟팅 옵션이 삭제되어 캠페인의 타겟팅 조건이 바뀌면 보냅니다.
* 다음 경우에는 이벤트를 보내지 않습니다. 바뀐 값은 다음 이벤트나 [예약된 캠페인 조회](#예약된-캠페인-조회)에 반영됩니다.
  * 광고 상품, 광고 유닛, 앱, 타겟팅, 타겟팅 옵션의 **이름**이 바뀐 경우
  * 소재가 승인·반려되거나 바뀐 경우
  * 매체사가 새 타겟팅 옵션을 만들거나 하위 옵션을 옵션 그룹 밖으로 옮겨, 예약된 캠페인에 그 옵션의 설정이 자동으로 붙은 경우
* `booking.started`와 `booking.finished`는 시각이 지난 뒤 최대 1시간 늦게 도착할 수 있습니다. 정확한 시각은 `payload.startTime`과 `payload.endTime`으로 확인하세요. 두 이벤트는 시각이 지난 뒤 24시간 안에 한 번만 보냅니다.

### 요청 헤더

| 헤더 | 설명 |
| - | - |
| `Content-Type` | `application/json` |
| `x-adrop-key-id` | 서명에 쓴 API 키의 ID. Webhook 설정의 `keyId`와 같습니다. |
| `x-adrop-signature` | `t={timestamp},v1={서명}` 형식의 서명. [서명 검증](#서명-검증)을 참고하세요. |
| `x-adrop-event-id` | 본문의 `eventId`와 같은 값 |

### 이벤트 본문

| 필드 | 타입 | 설명 |
| - | - | - |
| `eventId` | string | 이벤트 ID(ULID). 재전송해도 같은 값이므로 중복 처리를 막는 키로 쓰세요. |
| `eventType` | string | [이벤트 종류](#이벤트-종류) |
| `timestamp` | number | 이벤트가 발생한 시각(epoch 밀리초). 캠페인이 바뀐 시각이며, `booking.started`와 `booking.finished`는 전송이 결정된 시각입니다. 전송 시각이 아니므로 먼저 일어난 이벤트가 더 작은 값을 가지며, 재전송해도 바뀌지 않습니다. |
| `payload` | object | 이벤트 시점의 캠페인 전체 정보. [캠페인 정보](#캠페인-정보)를 참고하세요. |

```http 요청 예시 theme={null}
POST /adrop/booking HTTP/1.1
Host: partner.example.com
Content-Type: application/json
x-adrop-key-id: 01JRX7AAAAAAAAAAAAAAAAAAAA
x-adrop-event-id: 01K6Z3A4B5C6D7E8F9G0H1J2K3
x-adrop-signature: t=1790899200000,v1=a3b8f2c4d5e6f7081a2b3c4d5e6f7081a2b3c4d5e6f7081a2b3c4d5e6f7081a2b

{
  "eventId": "01K6Z3A4B5C6D7E8F9G0H1J2K3",
  "eventType": "booking.confirmed",
  "timestamp": 1790899200000,
  "payload": {
    "campaignId": "01K6Y8X9Z0A1B2C3D4E5F6G7H8",
    "title": "가을 신상 프로모션",
    "status": "예약됨",
    "product": "01HD38N9K3EA5HB322NJMTU1NN",
    "productTitle": "메인 배너 CPM",
    "pricingType": "CPM",
    "budget": 5000000,
    "advertiser": { "name": "Adrop 데모 브랜드", "url": "https://brand.example.com" },
    "startTime": 1791126000000,
    "endTime": 1792421999000,
    "units": [
      {
        "app": "01HD38P0M4FB6JC433PKFNV2PP",
        "unit": "01HD38Q1R5GC7KD544QLGW3QQ",
        "title": "메인 배너",
        "appTitle": "Adrop 데모 앱"
      }
    ],
    "targetings": [
      {
        "targeting": "01HX5K8ZJ0TARGETING000001",
        "title": "관심사",
        "kind": "오디언스",
        "policies": {
          "01HX5K8ZJ0AAAAAAAAAAAAAAA1": { "policy": "any", "select": "included", "title": "스포츠" }
        }
      },
      {
        "targeting": "01HX5K8ZJ0TARGETING000002",
        "title": "기사 섹션",
        "kind": "문맥",
        "policies": {
          "01HX5K8ZJ0DDDDDDDDDDDDDDD4": {
            "policy": "any",
            "select": "included",
            "title": "여행",
            "contexts": ["travel", "hotel", "flight"],
            "contextCount": 3
          }
        }
      }
    ]
  }
}
```

모든 이벤트는 같은 구조이며, `eventType`과 그 시점의 값만 다릅니다. 객체 안의 키 순서는 의미가 없습니다.

### 캠페인 정보

`payload`와 [예약된 캠페인 조회](#예약된-캠페인-조회) 결과는 같은 구조입니다. 필드 이름 끝의 `?`는 값이 없을 때 키 자체가 빠지는 선택 필드라는 뜻입니다.

| 필드 | 타입 | 설명 |
| - | - | - |
| `campaignId` | string | 캠페인 ID |
| `title?` | string | 광고주가 입력한 캠페인 제목 |
| `status` | string | 이벤트 시점(조회 시점)의 캠페인 상태. [상태 값](#상태-값)을 참고하세요. |
| `product` | string | 예약된 광고 상품 ID |
| `productTitle?` | string | 광고 상품 이름 |
| `pricingType` | string | 예약 당시의 판매 방식. `CPM`, `CPC`, `CPP`, `인하우스` 중 하나입니다. 저장된 값이 없는 예외적인 캠페인이면 빈 문자열입니다. |
| `budget` | number | 캠페인 예산(광고 상품 통화 기준). 정산 금액이 아닙니다. |
| `advertiser?` | object | 광고주가 입력한 광고주 정보. `name?`(광고주 이름)과 `url?`(광고주 URL)이 들어 있습니다. |
| `startTime` | number | 집행 시작 시각(epoch 밀리초) |
| `endTime` | number | 집행 종료 시각(epoch 밀리초) |
| `units` | object\[] | 예약된 광고 유닛. 같은 광고 유닛은 한 번만 나옵니다. |
| `targetings` | object\[] | 캠페인에 적용된 타겟팅. 없으면 빈 배열입니다. |

`units`의 각 항목에는 다음 필드가 있습니다.

| 필드 | 설명 |
| - | - |
| `app` | 앱 ID |
| `unit` | 광고 유닛 ID |
| `title?` | 광고 유닛 이름 |
| `appTitle?` | 앱 이름 |

`targetings`의 각 항목에는 다음 필드가 있습니다. 타겟팅과 타겟팅 옵션은 ID로 매칭하고, 이름은 사람이 알아보는 용도로만 쓰세요.

| 필드 | 설명 |
| - | - |
| `targeting` | 타겟팅 ID |
| `title?` | 타겟팅 이름 |
| `kind?` | 타겟팅 종류. `오디언스` 또는 `문맥`입니다. |
| `policies` | 타겟팅 옵션 ID를 키로 하는 객체. 각 값에 아래 필드가 들어 있습니다. |
| `policies.*.policy` | 광고 상품에서 이 옵션에 정한 규칙. `any`(자유 선택), `included`(필수 포함), `excluded`(필수 제외), `none`(쓰지 않음) |
| `policies.*.select?` | 광고주가 캠페인에서 고른 방식. `included`(포함) 또는 `excluded`(제외)이며, 고르지 않았으면 키가 없거나 `none`입니다. |
| `policies.*.title?` | 타겟팅 옵션 이름 |
| `policies.*.contexts?` | 문맥 타겟팅에서 캠페인이 포함 또는 제외로 선택한 옵션의 컨텍스트 ID. 최대 200개까지 담깁니다. 옵션 그룹이면 자체 컨텍스트 ID와 하위 옵션의 컨텍스트 ID를 합친 목록(중복 제외)입니다. |
| `policies.*.contextCount?` | `contexts`의 전체 개수. `contexts`에 담긴 개수보다 크면 200개를 넘어 일부만 담긴 것입니다. |

* `contexts`와 `contextCount`는 문맥 타겟팅에서 캠페인이 포함 또는 제외로 선택한 옵션에만 있습니다. 오디언스 타겟팅이나 선택하지 않은 옵션에는 없습니다.
* 선택한 옵션에 컨텍스트 ID가 없으면 `contexts`는 `[]`, `contextCount`는 `0`입니다.
* 200개를 넘는 컨텍스트 ID는 [문맥 타겟팅 API](/ko/integrations/context-targeting/overview)로 조회하세요.

<Note>
  선택 필드(`title`, `productTitle`, `advertiser`, `units`의 `title`·`appTitle`, `targetings`의 `title`·`kind`, `policies`의 `title`·`contexts`·`contextCount`)는 이벤트를 보내는 시점에 조회한 값입니다. 해당 항목이 삭제되어 조회되지 않으면 키가 빠지고 ID만 남으므로, 선택 필드가 없어도 오류로 처리하지 마세요.
</Note>

### 상태 값

`status`는 아래 문자열 중 하나입니다. 문자열을 그대로 비교하세요.

| 값 | 의미 |
| - | - |
| `예약됨` | 승인되었고 시작 시각 전입니다. |
| `진행중` | 시작 시각이 지났고 종료 전입니다. |
| `정지됨` | 승인된 캠페인이 종료 전에 일시정지되어 있습니다. |
| `종료됨` | 승인된 캠페인의 종료 시각이 지났습니다. 일시정지 중이어도 종료 시각이 지나면 `종료됨`입니다. |
| `취소됨` | 취소·삭제되었거나, 승인이 해제된 채 종료 시각이 지났습니다. 취소·삭제된 캠페인은 종료 시각이 지났어도 `취소됨`입니다. |
| `심사중` | `booking.cancelled`에서만 나옵니다. 광고주가 승인된 캠페인을 종료 전에 다시 심사 요청해 승인이 해제된 상태입니다. |

`status`는 소재 승인 여부와 관계없습니다.

***

## 서명 검증

모든 이벤트 요청에는 `x-adrop-signature` 헤더가 있습니다. 요청을 처리하기 전에 서명을 검증하세요.

```text theme={null}
x-adrop-signature: t={timestamp},v1={hex(HMAC-SHA256(signingSecret, "{timestamp}.{rawBody}"))}
```

* `signingSecret`은 Webhook 등록에 쓴 API 키를 SHA-256으로 해시한 16진수 문자열입니다. 따로 발급받지 않고 API 키로 직접 계산합니다.
* `t`는 서명한 시각(epoch 밀리초)입니다. 현재 시각과 5분 넘게 차이 나면 거부하세요.
* 재전송할 때마다 `t`와 서명은 새로 만들어지지만, 본문(`eventId`와 `timestamp` 포함)은 같습니다.
* 어느 API 키로 서명했는지는 `x-adrop-key-id`로 확인합니다. 키를 교체하는 동안에는 두 키가 섞여 올 수 있습니다.

<Warning>
  서명은 받은 요청 본문의 원본 바이트로 검증해야 합니다. 파싱한 JSON 객체를 다시 문자열로 만들면 키 순서나 공백이 달라져 검증에 실패합니다.
</Warning>

```javascript Node.js(Express) 예시 theme={null}
const crypto = require('crypto')
const express = require('express')

const app = express()

// 서명 검증에 쓸 원본 본문을 보관합니다
app.use(express.json({
  verify: (req, _res, buf) => { req.rawBody = buf }
}))

const sha256Hex = (value) => crypto.createHash('sha256').update(value).digest('hex')

// keyId → signingSecret. 키를 교체하는 동안에는 이전 키도 함께 등록합니다
const SIGNING_SECRETS = new Map([
  [process.env.ADROP_KEY_ID, sha256Hex(process.env.ADROP_API_KEY)],
])

function parseSignature(header) {
  if (!header) return null
  const parts = Object.fromEntries(
    header.split(',').map((part) => {
      const i = part.indexOf('=')
      return i > 0 ? [part.slice(0, i), part.slice(i + 1)] : [part, '']
    })
  )
  const ts = Number(parts.t)
  if (!Number.isFinite(ts) || !parts.v1) return null
  return { ts, sig: parts.v1 }
}

function verifySignature(rawBody, signatureHeader, signingSecret) {
  const parsed = parseSignature(signatureHeader)
  if (!parsed) return false
  if (Math.abs(Date.now() - parsed.ts) > 5 * 60 * 1000) return false

  const expected = crypto
    .createHmac('sha256', signingSecret)
    .update(`${parsed.ts}.${rawBody.toString('utf8')}`)
    .digest()
  const provided = Buffer.from(parsed.sig, 'hex')
  return expected.length === provided.length && crypto.timingSafeEqual(expected, provided)
}

app.post('/adrop/booking', async (req, res) => {
  const signingSecret = SIGNING_SECRETS.get(req.get('x-adrop-key-id'))
  if (!signingSecret || !verifySignature(req.rawBody, req.get('x-adrop-signature'), signingSecret)) {
    return res.status(401).send('Invalid signature')
  }

  const { eventId, eventType, timestamp, payload } = req.body
  // 이벤트 처리는 아래 "이벤트 처리 규칙"을 따르세요
  await handleBookingEvent({ eventId, eventType, timestamp, payload })
  res.sendStatus(200)
})
```

***

## 응답과 재전송

* 이벤트를 처리하면 **10초 안에** `2xx`로 응답하세요. 응답 본문은 쓰지 않습니다. 오래 걸리는 처리는 비동기로 넘기고 먼저 응답하세요.
* 일시적인 장애라면 `5xx`로 응답하세요. `3xx`와 `4xx`는 다시 보내지 않습니다.

| 항목 | 값 |
| - | - |
| 응답 대기 시간 | 10초 |
| 성공 판정 | `2xx` 응답 |
| 전송 횟수 | 최대 5회(최초 1회와 재전송 4회) |
| 재전송 간격 | 약 10초, 20초, 40초, 80초로 늘어납니다. |
| 재전송하는 경우 | `5xx` 응답, 네트워크 오류, 응답 대기 시간 초과, 일시적인 DNS 오류 |
| 재전송하지 않는 경우 | `3xx` 응답(리다이렉트를 따라가지 않습니다), `4xx` 응답, URL이 사설 IP로 해석되는 경우, 도메인이 없는 경우 |
| 모두 실패한 경우 | 그 이벤트는 다시 보내지 않습니다. 놓친 이벤트는 [예약된 캠페인 조회](#예약된-캠페인-조회)로 상태를 맞추세요. |

<Warning>
  서명 오류로 `401`을 반환하거나, 배포 중에 잠시 `404`나 `429` 같은 `4xx`를 반환해도 그 이벤트는 다시 오지 않습니다. 일시적인 장애에는 `5xx`로 응답하세요.
</Warning>

### 이벤트 처리 규칙

1. **`eventId`로 중복을 거르세요.** 같은 이벤트가 최대 5번 도착할 수 있습니다. 이미 처리한 `eventId`가 오면 다시 처리하지 말고 `2xx`로 응답하세요. 처리에 성공한 뒤에 `eventId`를 기록해야, 저장에 실패해 `5xx`로 응답한 이벤트가 재전송될 때 다시 처리할 수 있습니다.
2. **도착 순서를 믿지 마세요.** 재전송이나 동시에 일어난 변경 때문에 순서가 바뀌어 도착할 수 있고, `eventId`의 크기로도 순서를 알 수 없습니다. 캠페인마다 마지막으로 적용한 `timestamp`를 보관하고, 그보다 **작은** `timestamp`의 이벤트만 무시하세요. 같으면 적용합니다.
3. **`payload`로 덮어쓰세요.** `payload`는 매번 캠페인 전체 정보이므로 `campaignId` 기준으로 덮어쓰면 이벤트를 놓치거나 순서가 바뀌어도 최신 상태로 맞춰집니다. 선택 필드가 빠진 `payload`로 덮어쓰면 이전에 저장한 이름도 사라집니다.
4. **`eventType`은 변화, `status`는 상태입니다.** 두 값이 항상 짝이 맞지는 않습니다. 예를 들어 종료 전에 승인이 해제된 캠페인의 `booking.cancelled`는 `status`가 `심사중`이고, 일시정지 중에 종료된 캠페인의 `booking.finished`는 `종료됨`입니다.
5. **`budget`은 정산 금액이 아닙니다.** 정산과 매출 집계에는 [리포트 API](/ko/integrations/reports)를 쓰세요.

### 받을 이벤트 고르기

`events`로 받을 이벤트를 고를 때 `booking.confirmed`와 `booking.cancelled`만 고르면 승인 뒤의 집행 기간, 타겟팅, 광고 유닛, 예산, 광고주 정보 변경(`booking.updated`)을 놓칩니다. 변경 사항을 반영해야 한다면 `booking.updated`도 받거나 `events`를 비워 모든 이벤트를 받으세요.

## 키 교체와 폐기

이벤트는 **마지막으로 Webhook을 등록한 API 키**로 서명됩니다. 키를 바꿀 때는 다음 순서를 따르세요.

<Steps>
  <Step title="새 API 키 발급">
    `campaign:read`와 `campaign:subscribe` 권한으로 새 API 키를 발급합니다.
  </Step>

  <Step title="두 키로 검증하도록 배포">
    수신 서버가 새 키와 이전 키 모두로 서명을 검증하도록 배포합니다. 어느 키로 서명했는지는 `x-adrop-key-id`로 구분합니다.
  </Step>

  <Step title="새 키로 다시 등록">
    새 키로 `PUT /webhook`을 호출합니다. 이후 보내는 이벤트는 새 키로 서명됩니다.
  </Step>

  <Step title="교체 기간 두기">
    등록 시점에 이미 전송 중이거나 재전송을 기다리던 이벤트는 이전 키로 서명되어 도착할 수 있습니다. 10분 이상 기다리세요.
  </Step>

  <Step title="이전 키 폐기">
    콘솔에서 이전 키를 폐기하고, 수신 서버에서도 이전 키 검증을 지웁니다.
  </Step>
</Steps>

<Warning>
  Webhook 등록에 쓴 API 키를 폐기하면 Webhook이 비활성화(`active: false`)되어 이벤트 전송이 멈춥니다. 키를 바꿀 때는 반드시 새 키로 다시 등록한 **뒤에** 이전 키를 폐기하세요.
</Warning>

### 보안 권장 사항

* API 키는 시크릿 관리 도구처럼 안전한 곳에만 보관하고, 설정 파일이나 저장소, 메신저로 공유하지 마세요.
* 키가 노출된 것 같으면 콘솔에서 바로 폐기하세요. 폐기는 즉시 반영되며, 그 키로 등록한 Webhook도 함께 비활성화됩니다.
* 서명 검증에 실패한 요청은 처리하지 마세요. 접근 로그에는 `x-adrop-signature` 값을 가려서 남기세요.

***

## 예약된 캠페인 조회

예약된 캠페인을 조회합니다. Webhook을 처음 등록한 뒤 현재 상태를 맞추거나, 놓친 이벤트를 복구할 때 쓰세요. `campaign:read` 권한이 필요합니다.

조회 대상은 승인된 캠페인 중 종료 시각이 지나지 않은 캠페인(진행 중이거나 시작 전)입니다. 각 항목은 이벤트의 `payload`와 같은 [캠페인 정보](#캠페인-정보) 구조입니다.

### 예약된 캠페인 목록 조회

```http theme={null}
GET /campaigns
```

| 쿼리 파라미터 | 타입 | 필수 | 설명 |
| - | - | - | - |
| `unit` | string | 아니요 | 이 광고 유닛이 포함된 캠페인만 조회합니다. 최대 64자 |
| `next` | string | 아니요 | 이전 응답의 `next` 값. 다음 페이지를 조회합니다. |
| `limit` | number | 아니요 | 페이지당 항목 수. 1–120, 기본값 20 |

* 종료 시각(`endTime`)이 늦은 순서, 같으면 캠페인 ID 역순으로 정렬합니다.
* 응답에 `next`가 있으면 다음 페이지가 있습니다. `next` 값을 그대로 다음 요청의 `next`로 보내세요. 마지막 페이지에는 `next`가 없습니다.

```bash 요청 예시 theme={null}
curl "https://lake.adrop.io/campaigns?unit=01HD38Q1R5GC7KD544QLGW3QQ&limit=20" \
  -H "x-api-key: YOUR_API_KEY"
```

```json 응답 예시 theme={null}
{
  "items": [
    {
      "campaignId": "01K6Y8X9Z0A1B2C3D4E5F6G7H8",
      "title": "가을 신상 프로모션",
      "status": "예약됨",
      "product": "01HD38N9K3EA5HB322NJMTU1NN",
      "productTitle": "메인 배너 CPM",
      "pricingType": "CPM",
      "budget": 5000000,
      "advertiser": { "name": "Adrop 데모 브랜드", "url": "https://brand.example.com" },
      "startTime": 1791126000000,
      "endTime": 1792421999000,
      "units": [
        {
          "app": "01HD38P0M4FB6JC433PKFNV2PP",
          "unit": "01HD38Q1R5GC7KD544QLGW3QQ",
          "title": "메인 배너",
          "appTitle": "Adrop 데모 앱"
        }
      ],
      "targetings": []
    }
  ],
  "next": "01K6Y8X9Z0A1B2C3D4E5F6G7H8"
}
```

예약된 캠페인 전체를 처음부터 끝까지 읽어 저장하는 예시입니다.

```javascript 전체 동기화 예시 theme={null}
async function syncAllBookings() {
  let next
  do {
    const query = new URLSearchParams({ limit: '100', ...(next && { next }) })
    const res = await fetch(`https://lake.adrop.io/campaigns?${query}`, {
      headers: { 'x-api-key': process.env.ADROP_API_KEY },
    })
    if (!res.ok) throw new Error(`Request failed: ${res.status}`)
    const { items, next: nextCursor } = await res.json()
    for (const item of items) await saveBooking(item.campaignId, item)
    next = nextCursor
  } while (next)
}
```

조회 결과에 없는 캠페인은 취소되었거나 종료된 캠페인입니다. 취소 여부와 시각은 조회 API가 아니라 `booking.cancelled` 이벤트로 확인하세요.

### 예약된 캠페인 단건 조회

```http theme={null}
GET /campaigns/{campaignId}
```

```bash 요청 예시 theme={null}
curl https://lake.adrop.io/campaigns/01K6Y8X9Z0A1B2C3D4E5F6G7H8 \
  -H "x-api-key: YOUR_API_KEY"
```

* 응답은 목록의 `items` 항목 하나와 같은 구조입니다.
* `campaignId`가 ULID 형식이 아니면 `400`을 반환합니다.
* 취소·종료된 캠페인, 승인되지 않은 캠페인, 다른 프로젝트의 캠페인, 없는 캠페인은 모두 `404 CAMPAIGN_NOT_FOUND`를 반환합니다. 취소 여부는 `booking.cancelled` 이벤트로 확인하세요.

***

## 오류

| HTTP | 코드 | 원인 | 조치 |
| - | - | - | - |
| `400` | (검증 오류) | `url`이 `https`가 아니거나 2,048자를 넘음, `events`에 없는 이벤트 종류, 정의되지 않은 필드, `limit` 범위 초과, `campaignId`가 ULID 형식이 아님 | 요청을 고치세요. |
| `400` | `WEBHOOK_CONFIG_URL_NOT_ALLOWED` | 등록할 수 없는 URL(사설·내부 주소, 사용자 정보가 들어간 URL, 사설 IP로 해석되거나 해석할 수 없는 도메인) | 외부에서 접근할 수 있는 공개 `https` URL을 쓰세요. |
| `400` | `WEBHOOK_CONFIG_EVENT_TYPE_MISMATCH` | `events`에 캠페인 예약 Webhook이 보내지 않는 이벤트 종류가 있음 | `events`를 [이벤트 종류](#이벤트-종류)의 7개 값으로 제한하세요. |
| `401` | - | API 키가 없거나 올바르지 않거나 폐기됨 | API 키를 확인하세요. |
| `403` | - | 필요한 권한이 없는 키(`/webhook`은 `campaign:subscribe`, `/campaigns`는 `campaign:read`) | 권한이 있는 키를 쓰세요. |
| `404` | `WEBHOOK_NOT_FOUND` | `GET /webhook`: 등록한 설정이 없음 | `PUT /webhook`으로 등록하세요. |
| `404` | `AUTH_KEY_NOT_FOUND` | 요청한 API 키가 폐기되었거나 이 프로젝트의 키가 아님 | 유효한 키로 다시 요청하세요. |
| `404` | `CAMPAIGN_NOT_FOUND` | `GET /campaigns/{campaignId}`: 조회할 수 없는 캠페인 | 이벤트나 목록 조회로 상태를 확인하세요. |
| `502` | - | 일시적인 서버 오류 | 잠시 뒤 같은 요청을 다시 보내세요. 이 API의 요청은 반복해서 보내도 안전합니다. |

오류 코드는 응답 본문의 `errorCode` 또는 `message` 필드에 담깁니다.

***

## 연동 체크리스트

* [ ] `campaign:read`와 `campaign:subscribe` 권한이 있는 API 키를 발급해 안전하게 보관했습니다.
* [ ] `PUT /webhook`으로 `https` URL을 등록하고, `GET /webhook`에서 `active: true`를 확인했습니다.
* [ ] 정상, 위조, 만료된 요청으로 서명 검증을 확인했고, 원본 본문으로 검증합니다.
* [ ] `eventId`로 중복을 거르고, 보관한 값보다 작은 `timestamp`의 이벤트만 무시합니다.
* [ ] 10초 안에 `2xx`로 응답하고, 일시적인 장애에는 `5xx`로 응답합니다.
* [ ] 선택 필드가 없어도 오류로 처리하지 않고, `contextCount`가 `contexts` 개수보다 크면 일부만 담긴 것으로 처리합니다.
* [ ] `GET /campaigns`로 현재 예약된 캠페인을 처음 한 번 동기화했습니다.
* [ ] 키를 교체할 때 두 키로 검증하는 절차를 갖췄습니다.

***

## 관련 문서

<CardGroup cols={2}>
  <Card title="연동 & API 키" icon="key" href="/ko/integrations/overview">
    API 키 발급과 권한 설정 방법
  </Card>

  <Card title="문맥 타겟팅 API" icon="code" href="/ko/integrations/context-targeting/overview">
    타겟팅 옵션과 컨텍스트 ID를 서버에서 관리하는 방법
  </Card>
</CardGroup>


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