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

# Private CDN

> Signed upload and read URLs for enterprise organizations whose media is not public.

Enterprise organizations can keep authentication photos and certificates off the public CDN. Uploads go to a private bucket. Reads return CloudFront-signed URLs. The public share page does not show photos.

Create, read, and submit service requests stay the same partner APIs. Only the upload path and the shape of media URLs change.

If your organization is on the public CDN, use the standard [Workflow](/partner/workflow) upload path (`GET /intent`) instead.

## Required scope

Your API key must include `media:v2:private-read` in addition to `sr:v2:create` and `sr:v2:read`. Legitmark grants this scope when private media is enabled for your organization.

Send the same `leo_` key on every call below:

```javascript theme={null}
const headers = {
  'Authorization': 'Bearer leo_xxxxxxxxx',
  'Content-Type': 'application/json'
};
```

Without `media:v2:private-read`, `GET /api/v2/sr/{sr_uuid}` returns empty `media_url` arrays and a null `certificate_url` for private-CDN organizations.

## Integration steps

Same flow as the public partner API, with two changes: do not call `/intent`, and treat every media URL as short-lived.

<Steps>
  <Step title="Create the service request">
    `POST /api/v2/sr?sides=true&item=true` with your `leo_` key and the usual body. Passing `sides=true&item=true` returns the required/optional photo list inline so you do not need a second fetch before upload.

    **API Reference:** [`POST /api/v2/sr`](/api-reference#tag/Service-Requests/operation/createServiceRequest)
  </Step>

  <Step title="Upload photos">
    `POST /api/media/signed-urls/{sr_uuid}` once for all sides, then `PUT` each image to the returned `signed_url`. Do **not** call `GET /intent`.
  </Step>

  <Step title="Read photos">
    `GET /api/v2/sr/{sr_uuid}?item=true&sides=true` with the same key. `media_url` (and `summary.thumbnail` if you pass `summary=true`) are signed private-CDN links that last about 15 minutes.
  </Step>

  <Step title="Read the certificate">
    Use `certificate_url` from the latest Get Full SR. If you get `Access Denied` or `403`, fetch again. You can also regenerate from **Download certificate** in the dashboard.
  </Step>
</Steps>

## Upload photos

Do **not** call `GET https://asset.legitmark.com/intent` (or the development/staging asset host). That route writes to the public bucket and cannot route private-organization uploads.

Request every side's upload URL in one call, then `PUT` the image bytes to each URL.

**API Reference:** [`POST /api/media/signed-urls/{sr_uuid}`](/api-reference#tag/Media-Management/operation/generateBatchSignedUrls)

<CodeGroup>
  ```javascript HTTP theme={null}
  const sides = requiredSides.map((side) => ({
    uuid: side.uuid,
    filename: `${side.uuid}.jpg`,
    content_type: 'image/jpeg'
  }));

  const signedResponse = await fetch(
    `https://api.legitmark.com/api/media/signed-urls/${srUuid}`,
    {
      method: 'POST',
      headers,
      body: JSON.stringify({
        sides,
        expires_in: 900
      })
    }
  );
  const { urls } = await signedResponse.json();

  for (const side of requiredSides) {
    const { signed_url } = urls[side.uuid];
    await fetch(signed_url, {
      method: 'PUT',
      body: imageFiles[side.uuid],
      headers: { 'Content-Type': 'image/jpeg' }
    });
  }
  ```

  ```bash cURL theme={null}
  curl -X POST "https://api.legitmark.com/api/media/signed-urls/SR_UUID" \
    -H "Authorization: Bearer leo_xxxxxxxxx" \
    -H "Content-Type: application/json" \
    -d '{
      "sides": [
        {
          "uuid": "SIDE_UUID",
          "filename": "SIDE_UUID.jpg",
          "content_type": "image/jpeg"
        }
      ],
      "expires_in": 900
    }'

  curl -X PUT "SIGNED_URL_FROM_ABOVE" \
    -H "Content-Type: image/jpeg" \
    --data-binary @photo.jpg
  ```
</CodeGroup>

**Request body**

| Field        | Type    | Required | Description                                                                                                                                         |
| ------------ | ------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sides`      | array   | yes      | One entry per photo. Each item needs `uuid` (side UUID) and `filename` (`{side_uuid}.jpg`). `content_type` is optional (`image/jpeg`, `image/png`). |
| `expires_in` | integer | no       | Upload-URL lifetime in seconds. Default `900` (15 minutes).                                                                                         |

**Response**

```json theme={null}
{
  "success": true,
  "message": "Batch signed URLs generated successfully.",
  "urls": {
    "ec883c70-f896-47c9-81b6-8d6ff0b5856a": {
      "signed_url": "https://s3.us-east-1.amazonaws.com/...",
      "expires_at": "2026-09-01T08:15:00.000Z",
      "is_multipart": false
    }
  },
  "metadata": {
    "total_urls": 1,
    "failed_urls": 0,
    "sr_uuid": "8e61c991-553a-4a58-9eb6-561b64c11908",
    "expires_in": 900
  }
}
```

