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

# Media & Webhooks

> File upload, media management, and webhook configuration endpoints

## Media API

The Media API uploads, lists, reads, and deletes project-scoped media assets.
Media files are stored in S3-compatible object storage, and each request is
routed to an explicit project and environment target.

All `/api/v1/media*` endpoints require `X-MDCMS-Project` and
`X-MDCMS-Environment` headers.

### List Media

<ParamField method="GET" path="/api/v1/media">
  List media metadata for the routed project and environment.
</ParamField>

**Scope required:** `media:read`

<ParamField query="q" type="string">
  Case-insensitive filename search. Blank strings are ignored.
</ParamField>

<ParamField query="category" type="string">
  Filter by derived category: `image`, `video`, `audio`, `document`, `archive`,
  or `other`.
</ParamField>

<ParamField query="uploadedBy" type="string">
  Filter by the uploading user ID.
</ParamField>

<ParamField query="uploadedFrom" type="string">
  Include assets uploaded on or after this `YYYY-MM-DD` date.
</ParamField>

<ParamField query="uploadedTo" type="string">
  Include assets uploaded through this `YYYY-MM-DD` date.
</ParamField>

<ParamField query="sort" type="string">
  Sort by `uploadedAt`, `filename`, or `sizeBytes`. Defaults to `uploadedAt`.
</ParamField>

<ParamField query="order" type="string">
  Sort direction: `asc` or `desc`. Defaults to `desc` for `uploadedAt` and
  `sizeBytes`, and `asc` for `filename`.
</ParamField>

<ParamField query="limit" type="number">
  Page size from `1` to `100`. Defaults to `30`.
</ParamField>

<ParamField query="offset" type="number">
  Zero-based page offset. Defaults to `0`.
</ParamField>

Duplicate query parameters, unknown query parameters, invalid dates, and
inverted date ranges return `INVALID_QUERY_PARAM`.

**Example request:**

```bash theme={null}
curl "https://cms.example.com/api/v1/media?category=image&q=hero&sort=uploadedAt&order=desc&limit=30&offset=0" \
  -H "X-MDCMS-Project: marketing-site" \
  -H "X-MDCMS-Environment: production" \
  -H "Authorization: Bearer mdcms_key_live_abc123"
```

**Response:**

```json theme={null}
{
  "data": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "project": "marketing-site",
      "filename": "hero.png",
      "mimeType": "image/png",
      "sizeBytes": 245760,
      "url": "https://cdn.example.com/marketing-site/media/550e8400-e29b-41d4-a716-446655440000/hero.png",
      "uploadedBy": "880e8400-e29b-41d4-a716-446655440002",
      "uploadedAt": "2026-01-20T10:00:00.000Z"
    }
  ],
  "pagination": {
    "limit": 30,
    "offset": 0,
    "total": 1,
    "hasMore": false
  }
}
```

### Upload Media

<ParamField method="POST" path="/api/v1/media/upload">
  Upload a file. The request must use `multipart/form-data` encoding.
</ParamField>

**Scope required:** `media:upload`

**Content-Type:** `multipart/form-data`

<ParamField body="file" type="file" required>
  The file to upload. Image uploads are limited by the routed project's media
  image upload setting when configured. Non-image uploads are not subject to
  that image limit.
</ParamField>

**Example request:**

```bash theme={null}
curl -X POST "https://cms.example.com/api/v1/media/upload" \
  -H "X-MDCMS-Project: marketing-site" \
  -H "X-MDCMS-Environment: production" \
  -H "Authorization: Bearer mdcms_key_live_abc123" \
  -F "file=@/path/to/image.png"
```

**Response:**

```json theme={null}
{
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "project": "marketing-site",
    "filename": "image.png",
    "mimeType": "image/png",
    "sizeBytes": 245760,
    "url": "https://cdn.example.com/marketing-site/media/550e8400-e29b-41d4-a716-446655440000/image.png",
    "uploadedBy": "880e8400-e29b-41d4-a716-446655440002",
    "uploadedAt": "2026-01-20T10:00:00.000Z"
  }
}
```

### Get Media

<ParamField method="GET" path="/api/v1/media/{id}">
  Read metadata for one media asset in the routed project and environment.
</ParamField>

**Scope required:** `media:read`

### Path Parameters

<ParamField path="id" type="string" required>
  The media asset ID.
</ParamField>

**Example request:**

