# Reference

Base URL: `https://api.xonaplay.com`. The endpoint list below is generated
from our OpenAPI file, [`/openapi.json`](/openapi.json).

## Authentication

| Credential | How                             | Where                                              |
| ---------- | ------------------------------- | -------------------------------------------------- |
| Secret key | `Authorization: Bearer xp_sk_…` | Every endpoint on this page                        |
| None       | —                               | `/playback` of public content on Open or Protected |

A key acts on its whole workspace. Create, list and revoke keys in the
dashboard under **Settings → API**. A revoked key answers `401 INVALID_KEY`.

## Conventions

- JSON in and out, always **snake_case**.
- Success: `{ "status": "ok", "data": … }`. Lists add
  `"meta": { "total": 120, "page": 1, "per_page": 20, "last_page": 6 }`.
- Error: `{ "status": "error", "code": "NOT_FOUND", "message": "…" }`. Codes
  are uppercase and stable; messages are for humans and may change.
- Validation errors are `422` with `errors: [{ "field": "title", "message": "…" }]`.
- Ids: every `{id}` accepts the UUID `id` or the `short_id`.
- Timestamps: ISO 8601 in UTC.
- Pagination: `?page=1&per_page=20` (max 100).

## Video and live objects {#video-object}

`GET /v1/videos/{id}` and `GET /v1/live/{id}` return the same object our own
dashboard uses — nothing is stripped for the public API.

- `links.player` is the public share page (the one you embed in an iframe).
  It is not a video file.
- `links.hls` only plays on Open; on every other level it is protected and
  answers `403 TOKEN_REQUIRED` without a token. To play in your own player,
  always call [`/playback`](/playback) instead.
- `custody`, `engine`, `compute_engine` and `storage_profile` describe where
  XonaPlay stores and renders the video (our storage or yours, which engine
  did the work). Safe to ignore for most integrations.
- `playback_security` is the per-video override of the workspace default:
  `inherit`, `open`, `protected`, `strict` or `maximum`.
