# `/v1/webhooks`

Register and manage webhook subscriptions programmatically — the same
real-time callbacks you can set up by hand on the
[Webhooks tab](/360/api?tab=webhooks) of Admin Center → API, but driven from
your own code so an installed integration can wire up its own delivery
endpoint without an admin clicking through the dashboard.

For the delivery contract itself — the request envelope, the
`X-42min-Webhook-Signature` header, **how to verify it**, the retry schedule,
and auto-pause behavior — see
[API & webhooks](/help/team-and-admin/api-and-webhooks#webhooks). This page is
the **management** surface; that page is the **receiving** side.

---

## The per-credential sandbox

This is the one rule that makes `/v1/webhooks` behave differently from the
rest of the API:

> A credential only ever sees and mutates the webhooks **it created**.

Concretely:

- Webhooks created through this API by a **PAT** are owned by that token.
  Webhooks created by an **OAuth** client are owned by that client (all of
  the client's access tokens share one sandbox — the binding is the stable
  `client_id`, so a token refresh doesn't orphan anything; a *PAT*, by
  contrast, is bound to that specific token, so rotating the PAT leaves its
  webhooks unreachable from the new token).
- Webhooks created in the **admin UI** are invisible to `/v1/webhooks`, and
  webhooks created over the API don't appear in another credential's listing.
  The **admin UI still sees every webhook in the account** — the sandbox is
  one-directional.
- Two different integrations that legitimately point at the same ingestion
  URL are two real subscribers and both receive every matching event — the
  duplicate-URL check is scoped to your own sandbox, not the whole account.

Because of the sandbox, `GET /v1/webhooks` returning `[]` does **not** mean
the account has no webhooks — only that *this* credential created none.

## Conventions specific to this resource

- **Scopes** — `webhooks:read` for the `GET`s, `webhooks:write` for every
  mutation (create, update, delete, rotate-secret, test). See
  [Scopes](/help/api/scopes).
- **No `Idempotency-Key`, no `If-Match`.** Unlike `/v1/bookings`, the webhook
  endpoints don't take an idempotency key or an ETag — they aren't
  optimistically locked. Last write wins.
- **The signing secret is shown exactly once.** It is server-generated (you
  can't supply your own) and appears as `signing_secret` **only** in the
  `POST` (create) and `rotate-secret` responses. It is never returned by any
  `GET`. Lost it? Rotate.
- **Account ceiling: 42 webhooks.** The cap is account-wide across every
  credential *and* the admin UI combined — not 42 per credential.

### The webhook object

```json
{
  "id": "01H…",
  "url": "https://hooks.example.com/42min",
  "events": ["booking_created", "booking_updated"],
  "status": "active",
  "description": "Prod CRM sync",
  "paused_reason": null,
  "last_delivery_at": "2026-05-18T14:03:00.000Z",
  "last_delivery_ok": true,
  "created_at": "2026-05-10T09:00:00.000Z",
  "updated_at": "2026-05-18T14:03:00.000Z",
  "signing_secret": "42min_wh_…"
}
```

- `status` is `active` or `paused`. `paused_reason` is set when 42min
  auto-paused the hook (after 5 consecutive failed deliveries) and is `null`
  otherwise.
- `last_delivery_ok` is `true` / `false` / `null` (never delivered yet).
- `signing_secret` is present **only** on create and rotate-secret responses.

---

## GET `/v1/webhooks`

List the webhooks this credential created.

| | |
|---|---|
| **Method** | `GET` |
| **URL** | `https://api.42min.us/v1/webhooks` |
| **Scope** | `webhooks:read` |
| **Auth** | Required |

Returns a plain array (newest first) — this endpoint is **not** paginated.

```json
{
  "data": [ { "id": "01H…", "url": "https://…", "events": ["booking_created"], "status": "active", "…": "…" } ],
  "meta": { "request_id": "req_…" }
}
```

### curl

```bash
curl -H "Authorization: Bearer $TOKEN" \
  https://api.42min.us/v1/webhooks
```

---

## GET `/v1/webhooks/:id`

Return one webhook this credential created.

| | |
|---|---|
| **Method** | `GET` |
| **URL** | `https://api.42min.us/v1/webhooks/{id}` |
| **Scope** | `webhooks:read` |
| **Auth** | Required |

### Common errors

- `404 webhook.notFound` — no such webhook **in this credential's sandbox**
  (it may exist for another credential or in the admin UI — you just can't
  see it).

---

## POST `/v1/webhooks`

Create a webhook subscription.

| | |
|---|---|
| **Method** | `POST` |
| **URL** | `https://api.42min.us/v1/webhooks` |
| **Scope** | `webhooks:write` |
| **Auth** | Required |

### Body

```json
{
  "url": "https://hooks.example.com/42min",
  "events": ["booking_created", "booking_updated", "event_type_updated"],
  "description": "Prod CRM sync"
}
```

| Field | Type | Notes |
|---|---|---|
| `url` | string | Required. Must be `https://` and resolve to a public address — `http://`, `localhost`, and private/loopback IP ranges are rejected. Max 2000 chars. Normalized (fragment and trailing slash stripped) before storage. |
| `events` | string[] | Required, at least one. See [Event names](#event-names) — use the **underscore** form here. |
| `description` | string | Optional. Max 255 chars. |

### Response

`201 Created` with the webhook object **including `signing_secret`** — this is
the only `GET`-style payload that ever carries the secret on create. Store it
now; you'll need it to verify deliveries and it is never shown again.

### curl

```bash
curl -X POST https://api.42min.us/v1/webhooks \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.example.com/42min",
    "events": ["booking_created", "booking_updated"],
    "description": "Prod CRM sync"
  }'
```

### Common errors

- `400` — `url` isn't a public HTTPS endpoint, or `events` is empty / contains
  an unknown event name.
- `409 webhook.duplicateUrl` — *this credential* already has a webhook on that
  URL.
- `409` — the account is at the 42-webhook ceiling.

---

## PATCH `/v1/webhooks/:id`

Update a webhook. All fields optional; only those present are changed.

| | |
|---|---|
| **Method** | `PATCH` |
| **URL** | `https://api.42min.us/v1/webhooks/{id}` |
| **Scope** | `webhooks:write` |
| **Auth** | Required |

### Body

```json
{
  "url": "https://hooks.example.com/42min/v2",
  "events": ["booking_created", "booking_canceled"],
  "description": "Prod CRM sync (v2)",
  "status": "active"
}
```

| Field | Type | Notes |
|---|---|---|
| `url` | string | Same rules as create. Changing it re-runs the HTTPS/public check and the per-sandbox duplicate check. |
| `events` | string[] | Replaces the event list wholesale (at least one). |
| `description` | string | Max 255. |
| `status` | string | `active` or `paused`. Setting it to `active` **resets the consecutive-failure counter and clears `paused_reason`** — this is how you re-enable an auto-paused hook. |

### Response

`200 OK` with the updated webhook object (no `signing_secret`).

### curl

```bash
# Resume an auto-paused webhook
curl -X PATCH https://api.42min.us/v1/webhooks/01H… \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status":"active"}'
```

### Common errors

- `404 webhook.notFound` — not in this credential's sandbox.
- `400` / `409` — same URL validation and duplicate-URL rules as create.

---

## DELETE `/v1/webhooks/:id`

Permanently delete a webhook and its delivery history.

| | |
|---|---|
| **Method** | `DELETE` |
| **URL** | `https://api.42min.us/v1/webhooks/{id}` |
| **Scope** | `webhooks:write` |
| **Auth** | Required |

### Response

`204 No Content` — empty body.

### Common errors

- `404 webhook.notFound` — not in this credential's sandbox.

---

## POST `/v1/webhooks/:id/rotate-secret`

Issue a new signing secret. The old secret stops being valid **immediately** —
update your verifier before you call this, or accept a brief window of
signature failures.

| | |
|---|---|
| **Method** | `POST` |
| **URL** | `https://api.42min.us/v1/webhooks/{id}/rotate-secret` |
| **Scope** | `webhooks:write` |
| **Auth** | Required |

### Response

`200 OK` with the webhook object **including the new `signing_secret`** — the
second and last place the secret is ever returned.

### curl

```bash
curl -X POST https://api.42min.us/v1/webhooks/01H…/rotate-secret \
  -H "Authorization: Bearer $TOKEN"
```

---

## POST `/v1/webhooks/:id/test`

Enqueue a synthetic `booking.created` delivery so you can confirm your
endpoint is reachable and your signature check works. The payload's
`data.booking` is obviously fake (`"test": true`, `id: "test-booking-id"`).

| | |
|---|---|
| **Method** | `POST` |
| **URL** | `https://api.42min.us/v1/webhooks/{id}/test` |
| **Scope** | `webhooks:write` |
| **Auth** | Required |

### Response

`200 OK`:

```json
{ "data": { "ok": true, "delivery_id": "01H…" }, "meta": { "request_id": "req_…" } }
```

The delivery is queued, not synchronous — poll
[`/deliveries`](#get-v1webhooksiddeliveries) with the returned
`delivery_id` to see the outcome.

---

## GET `/v1/webhooks/:id/deliveries`

The **50 most recent** delivery attempts for this webhook, newest first.
Older attempts are pruned after 30 days.

| | |
|---|---|
| **Method** | `GET` |
| **URL** | `https://api.42min.us/v1/webhooks/{id}/deliveries` |
| **Scope** | `webhooks:read` |
| **Auth** | Required |

### Response

```json
{
  "data": [
    {
      "id": "01H…",
      "event": "booking_created",
      "attempt": 1,
      "status_code": 200,
      "error": null,
      "delivered_at": "2026-05-18T14:03:01.000Z",
      "created_at": "2026-05-18T14:03:00.000Z"
    }
  ],
  "meta": { "request_id": "req_…" }
}
```

- `status_code` is the HTTP status your endpoint returned (`null` if the
  request never completed — DNS failure, timeout, connection refused).
- `error` carries the failure reason when there is one; `null` on success.
- `delivered_at` is set only once a 2xx is received; it stays `null` for
  attempts that failed or are still pending.

---

## Event names

The `events` array you **send** to create/update uses the enum
(underscore) form. The `event` field in the **delivered** envelope and the
`X-42min-Webhook-Event` header use the dot form. They map one-to-one:

| Send in `events` | Delivered as | Fires when |
|---|---|---|
| `booking_created` | `booking.created` | A meeting is booked. |
| `booking_rescheduled` | `booking.rescheduled` | A booking moves to a new time. |
| `booking_canceled` | `booking.canceled` | A booking is canceled. |
| `booking_no_show` | `booking.no_show` | A booking is marked no-show. |
| `booking_updated` | `booking.updated` | `PATCH /v1/bookings/:uid` changed `metadata`, `responses`, or `attendee_name`. The payload adds `changed_fields[]` naming exactly which of those changed. This is the only path that mutates those fields silently — no `booking.rescheduled`/`booking.canceled` covers it. See [`/v1/bookings`](/help/api/bookings#patch-v1bookingsuid). |
| `event_type_updated` | `event_type.updated` | An event type is created-via-edit, edited, or toggled on/off from the [Event Types](/360/events) dashboard. Payload `data.event_type` is the **same shape as** [`GET /v1/event-types/:idOrSlug`](/help/api/event-types) (locations, `questions[]`, limits — all of it). |
| `event_type_deleted` | `event_type.deleted` | An event type is deleted. Payload carries only `{ id, slug, deleted_at }` — the row is gone. |

`event_type.updated` / `event_type.deleted` fire from **dashboard** changes —
there are no public write endpoints for event types yet, but the webhooks let
an integration keep its mirror of your event-type catalog in sync.

> **Catch-up after a pause.** If a webhook was auto-paused and you missed
> `booking.*` events, reconcile with
> [`GET /v1/bookings?updated_since=…&sort=updated_at_asc`](/help/api/bookings#get-v1bookings)
> rather than guessing — it returns every booking touched since a timestamp,
> in stable ascending order, ready to page through.

---

## Where this sits

- Receiving, verifying, retries, auto-pause, the activity log →
  [API & webhooks](/help/team-and-admin/api-and-webhooks#webhooks).
- Minting the PAT or OAuth client that calls this API →
  [Authentication](/help/api/authentication).
- The exact scope each endpoint needs → [Scopes](/help/api/scopes).
