> ## 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.

# Campaign Booking Webhook

> Receive changes to campaigns booked on your project with a webhook, and look up booked campaigns with the API.

A booking happens when an advertiser's campaign is approved and takes your schedule and ad units. When you register a campaign booking webhook, Adrop sends an event to your URL each time a booked campaign is approved, changed, paused, resumed, canceled, started, or finished. You can also look up booked campaigns with the API.

| Part | Direction | Description |
| - | - | - |
| [Register a webhook](#register-a-webhook) | You → Adrop | Register, read, and delete the URL that receives events, with an API key. |
| [Receiving events](#receiving-events) | Adrop → You | Receive an event each time a booked campaign changes. |
| [Booked campaigns](#booked-campaigns) | You → Adrop | Look up booked campaigns. Use it for the first sync and to catch up after missed events. |

Events and lookups include the campaign ID and title, status, ad product ID and name, pricing type, budget, advertiser name and URL, schedule, the IDs and names of ad units, apps, targetings, and targeting options, targeting policies, and the Context IDs of options selected in context targeting. They don't include advertiser contact details such as email, creatives, rates or surcharges, payments, or review details.

***

## Prerequisites

* You need an API key with these two scopes. The project owner issues keys from **\[Settings]** > **\[Integrations]**. For how to issue a key, see [Integrations & API Keys](/integrations/overview).

  | Scope | Used for |
  | - | - |
  | `campaign:subscribe` | Registering, reading, and deleting the webhook |
  | `campaign:read` | Looking up booked campaigns |

* `campaign:subscribe` isn't selected by default when you create an API key, so select it yourself. You can't change the scopes of a key after it's issued.

* Your receiving server needs an `https` URL that's reachable from the internet.

<Warning>
  Keep the API key on your server only, and never put it in apps or web pages. Events are signed with this key too, so if the key is exposed, revoke it right away and register the webhook again with a new key.
</Warning>

***

## Basic Information

| Item | Value |
| - | - |
| Base URL | `https://lake.adrop.io` |
| Authentication | API key in the `x-api-key` header. The key determines the project. |
| Content-Type | `application/json` |
| ID format | 26-character ULID, such as `01K6Y8X9Z0A1B2C3D4E5F6G7H8`. |

| Method | Path | Scope | Description |
| - | - | - | - |
| `PUT` | `/webhook` | `campaign:subscribe` | [Register or update the webhook](#register-a-webhook) |
| `GET` | `/webhook` | `campaign:subscribe` | [Get the webhook settings](#get-the-webhook-settings) |
| `DELETE` | `/webhook` | `campaign:subscribe` | [Delete the webhook](#delete-the-webhook) |
| `GET` | `/campaigns` | `campaign:read` | [List booked campaigns](#list-booked-campaigns) |
| `GET` | `/campaigns/{campaignId}` | `campaign:read` | [Get a booked campaign](#get-a-booked-campaign) |

***

## Register a Webhook

Registers the URL that receives events. A project can have one webhook. If one is already registered, this request updates it.

### Request

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

### Request Body

| Field | Type | Required | Description |
| - | - | - | - |
| `url` | string | Yes | The URL that receives events. It must start with `https`, up to 2,048 characters. |
| `events` | string\[] | No | The [event types](#event-types) to receive. Omit it or send an empty array to receive all events. |

* The existing settings are **replaced as a whole**. Send all the settings you want, even to change one of them. For example, omitting `events` switches back to receiving all events.
* Any other field fails with `400`.
* You can't register `localhost`, private or internal addresses, domains that resolve to a private IP, domains that can't be resolved, or URLs with user information (`https://user:pw@host`). These fail with `400 WEBHOOK_CONFIG_URL_NOT_ALLOWED`.
* If you register again with another API key, later events are signed with that key. See [Key Rotation and Revocation](#key-rotation-and-revocation).
* Campaigns booked before you register don't get events for that. After you register, use [Booked Campaigns](#booked-campaigns) once to sync the current state.

### Example Request

```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"]
  }'
```

### Example Response

Returns `200` with the saved settings.

```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"
}
```

| Field | Description |
| - | - |
| `_id` | Webhook settings ID |
| `keyId` | ID of the API key that signs events. Same as the `x-adrop-key-id` header of event requests. |
| `url` | The registered URL |
| `events` | The event types to receive. An empty array means all events. |
| `active` | Whether events are sent. It becomes `false` when the API key used for registration is revoked. |
| `createdAt`, `updatedAt` | Created and updated time (ISO 8601) |

## Get the Webhook Settings

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

* The response has the same format as the response of [Register a Webhook](#register-a-webhook).
* If nothing is registered, it returns `404 WEBHOOK_NOT_FOUND`.
* If `active` is `false`, events have stopped because the API key used for registration was revoked. Register again with a new key to resume them.

## Delete the Webhook

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

| Response | Meaning |
| - | - |
| `{ "result": true }` | The settings were deleted. No more events are sent. |
| `{ "result": false }` | There were no settings to delete. |

***

## Receiving Events

When a booked campaign changes, Adrop sends an event to your URL with `POST`.

### Event Types

| `eventType` | When it's sent |
| - | - |
| `booking.confirmed` | When a campaign is approved and its booking is confirmed. If the approval is withdrawn (`booking.cancelled`) and the campaign is approved again, it's sent again. |
| `booking.updated` | When the title, ad product, pricing type, budget, advertiser name or URL, schedule, targeting, or ad units of a booked campaign change |
| `booking.paused` | When a booked campaign is paused |
| `booking.resumed` | When a paused campaign is resumed |
| `booking.cancelled` | When a booking is released. This includes canceled or deleted campaigns and approved campaigns that the advertiser submits for review again, which withdraws the approval. It isn't sent for campaigns that were never approved. |
| `booking.started` | After the campaign's start time. It's also sent for paused campaigns. |
| `booking.finished` | After the campaign's end time |

* One change sends at most one event. Other values that changed at the same time are in that event's `payload`.
* `booking.updated` is sent only when a value that you can see actually changes. It's sent when a targeting option is deleted and the campaign's targeting conditions change.
* No event is sent in these cases. The new values appear in the next event and in [Booked Campaigns](#booked-campaigns).
  * The **name** of an ad product, ad unit, app, targeting, or targeting option changes.
  * A creative is approved, rejected, or changed.
  * You create a new targeting option or move a sub-option out of its option group, and booked campaigns automatically get the option's settings.
* `booking.started` and `booking.finished` can arrive up to an hour after the time passes. Use `payload.startTime` and `payload.endTime` for the exact times. Each of the two events is sent only once, within 24 hours after the time passes.

### Request Headers

| Header | Description |
| - | - |
| `Content-Type` | `application/json` |
| `x-adrop-key-id` | ID of the API key that signed the request. Same as `keyId` in the webhook settings. |
| `x-adrop-signature` | Signature in the format `t={timestamp},v1={signature}`. See [Signature Verification](#signature-verification). |
| `x-adrop-event-id` | Same value as `eventId` in the body |

### Event Body

| Field | Type | Description |
| - | - | - |
| `eventId` | string | Event ID (ULID). It stays the same on resends, so use it as the key to prevent duplicate processing. |
| `eventType` | string | [Event type](#event-types) |
| `timestamp` | number | When the event happened, in epoch milliseconds. It's the time the campaign changed, and for `booking.started` and `booking.finished`, the time sending was decided. It isn't the send time, so an earlier event has a smaller value, and resends don't change it. |
| `payload` | object | Full information of the campaign at the time of the event. See [Campaign Information](#campaign-information). |

```http Example request 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": "Fall New Arrivals Promotion",
    "status": "예약됨",
    "product": "01HD38N9K3EA5HB322NJMTU1NN",
    "productTitle": "Main Banner CPM",
    "pricingType": "CPM",
    "budget": 5000000,
    "advertiser": { "name": "Adrop Demo Brand", "url": "https://brand.example.com" },
    "startTime": 1791126000000,
    "endTime": 1792421999000,
    "units": [
      {
        "app": "01HD38P0M4FB6JC433PKFNV2PP",
        "unit": "01HD38Q1R5GC7KD544QLGW3QQ",
        "title": "Main Banner",
        "appTitle": "Adrop Demo App"
      }
    ],
    "targetings": [
      {
        "targeting": "01HX5K8ZJ0TARGETING000001",
        "title": "Interests",
        "kind": "오디언스",
        "policies": {
          "01HX5K8ZJ0AAAAAAAAAAAAAAA1": { "policy": "any", "select": "included", "title": "Sports" }
        }
      },
      {
        "targeting": "01HX5K8ZJ0TARGETING000002",
        "title": "Article Sections",
        "kind": "문맥",
        "policies": {
          "01HX5K8ZJ0DDDDDDDDDDDDDDD4": {
            "policy": "any",
            "select": "included",
            "title": "Travel",
            "contexts": ["travel", "hotel", "flight"],
            "contextCount": 3
          }
        }
      }
    ]
  }
}
```

Every event has the same structure. Only `eventType` and the values at that time differ. The order of keys in an object has no meaning.

### Campaign Information

`payload` and the results of [Booked Campaigns](#booked-campaigns) have the same structure. A `?` at the end of a field name marks an optional field whose key is omitted when it has no value.

| Field | Type | Description |
| - | - | - |
| `campaignId` | string | Campaign ID |
| `title?` | string | Campaign title entered by the advertiser |
| `status` | string | Campaign status at the time of the event or lookup. See [Status Values](#status-values). |
| `product` | string | ID of the booked ad product |
| `productTitle?` | string | Ad product name |
| `pricingType` | string | Pricing type at the time of booking: `CPM`, `CPC`, `CPP`, or `인하우스` (in-house). It's an empty string for an irregular campaign with no stored value. |
| `budget` | number | Campaign budget, in the ad product's currency. It isn't a settlement amount. |
| `advertiser?` | object | Advertiser information entered by the advertiser, with `name?` (advertiser name) and `url?` (advertiser URL) |
| `startTime` | number | Start time, in epoch milliseconds |
| `endTime` | number | End time, in epoch milliseconds |
| `units` | object\[] | Booked ad units. Each ad unit appears once. |
| `targetings` | object\[] | Targetings applied to the campaign. An empty array if there are none. |

Each item of `units` has these fields:

| Field | Description |
| - | - |
| `app` | App ID |
| `unit` | Ad unit ID |
| `title?` | Ad unit name |
| `appTitle?` | App name |

Each item of `targetings` has these fields. Match targetings and targeting options by ID, and use names only for people to read.

| Field | Description |
| - | - |
| `targeting` | Targeting ID |
| `title?` | Targeting name |
| `kind?` | Targeting type: `오디언스` (audience) or `문맥` (context) |
| `policies` | An object keyed by targeting option ID. Each value has the fields below. |
| `policies.*.policy` | The rule that the ad product sets for the option: `any` (Free Selection), `included` (Required Include), `excluded` (Required Exclude), or `none` (not used) |
| `policies.*.select?` | What the advertiser chose in the campaign: `included` (include) or `excluded` (exclude). If the advertiser didn't choose, the key is omitted or the value is `none`. |
| `policies.*.title?` | Targeting option name |
| `policies.*.contexts?` | Context IDs of an option that the campaign includes or excludes in context targeting, up to 200. For an option group, it's the list of its own Context IDs and those of its sub-options, without duplicates. |
| `policies.*.contextCount?` | Total number of `contexts`. If it's larger than the number of items in `contexts`, there are more than 200 and only some are included. |

* `contexts` and `contextCount` appear only for options that the campaign includes or excludes in context targeting. Audience targetings and options that aren't selected don't have them.
* If a selected option has no Context IDs, `contexts` is `[]` and `contextCount` is `0`.
* To get more than 200 Context IDs, use the [Context Targeting API](/integrations/context-targeting/overview).

<Note>
  Optional fields (`title`, `productTitle`, `advertiser`, `title` and `appTitle` in `units`, `title` and `kind` in `targetings`, and `title`, `contexts`, and `contextCount` in `policies`) are looked up when the event is sent. If the item was deleted and can't be found, the key is omitted and only the ID remains, so don't treat a missing optional field as an error.
</Note>

### Status Values

`status` is one of the strings below. Compare the strings as they are.

| Value | Meaning |
| - | - |
| `예약됨` | Approved, before the start time. |
| `진행중` | After the start time, before the end. |
| `정지됨` | An approved campaign is paused before its end. |
| `종료됨` | The end time of an approved campaign has passed. A paused campaign is also `종료됨` after its end time. |
| `취소됨` | Canceled or deleted, or the end time passed while the approval was withdrawn. A canceled or deleted campaign is `취소됨` even after its end time. |
| `심사중` | Only in `booking.cancelled`. The advertiser submitted an approved campaign for review again before its end, which withdrew the approval. |

`status` doesn't depend on whether creatives are approved.

***

## Signature Verification

Every event request has an `x-adrop-signature` header. Verify the signature before you process the request.

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

* `signingSecret` is the SHA-256 hash, as a hexadecimal string, of the API key used to register the webhook. You compute it from the API key; it isn't issued separately.
* `t` is the signing time in epoch milliseconds. Reject requests where it differs from the current time by more than 5 minutes.
* Each resend has a new `t` and signature, but the body, including `eventId` and `timestamp`, stays the same.
* `x-adrop-key-id` tells you which API key signed the request. While you rotate keys, events signed with either key can arrive.

<Warning>
  Verify the signature against the raw bytes of the request body you received. If you turn a parsed JSON object back into a string, the key order or whitespace can change and verification fails.
</Warning>

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

const app = express()

// Keep the raw body for signature verification
app.use(express.json({
  verify: (req, _res, buf) => { req.rawBody = buf }
}))

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

// keyId → signingSecret. While you rotate keys, register the previous key too
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
  // Follow "Event Handling Rules" below to process the event
  await handleBookingEvent({ eventId, eventType, timestamp, payload })
  res.sendStatus(200)
})
```

***

## Responses and Retries

* After you process an event, respond with `2xx` **within 10 seconds**. The response body isn't used. Hand long processing off to run asynchronously, and respond first.
* For a temporary failure, respond with `5xx`. `3xx` and `4xx` responses aren't retried.

| Item | Value |
| - | - |
| Response timeout | 10 seconds |
| Success | A `2xx` response |
| Attempts | Up to 5 (the first attempt and 4 retries) |
| Retry interval | Grows to about 10, 20, 40, and 80 seconds |
| Retried on | `5xx` responses, network errors, timeouts, and temporary DNS errors |
| Not retried on | `3xx` responses (redirects aren't followed), `4xx` responses, URLs that resolve to a private IP, and domains that don't exist |
| When all attempts fail | The event isn't sent again. To catch up on missed events, sync with [Booked Campaigns](#booked-campaigns). |

<Warning>
  If you return `401` for a signature error, or a `4xx` such as `404` or `429` for a moment during a deployment, that event doesn't come again. Respond with `5xx` for temporary failures.
</Warning>

### Event Handling Rules

1. **Filter duplicates by `eventId`.** The same event can arrive up to 5 times. If an `eventId` you already processed arrives, don't process it again, and respond with `2xx`. Record the `eventId` only after processing succeeds, so that an event you answered with `5xx` after a failed save is processed again when it's resent.
2. **Don't rely on arrival order.** Resends and simultaneous changes can make events arrive out of order, and the size of `eventId` doesn't tell the order either. Keep the last `timestamp` you applied for each campaign, and ignore only events with a **smaller** `timestamp`. Apply events with the same `timestamp`.
3. **Overwrite with `payload`.** `payload` is always the full campaign information, so overwriting by `campaignId` brings you to the latest state even if you miss events or they arrive out of order. If you overwrite with a `payload` that lacks optional fields, the names you saved before are removed too.
4. **`eventType` is the change, and `status` is the state.** They don't always pair up. For example, `booking.cancelled` for a campaign whose approval was withdrawn before its end has `status` `심사중`, and `booking.finished` for a campaign that ended while paused has `종료됨`.
5. **`budget` isn't a settlement amount.** For settlement and revenue, use the [Report API](/integrations/reports).

### Choosing Events

If you choose events with `events` and pick only `booking.confirmed` and `booking.cancelled`, you miss changes after approval (`booking.updated`) to the schedule, targeting, ad units, budget, and advertiser information. To reflect changes, also receive `booking.updated`, or leave `events` empty to receive all events.

## Key Rotation and Revocation

Events are signed with **the API key that last registered the webhook**. To change keys, follow these steps.

<Steps>
  <Step title="Issue a new API key">
    Issue a new API key with the `campaign:read` and `campaign:subscribe` scopes.
  </Step>

  <Step title="Deploy verification with both keys">
    Deploy your receiving server so that it verifies signatures with both the new and the previous key. Tell them apart by `x-adrop-key-id`.
  </Step>

  <Step title="Register again with the new key">
    Call `PUT /webhook` with the new key. Events sent after that are signed with the new key.
  </Step>

  <Step title="Allow a transition period">
    Events that were already being sent or waiting for a retry when you registered can arrive signed with the previous key. Wait at least 10 minutes.
  </Step>

  <Step title="Revoke the previous key">
    Revoke the previous key in the console, and remove its verification from your receiving server.
  </Step>
</Steps>

<Warning>
  If you revoke the API key used to register the webhook, the webhook is deactivated (`active: false`) and events stop. When you change keys, always revoke the previous key **after** you register again with the new key.
</Warning>

### Security Recommendations

* Keep API keys only in a secure place such as a secrets manager, and don't share them in config files, repositories, or messengers.
* If a key may have been exposed, revoke it in the console right away. Revocation takes effect immediately, and the webhook registered with that key is deactivated too.
* Don't process requests that fail signature verification. Mask the `x-adrop-signature` value in access logs.

***

## Booked Campaigns

Look up booked campaigns. Use it to sync the current state after you first register the webhook, or to catch up on missed events. It needs the `campaign:read` scope.

The results are approved campaigns whose end time hasn't passed, either running or not started yet. Each item has the same [campaign information](#campaign-information) structure as the event `payload`.

### List Booked Campaigns

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

| Query parameter | Type | Required | Description |
| - | - | - | - |
| `unit` | string | No | Return only campaigns that include this ad unit. Up to 64 characters |
| `next` | string | No | The `next` value from the previous response, to get the next page |
| `limit` | number | No | Items per page. 1–120, default 20 |

* Results are sorted by end time (`endTime`), latest first, then by campaign ID in descending order.
* If the response has `next`, there's another page. Send the `next` value as is in the next request. The last page has no `next`.

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

```json Example response theme={null}
{
  "items": [
    {
      "campaignId": "01K6Y8X9Z0A1B2C3D4E5F6G7H8",
      "title": "Fall New Arrivals Promotion",
      "status": "예약됨",
      "product": "01HD38N9K3EA5HB322NJMTU1NN",
      "productTitle": "Main Banner CPM",
      "pricingType": "CPM",
      "budget": 5000000,
      "advertiser": { "name": "Adrop Demo Brand", "url": "https://brand.example.com" },
      "startTime": 1791126000000,
      "endTime": 1792421999000,
      "units": [
        {
          "app": "01HD38P0M4FB6JC433PKFNV2PP",
          "unit": "01HD38Q1R5GC7KD544QLGW3QQ",
          "title": "Main Banner",
          "appTitle": "Adrop Demo App"
        }
      ],
      "targetings": []
    }
  ],
  "next": "01K6Y8X9Z0A1B2C3D4E5F6G7H8"
}
```

This example reads all booked campaigns from start to finish and saves them.

```javascript Full sync example 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)
}
```

Campaigns that aren't in the results have been canceled or have ended. To know whether and when a campaign was canceled, use the `booking.cancelled` event, not the lookup API.

### Get a Booked Campaign

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

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

* The response has the same structure as one item of `items` in the list.
* If `campaignId` isn't a ULID, it returns `400`.
* Canceled or finished campaigns, campaigns that aren't approved, campaigns of other projects, and campaigns that don't exist all return `404 CAMPAIGN_NOT_FOUND`. To know whether a campaign was canceled, use the `booking.cancelled` event.

***

## Errors

| HTTP | Code | Cause | What to do |
| - | - | - | - |
| `400` | (validation error) | `url` isn't `https` or is longer than 2,048 characters, an unknown event type in `events`, an undefined field, `limit` out of range, or a `campaignId` that isn't a ULID | Fix the request. |
| `400` | `WEBHOOK_CONFIG_URL_NOT_ALLOWED` | A URL that can't be registered: a private or internal address, a URL with user information, or a domain that resolves to a private IP or can't be resolved | Use a public `https` URL that's reachable from the internet. |
| `400` | `WEBHOOK_CONFIG_EVENT_TYPE_MISMATCH` | `events` has an event type that the campaign booking webhook doesn't send | Limit `events` to the 7 values in [Event Types](#event-types). |
| `401` | - | The API key is missing, invalid, or revoked | Check the API key. |
| `403` | - | The key doesn't have the required scope (`campaign:subscribe` for `/webhook`, `campaign:read` for `/campaigns`) | Use a key with the scope. |
| `404` | `WEBHOOK_NOT_FOUND` | `GET /webhook`: nothing is registered | Register with `PUT /webhook`. |
| `404` | `AUTH_KEY_NOT_FOUND` | The API key of the request was revoked or doesn't belong to this project | Send the request again with a valid key. |
| `404` | `CAMPAIGN_NOT_FOUND` | `GET /campaigns/{campaignId}`: a campaign that can't be looked up | Check the state with events or the list. |
| `502` | - | A temporary server error | Wait a moment, then send the same request again. Requests of this API are safe to repeat. |

The error code is in the `errorCode` or `message` field of the response body.

***

## Integration Checklist

* [ ] You issued an API key with the `campaign:read` and `campaign:subscribe` scopes and keep it secure.
* [ ] You registered an `https` URL with `PUT /webhook` and confirmed `active: true` with `GET /webhook`.
* [ ] You tested signature verification with valid, forged, and expired requests, and you verify against the raw body.
* [ ] You filter duplicates by `eventId`, and ignore only events with a smaller `timestamp` than the one you keep.
* [ ] You respond with `2xx` within 10 seconds, and with `5xx` for temporary failures.
* [ ] You don't treat missing optional fields as errors, and you treat a `contextCount` larger than the number of `contexts` as a partial list.
* [ ] You synced the currently booked campaigns once with `GET /campaigns`.
* [ ] You have a procedure to verify with both keys during key rotation.

***

## Related Documents

<CardGroup cols={2}>
  <Card title="Integrations & API Keys" icon="key" href="/integrations/overview">
    Issue API keys and set their scopes
  </Card>

  <Card title="Context Targeting API" icon="code" href="/integrations/context-targeting/overview">
    Manage targeting options and Context IDs from your server
  </Card>
</CardGroup>


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