- `delivery` is `hls` (default) or `original`: whether XonaPlay renders HLS from
  the untouched original. See [Original or HLS](/playback#original-or-hls).

## Rate limits

| Caller                    | Per minute   |
| ------------------------- | ------------ |
| Secret key                | 600 requests |
| Session cookie            | 300 requests |
| `/playback` without a key | 60 per IP    |

Over the limit you get `429 RATE_LIMITED` with a `Retry-After` header (in
seconds) telling you how long to wait before the next request. The 600/min
key bucket only applies to a _valid_ secret key; an invalid or revoked one
falls back to the lower anonymous-IP limit.

## Webhooks {#webhooks}

Register an HTTPS endpoint in **Settings → API → Webhooks** (or with
`POST /v1/webhooks`). We POST:

```json
{
  "id": "evt_5c1f0e7a9b2d4c3e",
  "type": "video.ready",
  "created_at": "2026-10-09T01:30:00.000Z",
  "data": {
    "video": {
      "id": "0f8e2c7a-…",
      "short_id": "9KfR2mQx",
      "title": "Lesson 1",
      "status": "ready",
      "duration_sec": 3600
    }
  }
}
```

| Event          | When                                       |
| -------------- | ------------------------------------------ |
| `video.ready`  | Encoding finished; the video can be played |
| `video.failed` | Encoding failed for good                   |
| `live.started` | A live stream went on air                  |
| `live.ended`   | A live stream ended                        |

`live.started` and `live.ended` carry a different key: `data.live` (a live
stream object — `id`, `short_id`, `name`, `status`, …), not `data.video`:

```json
{
  "id": "evt_8a2d4c3e9b2d4c3e",
  "type": "live.started",
  "created_at": "2026-10-09T01:32:00.000Z",
  "data": {
    "live": {
      "id": "3b7f0c1a-…",
      "short_id": "7qLp3Zxa",
      "name": "Weekly class",
      "status": "live"
    }
  }
}
```

- Signed with `X-Xona-Signature: t=<unix seconds>,v1=<hex>` where `v1` is
  HMAC-SHA256 of `"<t>.<raw body>"` with your endpoint secret (`whsec_…`,
  shown once). See the [verification recipe](/recipes#verify-a-webhook).
- Answer any 2xx within 10 seconds. Anything else is retried after 1 min,
  5 min, 30 min, 2 h and 6 h; then the delivery is marked failed.
- Deliveries can arrive more than once or out of order: use `id` to dedupe and
  re-read the video if order matters.

## Videos

List, read, update and delete videos, and get a signed playback link.

### `GET /v1/videos` {#get-v1-videos}

List videos

Videos of the authenticated workspace, merged from every engine (platform custody and BYO), newest first. Paginated.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `page` | query | string | No |  |
| `per_page` | query | string | No |  |
| `q` | query | string | No |  |
| `status` | query | string | No |  |

**Responses**

- `200` — Paginated list of videos.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).

```json
{
  "status": "",
  "data": [
    {
      "id": "",
      "short_id": "",
      "title": "",
      "slug": "",
      "description": "",
      "status": "",
      "visibility": "",
      "playback_security": "",
      "delivery": "",
      "source_type": "",
      "duration_sec": "",
      "width": "",
      "height": "",
      "size_original_bytes": "",
      "size_processed_bytes": "",
      "qualities": [
        ""
      ],
      "custody": "",
      "engine": "",
      "compute_engine": "",
      "storage_profile": "",
      "links": {
        "player": "",
        "hls": "",
        "poster": ""
      },
      "metadata": "",
      "created_at": "",
      "updated_at": ""
    }
  ],
  "meta": {
    "total": "",
    "page": "",
    "per_page": "",
    "last_page": ""
  }
}
```

### `GET /v1/videos/{video_id}` {#get-v1-videos-video-id-}

Get a video

Accepts either the `id` (UUID) or the `short_id`.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `video_id` | path | string | Yes |  |

**Responses**

- `200` — The video.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `404` — No video with that id or short_id (`NOT_FOUND`).

```json
{
  "status": "",
  "data": {
    "id": "",
    "short_id": "",
    "title": "",
    "slug": "",
    "description": "",
    "status": "",
    "visibility": "",
    "playback_security": "",
    "delivery": "",
    "source_type": "",
    "duration_sec": "",
    "width": "",
    "height": "",
    "size_original_bytes": "",
    "size_processed_bytes": "",
    "qualities": [
      ""
    ],
    "custody": "",
    "engine": "",
    "compute_engine": "",
    "storage_profile": "",
    "links": {
      "player": "",
      "hls": "",
      "poster": ""
    },
    "metadata": "",
    "created_at": "",
    "updated_at": ""
  }
}
```

### `PATCH /v1/videos/{video_id}` {#patch-v1-videos-video-id-}

Update a video

Partial update. `playback_security` overrides the workspace default for this video only; it is rejected on BYO-engine videos (`NOT_SUPPORTED_FOR_BYO`) and `maximum` requires the staff opt-in for the workspace (`MAXIMUM_NOT_ALLOWED`).

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `video_id` | path | string | Yes |  |

**Request body**

- `title`: string
- `slug`: string
- `visibility`: object
- `playback_security`: object
- `delivery`: string
- `metadata`: object

**Responses**

- `200` — The updated video.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `404` — No video with that id or short_id (`NOT_FOUND`).
- `422` — Validation failed, `playback_security=maximum` without the workspace opt-in (`MAXIMUM_NOT_ALLOWED`), or an override on a BYO video (`NOT_SUPPORTED_FOR_BYO`).

```json
{
  "status": "",
  "data": {
    "id": "",
    "short_id": "",
    "title": "",
    "slug": "",
    "description": "",
    "status": "",
    "visibility": "",
    "playback_security": "",
    "delivery": "",
    "source_type": "",
    "duration_sec": "",
    "width": "",
    "height": "",
    "size_original_bytes": "",
    "size_processed_bytes": "",
    "qualities": [
      ""
    ],
    "custody": "",
    "engine": "",
    "compute_engine": "",
    "storage_profile": "",
    "links": {
      "player": "",
      "hls": "",
      "poster": ""
    },
    "metadata": "",
    "created_at": "",
    "updated_at": ""
  }
}
```

### `DELETE /v1/videos/{video_id}` {#delete-v1-videos-video-id-}

Delete a video

`only_original=true` keeps the processed renditions and deletes only the source file.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `video_id` | path | string | Yes |  |
| `only_original` | query | string | No |  |

**Responses**

- `200` — Deleted.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `404` — No video with that id or short_id (`NOT_FOUND`).

```json
{
  "status": "",
  "data": {
    "deleted": true,
    "only_original": true
  }
}
```

### `GET /v1/videos/{video_id}/download` {#get-v1-videos-video-id-download}

Download the original file

Short-lived signed link (1 hour) to the original exactly as it was uploaded or imported — never transcoded. Requires a secret key or a session.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `video_id` | path | string | Yes |  |

**Responses**

- `200` — Download link.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `404` — No video with that id or short_id (`NOT_FOUND`), or its original was deleted (`ORIGINAL_NOT_AVAILABLE`).
- `409` — The original is still uploading (`VIDEO_NOT_READY`).
- `422` — The video lives on your own server (`NOT_SUPPORTED_FOR_BYO`).

```json
{
  "status": "",
  "data": {
    "url": "",
    "expires_at": "",
    "file_name": "",
    "size_bytes": ""
  }
}
```

### `GET /v1/videos/{video_id}/tracks` {#get-v1-videos-video-id-tracks}

List audio and subtitle tracks

Tracks that came with the original file (`origin: source`) plus the ones added through the API (`origin: manual`). `supported` is false when tracks cannot be added to this video, with the reason in `unsupported_reason`.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `video_id` | path | string | Yes |  |

**Responses**

- `200` — Tracks.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `404` — No video with that id or short_id (`NOT_FOUND`).

```json
{
  "status": "",
  "data": {
    "supported": true,
    "unsupported_reason": "",
    "tracks": [
      {
        "id": "",
        "kind": "",
        "origin": "",
        "lang": "",
        "label": "",
        "status": "",
        "error": "",
        "created_at": ""
      }
    ]
  }
}
```

### `POST /v1/videos/{video_id}/tracks/audio` {#post-v1-videos-video-id-tracks-audio}

Add an audio track

Step 1 of 2. Creates the track (`uploading`) and returns a pre-signed `PUT` for the audio file (mp3, m4a, aac, wav, flac, ogg or opus; up to 1 GiB). Upload the file, then call `POST /videos/{video_id}/tracks/{track_id}/complete`.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `video_id` | path | string | Yes |  |

**Request body**

- `lang`: string (required)
- `label`: string (required)
- `file_name`: string (required)
- `size_bytes`: object (required)
- `content_type`: string (required)

**Responses**

- `200` — Track and upload URL.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `404` — No video with that id or short_id (`NOT_FOUND`).
- `409` — The video does not accept tracks yet (`TRACKS_NOT_SUPPORTED`: not HLS, still processing, or on your own server), or the per-video limit was reached (`TRACK_LIMIT_REACHED`).
- `422` — Invalid `lang`/`label`, unsupported file type or size (`UNSUPPORTED_AUDIO_FORMAT`, `AUDIO_TOO_LARGE`).

```json
{
  "status": "",
  "data": {
    "track": {
      "id": "",
      "kind": "",
      "origin": "",
      "lang": "",
      "label": "",
      "status": "",
      "error": "",
      "created_at": ""
    },
    "upload": {
      "url": "",
      "method": "",
      "headers": "",
      "expires_at": ""
    }
  }
}
```

### `POST /v1/videos/{video_id}/tracks/subtitles` {#post-v1-videos-video-id-tracks-subtitles}

Add a subtitle track

Send the text of an `.srt` or `.vtt` file (max 2 MB) in `content`. SRT is converted to WebVTT. The track is ready immediately.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `video_id` | path | string | Yes |  |

**Request body**

- `lang`: string (required)
- `label`: string (required)
- `content`: string (required)

**Responses**

- `200` — The new track.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `404` — No video with that id or short_id (`NOT_FOUND`).
- `409` — The video does not accept tracks yet (`TRACKS_NOT_SUPPORTED`: not HLS, still processing, or on your own server), or the per-video limit was reached (`TRACK_LIMIT_REACHED`).
- `413` — The subtitle file is larger than 2 MB (`SUBTITLE_TOO_LARGE`).
- `422` — Invalid `lang`, `label` or subtitle content (`INVALID_LANG`, `INVALID_SUBTITLE`).

```json
{
  "status": "",
  "data": {
    "track": {
      "id": "",
      "kind": "",
      "origin": "",
      "lang": "",
      "label": "",
      "status": "",
      "error": "",
      "created_at": ""
    }
  }
}
```

### `DELETE /v1/videos/{video_id}/tracks/{track_id}` {#delete-v1-videos-video-id-tracks-track-id-}

Delete a track you added

Only `origin: manual` tracks can be deleted.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `video_id` | path | string | Yes |  |
| `track_id` | path | string | Yes |  |

**Responses**

- `200` — Deleted.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `404` — No video or track with that id (`NOT_FOUND`).
- `409` — The video does not accept tracks yet (`TRACKS_NOT_SUPPORTED`: not HLS, still processing, or on your own server), or the per-video limit was reached (`TRACK_LIMIT_REACHED`).

```json
{
  "status": "",
  "data": {
    "deleted": true
  }
}
```

### `POST /v1/videos/{video_id}/tracks/{track_id}/complete` {#post-v1-videos-video-id-tracks-track-id-complete}

Complete an audio track upload

Step 2 of 2. Verifies the uploaded file and starts processing (`processing` → `ready` or `failed`). Safe to call twice.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `video_id` | path | string | Yes |  |
| `track_id` | path | string | Yes |  |

**Responses**

- `200` — The track.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `404` — No video or track with that id (`NOT_FOUND`).
- `409` — The file is missing or its size does not match (`UPLOAD_MISSING`, `UPLOAD_SIZE_MISMATCH`), or the video does not accept tracks.
- `503` — No processing server is available right now (`NO_ENGINE_AVAILABLE`).

```json
{
  "status": "",
  "data": {
    "track": {
      "id": "",
      "kind": "",
      "origin": "",
      "lang": "",
      "label": "",
      "status": "",
      "error": "",
      "created_at": ""
    }
  }
}
```

## Uploads

Open a direct upload session, PUT the parts to storage, then confirm.

### `POST /v1/videos/imports` {#post-v1-videos-imports}

Import a video from a URL

XonaPlay downloads the file from `source_url` server-to-server (redirects allowed, public HTTPS only) and stores it untouched as the original — your server never handles the bytes. With `delivery: "original"` the video is ready as soon as the download finishes; with `"hls"` (default) rendering starts then. You get `video.ready` or `video.failed`.

**Request body**

- `title`: string (required)
- `source_url`: string (required)
- `file_name`: string
- `delivery`: object
- `storage_profile_id`: string
- `compute_engine_id`: string

**Responses**

- `200` — Import queued.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `402` — Plan limit reached (`PLAN_LIMIT`).
- `422` — Invalid or not allowed `source_url` (`VALIDATION_FAILED`).

```json
{
  "status": "",
  "data": {
    "video_id": "",
    "import_id": "",
    "status": ""
  }
}
```

### `POST /v1/videos/uploads` {#post-v1-videos-uploads}

Create an upload session

Starts a multipart direct upload straight to storage: creates the video (status `uploading`) and returns one pre-signed PUT URL per part.

**Request body**

- `title`: string (required)
- `file_name`: string (required)
- `size_bytes`: number (required)
- `content_type`: string
- `storage_profile_id`: string
- `compute_engine_id`: string
- `delivery`: object

**Responses**

- `200` — Upload session created.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `422` — Validation failed (e.g. missing `size_bytes`).

```json
{
  "status": "",
  "data": {
    "video_id": "",
    "session_id": "",
    "part_size": "",
    "urls": [
      ""
    ],
    "max_height": ""
  }
}
```

### `DELETE /v1/videos/uploads/{session_id}` {#delete-v1-videos-uploads-session-id-}

Abort an upload session

Cancels the multipart upload and deletes the pending video.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `session_id` | path | string | Yes |  |

**Responses**

- `200` — Aborted.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `404` — No upload session with that id (`NOT_FOUND`).

```json
{
  "status": "",
  "data": {
    "aborted": true
  }
}
```

### `POST /v1/videos/uploads/{session_id}/complete` {#post-v1-videos-uploads-session-id-complete}

Complete an upload session

Closes the multipart upload (completes it in storage), verifies the total size against what was declared and the plan limits, and queues the video for processing.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `session_id` | path | string | Yes |  |

**Request body**

- `parts`: array<object>
  - `part_number`: number (required)
  - `etag`: string (required)

**Responses**

- `200` — The upload was completed and the video queued.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `404` — No upload session with that id (`NOT_FOUND`).
- `409` — The session is already closed, or the uploaded size exceeds what was declared or the plan storage limit.

```json
{
  "status": "",
  "data": {
    "video_id": "",
    "status": ""
  }
}
```

## Live

List live streams and get a signed playback link for one that is on air.

### `GET /v1/live` {#get-v1-live}

List live streams

Live streams of the authenticated workspace, merged from every engine (platform custody and BYO), newest first. Paginated.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `page` | query | string | No |  |
| `per_page` | query | string | No |  |
| `q` | query | string | No |  |

**Responses**

- `200` — Paginated list of live streams.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).