```bash theme={null}
curl "https://cms.example.com/api/v1/media/550e8400-e29b-41d4-a716-446655440000" \
  -H "X-MDCMS-Project: marketing-site" \
  -H "X-MDCMS-Environment: production" \
  -H "Authorization: Bearer mdcms_key_live_abc123"
```

**Response:**

```json theme={null}
{
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "project": "marketing-site",
    "filename": "image.png",
    "mimeType": "image/png",
    "sizeBytes": 245760,
    "url": "https://cdn.example.com/marketing-site/media/550e8400-e29b-41d4-a716-446655440000/image.png",
    "uploadedBy": "880e8400-e29b-41d4-a716-446655440002",
    "uploadedAt": "2026-01-20T10:00:00.000Z"
  }
}
```

### Delete Media

<ParamField method="DELETE" path="/api/v1/media/{id}">
  Delete a media file. This removes the file from storage and invalidates all
  URLs.
</ParamField>

**Scope required:** `media:delete`

### Path Parameters

<ParamField path="id" type="string" required>
  The media asset ID.
</ParamField>

**Example request:**

```bash theme={null}
curl -X DELETE "https://cms.example.com/api/v1/media/550e8400-e29b-41d4-a716-446655440000" \
  -H "X-MDCMS-Project: marketing-site" \
  -H "X-MDCMS-Environment: production" \
  -H "X-MDCMS-CSRF-Token: csrf_token_value" \
  -H "Authorization: Bearer mdcms_key_live_abc123"
```

**Response:**

```json theme={null}
{
  "data": {
    "deleted": true,
    "id": "550e8400-e29b-41d4-a716-446655440000"
  }
}
```

<Warning>
  Media deletion is permanent and cannot be undone. Ensure no documents
  reference the file before deleting. The API does not scan or rewrite content
  references.
</Warning>

***

## Webhooks API

Webhooks let you receive real-time HTTP notifications when content changes occur in your project. Configure webhook endpoints to trigger builds, invalidate caches, or sync data with external systems.

<Note>
  The Webhooks API is a post-MVP feature. The endpoints are defined and will be
  available in an upcoming release.
</Note>

### List Webhooks

<ParamField method="GET" path="/api/v1/webhooks">
  Returns all webhook configurations for the current project.
</ParamField>

**Scope required:** `webhooks:read`

**Example request:**

```bash theme={null}
curl "https://cms.example.com/api/v1/webhooks" \
  -H "X-MDCMS-Project: marketing-site" \
  -H "X-MDCMS-Environment: production" \
  -H "Authorization: Bearer mdcms_key_live_abc123"
```

**Response:**

```json theme={null}
{
  "data": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "project": "marketing-site",
      "environment": "production",
      "url": "https://api.example.com/webhooks/mdcms",
      "events": ["content.published", "content.unpublished"],
      "active": true,
      "createdBy": "user_01HZX9N8Y6M2J4K7V3P6Q8R9SA",
      "updatedBy": "user_01HZX9N8Y6M2J4K7V3P6Q8R9SA",
      "createdAt": "2026-01-10T12:00:00.000Z",
      "updatedAt": "2026-01-10T12:00:00.000Z"
    },
    {
      "id": "660e8400-e29b-41d4-a716-446655440001",
      "project": "marketing-site",
      "environment": "production",
      "url": "https://deploy.example.com/trigger",
      "events": ["content.published"],
      "active": true,
      "createdBy": "user_01HZX9N8Y6M2J4K7V3P6Q8R9SA",
      "updatedBy": "user_01HZX9N8Y6M2J4K7V3P6Q8R9SA",
      "createdAt": "2026-01-12T08:00:00.000Z",
      "updatedAt": "2026-01-12T08:00:00.000Z"
    }
  ]
}
```

### Create Webhook

<ParamField method="POST" path="/api/v1/webhooks">
  Register a new webhook endpoint.
</ParamField>

**Scope required:** `webhooks:write`

<ParamField body="url" type="string" required>
  The HTTPS URL to receive webhook payloads. Must use HTTPS.
</ParamField>