<Warning>
  Do not send your `Authorization` header on the `PUT` to `signed_url`. The URL is already signed, and S3 rejects the request with `400` if an `Authorization` header is present. Only the `POST /api/media/signed-urls/{sr_uuid}` call uses your API key.
</Warning>

<Note>
  The TypeScript SDK still uses `GET /intent`. Private-CDN organizations should call `POST /api/media/signed-urls/{sr_uuid}` over HTTP until the SDK is updated.
</Note>

### Browser uploads (CORS)

If a warehouse or web app `PUT`s the image from the browser, S3 must allow that page's origin. Share every upload origin with Legitmark (exact origins and `https://*.example.com` wildcards). Origins already allowlisted for your organization keep working. A CORS error on the `PUT` means a new origin needs to be added — do not fall back to `/intent`.

## Read photos

Fetch the service request with the same API key. `media_url` values are CloudFront-signed links on your private CDN host.

**API Reference:** [`GET /api/v2/sr/{sr_uuid}`](/api-reference#tag/Service-Requests/operation/getServiceRequestV2)

<CodeGroup>
  ```javascript HTTP theme={null}
  const response = await fetch(
    `https://api.legitmark.com/api/v2/sr/${srUuid}?item=true&sides=true`,
    { headers }
  );
  const { sr } = await response.json();

  for (const side of sr.sides.required) {
    const latest = side.media_url.find((media) => media.is_latest);
    if (latest) {
      console.log(`${side.name}: ${latest.url}`);
    }
  }
  ```

  ```bash cURL theme={null}
  curl "https://api.legitmark.com/api/v2/sr/SR_UUID?item=true&sides=true" \
    -H "Authorization: Bearer leo_xxxxxxxxx"
  ```
</CodeGroup>

A signed read URL looks like this (query values are truncated):

```
https://staging-{org}-private-cdn.legitmark.com/org/{org_uuid}/sr/{sr_uuid}/{side_uuid}.jpg?Expires=1787907356&Key-Pair-Id=...&Signature=...
```

* Keep `Expires`, `Key-Pair-Id`, and `Signature` on the URL. Stripping the query string returns `403`.
* `summary.thumbnail` is signed the same way when you pass `summary=true`.
* Public `cdn.legitmark.com` links from earlier testing no longer serve these photos.

## Certificates

`certificate_url` on the latest `GET /api/v2/sr/{sr_uuid}` is a signed PDF link on the same private host. Use that URL. If you get `Access Denied` or `403`, the signature expired — call `GET` again and use the new URL.

You can also regenerate a fresh link from **Download certificate** in the dashboard (`https://app.legitmark.com` or the development/staging app host).

Do not reuse a certificate link you stored earlier.

## URL lifetime

Signed read URLs last **15 minutes** from the moment they are issued (`expires_in` default `900` seconds).

* Do not persist or cache signed URLs in your database.
* Do not log or forward a signed URL as a durable share link. Anyone who has the URL can fetch the object until it expires.
* Call `GET /api/v2/sr/{sr_uuid}` again whenever you need to display or download a photo or certificate.
* An expired or unsigned URL returns `403`. That is expected. Fetch a fresh URL.

Webhook payloads do not include media URLs. When a `state_change` arrives, fetch the SR if you need photos or the certificate.

## Public share page

The public share page does not show photos for private-CDN organizations. That is intended. Photos and certificates stay available through the partner API with your keyed requests.

| Environment | Share page                                            |
| ----------- | ----------------------------------------------------- |
| Development | `https://dev.app.legitmark.com/view?sr={sr_uuid}`     |
| Staging     | `https://staging.app.legitmark.com/view?sr={sr_uuid}` |
| Production  | `https://app.legitmark.com/view?sr={sr_uuid}`         |

## Environments

| Environment | API host                            | Typical private CDN host                          |
| ----------- | ----------------------------------- | ------------------------------------------------- |
| Development | `https://dev.api.legitmark.com`     | `https://dev-{org}-private-cdn.legitmark.com`     |
| Staging     | `https://staging.api.legitmark.com` | `https://staging-{org}-private-cdn.legitmark.com` |
| Production  | `https://api.legitmark.com`         | `https://{org}-private-cdn.legitmark.com`         |

Replace `{org}` with the hostname Legitmark provisioned for your organization. Use the URL returned by the API rather than constructing CDN hosts yourself.

## Image requirements

Same as the public workflow:

* **Format:** JPG/JPEG/PNG
* **Size:** 600 x 600 px minimum
* **File size:** 5 MB maximum per image
* **Quality:** Clear, well-lit, focused images