```json
{
  "status": "",
  "data": [
    {
      "id": "",
      "short_id": "",
      "name": "",
      "slug": "",
      "status": "",
      "visibility": "",
      "playback_security": "",
      "stream_key": "",
      "ingest": {
        "primary": "",
        "backup": ""
      },
      "dvr_enabled": true,
      "dvr_window_minutes": "",
      "replay_enabled": true,
      "replay_unlisted": true,
      "viewers": "",
      "total_views": "",
      "custody": "",
      "engine": "",
      "storage_profile": "",
      "links": {
        "player": "",
        "hls": "",
        "poster": ""
      },
      "last_live_at": "",
      "created_at": "",
      "updated_at": ""
    }
  ],
  "meta": {
    "total": "",
    "page": "",
    "per_page": "",
    "last_page": ""
  }
}
```

### `GET /v1/live/{stream_id}` {#get-v1-live-stream-id-}

Get a live stream

Accepts either the `id` (UUID) or the `short_id`.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `stream_id` | path | string | Yes |  |

**Responses**

- `200` — The live stream.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `404` — No live stream with that id or short_id (`NOT_FOUND`).

```json
{
  "status": "",
  "data": {
    "id": "",
    "short_id": "",
    "name": "",
    "slug": "",
    "status": "",
    "visibility": "",
    "playback_security": "",
    "stream_key": "",
    "ingest": {
      "primary": "",
      "backup": ""
    },
    "dvr_enabled": true,
    "dvr_window_minutes": "",
    "replay_enabled": true,
    "replay_unlisted": true,
    "viewers": "",
    "total_views": "",
    "custody": "",
    "engine": "",
    "storage_profile": "",
    "links": {
      "player": "",
      "hls": "",
      "poster": ""
    },
    "last_live_at": "",
    "created_at": "",
    "updated_at": ""
  }
}
```