<ParamField body="events" type="string[]" required>
  List of event types to subscribe to. See [Event Types](#event-types) below.
</ParamField>

<ParamField body="secret" type="string" required>
  Shared signing secret for HMAC signature verification. Must be 32 to 4096
  characters. Studio generates this value automatically for new webhooks; API
  clients must provide a cryptographically random value.
</ParamField>

<ParamField body="active" type="boolean">
  Whether the webhook is active. Defaults to `true`.
</ParamField>

**Example request:**

```bash theme={null}
curl -X POST "https://cms.example.com/api/v1/webhooks" \
  -H "Content-Type: application/json" \
  -H "X-MDCMS-Project: marketing-site" \
  -H "X-MDCMS-Environment: production" \
  -H "X-MDCMS-CSRF-Token: csrf_token_value" \
  -H "Authorization: Bearer mdcms_key_live_abc123" \
  -d '{
    "url": "https://api.example.com/webhooks/mdcms",
    "events": ["content.published", "content.unpublished", "content.deleted"],
    "secret": "whsec_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
    "active": true
  }'
```

**Response:**

```json theme={null}
{
  "data": {
    "id": "770e8400-e29b-41d4-a716-446655440002",
    "project": "marketing-site",
    "environment": "production",
    "url": "https://api.example.com/webhooks/mdcms",
    "events": ["content.published", "content.unpublished", "content.deleted"],
    "active": true,
    "createdBy": "user_01HZX9N8Y6M2J4K7V3P6Q8R9SA",
    "updatedBy": "user_01HZX9N8Y6M2J4K7V3P6Q8R9SA",
    "createdAt": "2026-01-20T10:00:00.000Z",
    "updatedAt": "2026-01-20T10:00:00.000Z"
  }
}
```

### Update Webhook

<ParamField method="PUT" path="/api/v1/webhooks/{id}">
  Update an existing webhook configuration.
</ParamField>

**Scope required:** `webhooks:write`

### Path Parameters

<ParamField path="id" type="string" required>
  The webhook ID.
</ParamField>

<ParamField body="url" type="string">
  Updated HTTPS URL.
</ParamField>

<ParamField body="events" type="string[]">
  Updated event subscriptions.
</ParamField>

<ParamField body="secret" type="string">
  Updated shared signing secret. Omit this field to preserve the existing
  write-only secret.
</ParamField>

<ParamField body="active" type="boolean">
  Enable or disable the webhook.
</ParamField>

**Example request:**

```bash theme={null}
curl -X PUT "https://cms.example.com/api/v1/webhooks/770e8400-e29b-41d4-a716-446655440002" \
  -H "Content-Type: application/json" \
  -H "X-MDCMS-Project: marketing-site" \
  -H "X-MDCMS-Environment: production" \
  -H "X-MDCMS-CSRF-Token: csrf_token_value" \
  -H "Authorization: Bearer mdcms_key_live_abc123" \
  -d '{
    "events": ["content.published", "content.unpublished", "content.deleted", "content.created"],
    "active": true
  }'
```

**Response:**

```json theme={null}
{
  "data": {
    "id": "770e8400-e29b-41d4-a716-446655440002",
    "project": "marketing-site",
    "environment": "production",
    "url": "https://api.example.com/webhooks/mdcms",
    "events": [
      "content.published",
      "content.unpublished",
      "content.deleted",
      "content.created"
    ],
    "active": true,
    "createdBy": "user_01HZX9N8Y6M2J4K7V3P6Q8R9SA",
    "updatedBy": "user_01HZX9N8Y6M2J4K7V3P6Q8R9SA",
    "createdAt": "2026-01-20T10:00:00.000Z",
    "updatedAt": "2026-01-20T11:00:00.000Z"
  }
}
```

### Delete Webhook

<ParamField method="DELETE" path="/api/v1/webhooks/{id}">
  Remove a webhook configuration. Pending deliveries are cancelled.
</ParamField>

**Scope required:** `webhooks:write`

### Path Parameters

<ParamField path="id" type="string" required>
  The webhook ID.
</ParamField>

**Example request:**

```bash theme={null}
curl -X DELETE "https://cms.example.com/api/v1/webhooks/770e8400-e29b-41d4-a716-446655440002" \
  -H "X-MDCMS-Project: marketing-site" \
  -H "X-MDCMS-Environment: production" \
  -H "X-MDCMS-CSRF-Token: csrf_token_value" \
  -H "Authorization: Bearer mdcms_key_live_abc123"
```

**Response:**

```json theme={null}
{
  "data": {
    "deleted": true,
    "id": "770e8400-e29b-41d4-a716-446655440002"
  }
}
```

***

### Event Types

| Event                 | Trigger                                                     |
| --------------------- | ----------------------------------------------------------- |
| `content.created`     | A new document is created.                                  |
| `content.updated`     | A document's draft is updated (frontmatter or body change). |
| `content.published`   | A document is published, creating a new version.            |
| `content.unpublished` | A published document is unpublished.                        |
| `content.deleted`     | A document is soft-deleted.                                 |
| `content.restored`    | A soft-deleted document is restored.                        |
| `media.uploaded`      | A media file is uploaded.                                   |

### Webhook Payload

All webhook deliveries use the following envelope:

```json theme={null}
{
  "event": "content.published",
  "timestamp": "2026-01-20T12:00:00.000Z",
  "project": "marketing-site",
  "environment": "production",
  "document": {
    "documentId": "550e8400-e29b-41d4-a716-446655440000",
    "translationGroupId": "660e8400-e29b-41d4-a716-446655440001",
    "type": "BlogPost",
    "path": "content/blog/hello-world",
    "locale": "en",
    "format": "mdx",
    "version": 4
  },
  "user": {
    "id": "user_01HZX9N8Y6M2J4K7V3P6Q8R9SA",
    "email": "editor@example.com"
  }
}
```

### Signature Verification

Every webhook delivery includes an `X-MDCMS-Signature` header for payload verification:

```
X-MDCMS-Signature: t=1705747200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

The signature is an HMAC-SHA256 digest computed over the concatenation of the timestamp and the raw request body, using the webhook's `secret` as the key. The raw body must be the exact bytes MDCMS sent; parsing JSON and serializing it again can change whitespace or key order and invalidate the signature.

**Verification algorithm:**

1. Extract the `t` (timestamp) and `v1` (signature) values from the header.
2. Construct the signed payload: `{timestamp}.{raw_body}`.
3. Compute `HMAC-SHA256(secret, signed_payload)`.
4. Compare the computed digest with the `v1` value (use constant-time comparison).
5. Optionally, reject payloads where the timestamp is more than 5 minutes old to prevent replay attacks.

**Verification helper (Node.js):**

```typescript theme={null}
import { createHmac, timingSafeEqual } from "crypto";

function verifyWebhookSignature(
  rawBody: string,
  signatureHeader: string,
  secret: string,
  toleranceSeconds = 300,
): boolean {
  const parts = Object.fromEntries(
    signatureHeader.split(",").map((p) => {
      const [key, value] = p.split("=");
      return [key, value];
    }),
  );

  const timestamp = parts.t;
  const signature = parts.v1;

  if (!timestamp || !signature) {
    return false;
  }

  const timestampSeconds = Number.parseInt(timestamp, 10);
  if (!Number.isSafeInteger(timestampSeconds) || timestampSeconds < 0) {
    return false;
  }

  const age = Math.abs(Math.floor(Date.now() / 1000) - timestampSeconds);
  if (age > toleranceSeconds) {
    return false;
  }

  const signedPayload = `${timestamp}.${rawBody}`;
  const expected = createHmac("sha256", secret)
    .update(signedPayload)
    .digest("hex");
  const actualBuffer = Buffer.from(signature, "hex");
  const expectedBuffer = Buffer.from(expected, "hex");

  if (actualBuffer.length !== expectedBuffer.length) {
    return false;
  }

  return timingSafeEqual(actualBuffer, expectedBuffer);
}
```

**Express receiver example:**

```typescript theme={null}
import express from "express";

const app = express();
const signingSecret = process.env.MDCMS_WEBHOOK_SIGNING_SECRET!;

app.post(
  "/webhooks/mdcms",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const rawBody = req.body.toString("utf8");
    const signature = req.header("x-mdcms-signature");

    if (!verifyWebhookSignature(rawBody, signature ?? "", signingSecret)) {
      res.status(401).send("Invalid webhook signature");
      return;
    }

    const payload = JSON.parse(rawBody);

    // Process the verified payload asynchronously if work may take longer than
    // a few seconds.
    queueWebhookJob(payload);
    res.sendStatus(200);
  },
);
```

### Delivery Behavior

* **Async delivery** -- Webhooks are dispatched asynchronously and do not block the originating API request.
* **Retries** -- Failed deliveries (non-2xx responses or network errors) use three total attempts: the initial attempt, then retries after 1 second and 2 seconds.
* **HTTPS only** -- Webhook URLs must use HTTPS. HTTP URLs are rejected at configuration time.
* **Timeout** -- Each delivery attempt has a 10-second timeout. If the endpoint does not respond within 10 seconds, the attempt is marked as failed.

<Tip>
  Return a `200` status code as quickly as possible from your webhook endpoint.
  Perform any heavy processing asynchronously to avoid timeouts.
</Tip>