## Playback

Signed HLS/embed links for a video or live stream — the only way to play content.

### `GET /v1/live/{stream_id}/playback` {#get-v1-live-stream-id-playback}

Get signed playback links for a live stream

The ONLY way to play a live stream: returns a signed `hls_url`/`embed_url` pair whose shape depends on the workspace playback security level. No credential required for public `open`/`protected` content; otherwise a secret key or session is required. On a fatal player error, call this again (see the "Reload" rule in the guide) instead of reusing the old URL.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `stream_id` | path | string | Yes |  |
| `viewer_id` | query | string | No |  |

**Responses**

- `200` — Signed playback links.
- `401` — A secret key or session is required for this content (`KEY_REQUIRED`).
- `403` — Domain not allowed (`DOMAIN_NOT_ALLOWED`), workspace unavailable (`WORKSPACE_UNAVAILABLE`), or viewer banned (`ACCESS_DENIED`).
- `404` — No live stream with that id or short_id (`NOT_FOUND`).
- `409` — The stream is not live right now (`LIVE_NOT_ACTIVE`); retry later.
- `429` — Too many requests; retry after `Retry-After` (`RATE_LIMITED`).

```json
{
  "status": "",
  "data": {
    "stream_id": "",
    "short_id": "",
    "hls_url": "",
    "poster_url": "",
    "embed_url": "",
    "expires_at": "",
    "security": "",
    "status": ""
  }
}
```

### `GET /v1/videos/{video_id}/playback` {#get-v1-videos-video-id-playback}

Get signed playback links for a video

The ONLY way to play a video: returns a signed `hls_url`/`embed_url` pair whose shape depends on the workspace/video playback security level (`open`/`protected`/`strict`/`maximum`). No credential required for public `open`/`protected` content; otherwise a secret key or session is required. On a fatal player error, call this again (see the "Reload" rule in the guide) instead of reusing the old URL.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `video_id` | path | string | Yes |  |
| `viewer_id` | query | string | No |  |

**Responses**

- `200` — Signed playback links.
- `401` — A secret key or session is required for this content (`KEY_REQUIRED`).
- `403` — Domain not allowed (`DOMAIN_NOT_ALLOWED`), workspace unavailable (`WORKSPACE_UNAVAILABLE`), or viewer banned (`ACCESS_DENIED`).
- `404` — No video with that id or short_id (`NOT_FOUND`).
- `409` — The video is still processing (`VIDEO_NOT_READY`); retry later.
- `429` — Too many requests; retry after `Retry-After` (`RATE_LIMITED`).

```json
{
  "status": "",
  "data": {
    "video_id": "",
    "short_id": "",
    "hls_url": "",
    "original_url": "",
    "poster_url": "",
    "thumbnails_url": "",
    "embed_url": "",
    "expires_at": "",
    "security": "",
    "delivery": "",
    "duration_sec": ""
  }
}
```

## Webhooks

Register endpoints for video and live events, and inspect deliveries.

### `GET /v1/webhooks` {#get-v1-webhooks}

List webhook endpoints

Webhook endpoints registered for the workspace (secrets never come back in full).

**Responses**

- `200` — Webhook endpoints.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).

```json
{
  "status": "",
  "data": [
    {
      "id": "",
      "url": "",
      "description": "",
      "secret_last4": "",
      "events": [
        ""
      ],
      "enabled": true,
      "disabled_by_staff": true,
      "last_success_at": "",
      "last_failure_at": "",
      "created_at": "",
      "updated_at": ""
    }
  ]
}
```

### `POST /v1/webhooks` {#post-v1-webhooks}

Create a webhook endpoint

Registers a new endpoint. The signing secret (`whsec_…`) is returned ONCE, in this response — store it, it cannot be retrieved again.

**Request body**

- `url`: string (required)
- `description`: string
- `events`: array<object> (required)

**Responses**

- `200` — Endpoint created, with its signing secret.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `409` — Workspace already has the maximum number of endpoints (`ENDPOINT_LIMIT_REACHED`).
- `422` — Validation failed (bad `url` or `events`).

```json
{
  "status": "",
  "data": {
    "endpoint": {
      "id": "",
      "url": "",
      "description": "",
      "secret_last4": "",
      "events": [
        ""
      ],
      "enabled": true,
      "disabled_by_staff": true,
      "last_success_at": "",
      "last_failure_at": "",
      "created_at": "",
      "updated_at": ""
    },
    "secret": ""
  }
}
```

### `PATCH /v1/webhooks/{id}` {#patch-v1-webhooks-id-}

Update a webhook endpoint

Partial update. An endpoint disabled by staff cannot be re-enabled by the workspace.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `id` | path | string | Yes |  |

**Request body**

- `url`: string
- `description`: string
- `events`: array<object>
- `enabled`: boolean

**Responses**

- `200` — The updated endpoint.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `403` — This endpoint was disabled by staff and cannot be re-enabled (`DISABLED_BY_STAFF`).
- `404` — No webhook endpoint with that id (`NOT_FOUND`).
- `422` — Validation failed (bad `url` or `events`).

```json
{
  "status": "",
  "data": {
    "id": "",
    "url": "",
    "description": "",
    "secret_last4": "",
    "events": [
      ""
    ],
    "enabled": true,
    "disabled_by_staff": true,
    "last_success_at": "",
    "last_failure_at": "",
    "created_at": "",
    "updated_at": ""
  }
}
```

### `DELETE /v1/webhooks/{id}` {#delete-v1-webhooks-id-}

Delete a webhook endpoint

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `id` | path | string | Yes |  |

**Responses**

- `200` — Deleted.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `404` — No webhook endpoint with that id (`NOT_FOUND`).

```json
{
  "status": "",
  "data": {
    "deleted": true
  }
}
```

### `GET /v1/webhooks/{id}/deliveries` {#get-v1-webhooks-id-deliveries}

List delivery attempts

Delivery history for one endpoint, newest first. Paginated.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `id` | path | string | Yes |  |
| `page` | query | string | No |  |
| `per_page` | query | string | No |  |

**Responses**

- `200` — Paginated deliveries.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `404` — No webhook endpoint with that id (`NOT_FOUND`).

```json
{
  "status": "",
  "data": [
    {
      "id": "",
      "event": "",
      "status": "",
      "attempts": "",
      "last_status_code": "",
      "last_error": "",
      "created_at": "",
      "delivered_at": ""
    }
  ],
  "meta": {
    "total": "",
    "page": "",
    "per_page": "",
    "last_page": ""
  }
}
```

### `POST /v1/webhooks/{id}/test` {#post-v1-webhooks-id-test}

Send a test event

Delivers a `webhook.test` event to the endpoint right away, so you can check your receiver.

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `id` | path | string | Yes |  |

**Responses**

- `200` — The test delivery.
- `401` — Missing or invalid credential (`KEY_REQUIRED`, `INVALID_KEY`).
- `404` — No webhook endpoint with that id (`NOT_FOUND`).

```json
{
  "status": "",
  "data": {
    "id": "",
    "event": "",
    "status": "",
    "attempts": "",
    "last_status_code": "",
    "last_error": "",
    "created_at": "",
    "delivered_at": ""
  }
}
```
