Source: https://developers.tokportal.com/media-upload/
Markdown: https://developers.tokportal.com/media-upload.md

# Media Upload

Before configuring videos, you need to upload your media files (videos and images) to TokPortal's storage.

You have **two options**:

- **Direct upload** (simpler) — Send the file directly to the API. Best for scripts, MCP, and automation.
- **Presigned URL** (advanced) — Get a temporary upload URL and PUT the file yourself. Best for browser-based uploads.

***

## Option 1: Direct Upload

Upload a file directly — the server handles everything.

### Upload Video (Direct)

```
POST /upload/video/direct
Content-Type: multipart/form-data
```

```bash
curl -X POST https://app.tokportal.com/api/ext/upload/video/direct \
  -H "X-API-Key: sk_xxx" \
  -F "file=@/path/to/video.mp4" \
  -F "bundle_id=your-bundle-id"
```

**Response:**

```json
{
  "data": {
    "public_url": "https://pub-xxx.r2.dev/videos/bundle-id/123456.mp4",
    "filename": "video.mp4",
    "size_bytes": 15234567,
    "content_type": "video/mp4"
  }
}
```

### Upload Image (Direct)

```
POST /upload/image/direct
Content-Type: multipart/form-data
```

```bash
curl -X POST https://app.tokportal.com/api/ext/upload/image/direct \
  -H "X-API-Key: sk_xxx" \
  -F "file=@/path/to/photo.jpg" \
  -F "bundle_id=your-bundle-id" \
  -F "purpose=carousel"
```

`purpose` can be `carousel` (default) or `profile_picture`. Omitting it still defaults to `carousel`, but an **explicitly invalid** value is no longer accepted: it is refused with `INVALID_FIELD` for strict callers (MCP by default) and reported in `meta.ignored_fields` for the rest. It never silently lands the image in the carousel bucket — check the returned `bucket`.

**Response:**

```json
{
  "data": {
    "public_url": "https://xxx.supabase.co/storage/v1/object/public/carousel-images/...",
    "storage_path": "carousel-images/org_xxx/bundle-id/photo-123456.jpg",
    "bucket": "carousel-images",
    "filename": "photo.jpg",
    "size_bytes": 245678,
    "content_type": "image/jpeg"
  }
}
```

### Using Upload Results in Video Configuration

| Video Config Field    | Use This Value                                    |
| --------------------- | ------------------------------------------------- |
| `video_url`           | `public_url` from the **video** upload response   |
| `carousel_images[]`   | `storage_path` from the **image** upload response |
| `profile_picture_url` | `storage_path` from the **image** upload response |

> **Important:** For `carousel_images` and `profile_picture_url`, use `storage_path` — **not** `public_url`. Videos are the opposite: `uploadVideo`'s `public_url` goes straight into `video_url`.

***

## Import Image From URL

If your image already exists at a public direct URL, TokPortal can fetch and store it permanently.

```http
POST /upload/image/from-url
Content-Type: application/json
```

```bash
curl -X POST https://app.tokportal.com/api/ext/upload/image/from-url \
  -H "X-API-Key: sk_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://cdn.example.com/slide1.jpg",
    "bundle_id": "your-bundle-id",
    "purpose": "carousel"
  }'
```

Use the returned `storage_path` for `carousel_images` or `profile_picture_url`.

***

## Option 2: Presigned URL Upload

Get a temporary URL and upload the file yourself. This is useful for browser-based uploads where the client uploads directly.

> **CAUTION: Signed URL responses are not idempotency-ledger responses**
> Do not send `Idempotency-Key` to `POST /upload/video` or `POST /upload/image`. Their responses contain temporary signed upload URLs, so TokPortal rejects the header before execution with `400 IDEMPOTENCY_KEY_NOT_ALLOWED_FOR_SENSITIVE_RESPONSE`. If a response is lost or the URL expires, request a new signed URL without the header. Do not log, persist beyond expiry, or send `upload_url` to support. This restriction does not apply to the direct upload and public-URL import operations unless their reference explicitly says otherwise.

### Upload Flow

1. **Request a presigned URL** — Call the upload endpoint with file metadata.
2. **Upload the file** — Use the returned `upload_url` and `method` to upload the file.
3. **Use the URL in video config** — Pass the appropriate URL to the video configuration endpoints.

***

## Presigned Video Upload

```
POST /upload/video
```

Returns a presigned Cloudflare R2 URL for uploading a video file.

| Field          | Type   | Required | Description                                                |
| -------------- | ------ | -------- | ---------------------------------------------------------- |
| `filename`     | string | Yes      | Original filename including extension (e.g., `promo.mp4`). |
| `content_type` | string | Yes      | MIME type of the file (e.g., `video/mp4`).                 |
| `bundle_id`    | string | Yes      | The bundle this video belongs to.                          |

### Step 1 — Get the presigned URL

```bash
curl -X POST https://app.tokportal.com/api/ext/upload/video \
  -H "X-API-Key: sk_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "filename": "promo.mp4",
    "content_type": "video/mp4",
    "bundle_id": "bnd_a1b2c3d4"
  }'
```

**Response:**

```json
{
  "data": {
    "upload_url": "https://storage.tokportal.com/videos/org_xxx/bnd_a1b2c3d4/promo-1707660000.mp4?X-Amz-Algorithm=AWS4-HMAC-SHA256&...",
    "public_url": "https://pub-xxx.r2.dev/videos/org_xxx/bnd_a1b2c3d4/promo-1707660000.mp4",
    "method": "PUT",
    "content_type": "video/mp4",
    "expires_in_seconds": 3600,
    "instructions": "PUT the file to upload_url with the specified Content-Type header."
  }
}
```

### Step 2 — Upload the file

Use the `method` and `upload_url` from the response:

```bash
curl -X PUT "https://storage.tokportal.com/videos/org_xxx/bnd_a1b2c3d4/promo-1707660000.mp4?X-Amz-Algorithm=AWS4-HMAC-SHA256&..." \
  -H "Content-Type: video/mp4" \
  --data-binary @promo.mp4
```

### Step 3 — Use the public URL

Pass `public_url` as `video_url` when [configuring a video](https://developers.tokportal.com/videos/configure-videos):

```json
{
  "video_type": "video",
  "video_url": "https://pub-xxx.r2.dev/videos/org_xxx/bnd_a1b2c3d4/promo-1707660000.mp4",
  "description": "New product launch",
  "target_publish_date": "2026-03-15"
}
```

***

## Presigned Image Upload

```
POST /upload/image
```

Returns a presigned Supabase Storage URL for uploading an image. Use this for carousel slides and profile pictures.

| Field          | Type   | Required | Description                                                                                                                                                                                                                                                                                                        |
| -------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `filename`     | string | Yes      | Original filename. Only used as a fallback for the stored object's extension when `content_type` is not one TokPortal maps.                                                                                                                                                                                        |
| `content_type` | string | Yes      | MIME type of the file (e.g., `image/jpeg`). **This is what names the stored object**: the declared content type picks the extension, and the filename is only the fallback. `{"filename": "avatar", "content_type": "image/png"}` therefore stores a `.png` key — it no longer produces a key ending in `.avatar`. |
| `bundle_id`    | string | Yes      | The bundle this image belongs to.                                                                                                                                                                                                                                                                                  |
| `purpose`      | string | No       | `"carousel"` (default) or `"profile_picture"`. Also selects the returned `bucket`.                                                                                                                                                                                                                                 |

### Step 1 — Get the presigned URL

```bash
curl -X POST https://app.tokportal.com/api/ext/upload/image \
  -H "X-API-Key: sk_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "filename": "slide1.jpg",
    "content_type": "image/jpeg",
    "bundle_id": "bnd_a1b2c3d4",
    "purpose": "carousel"
  }'
```

**Response:**

```json
{
  "data": {
    "upload_url": "https://xxx.supabase.co/storage/v1/object/upload/sign/carousel-images/org_xxx/bnd_a1b2c3d4/slide1-1707660000.jpg?token=...",
    "public_url": "https://xxx.supabase.co/storage/v1/object/public/carousel-images/org_xxx/bnd_a1b2c3d4/slide1-1707660000.jpg",
    "storage_path": "carousel-images/org_xxx/bnd_a1b2c3d4/slide1-1707660000.jpg",
    "bucket": "carousel-images",
    "method": "PUT",
    "content_type": "image/jpeg",
    "expires_in_seconds": 3600,
    "instructions": "PUT the file to upload_url with the specified Content-Type header."
  }
}
```

### Step 2 — Upload the file

```bash
curl -X PUT "https://xxx.supabase.co/storage/v1/object/upload/sign/carousel-images/org_xxx/bnd_a1b2c3d4/slide1-1707660000.jpg?token=..." \
  -H "Content-Type: image/jpeg" \
  --data-binary @slide1.jpg
```

### Step 3 — Use the storage path

For `carousel_images` and `profile_picture_url`, use the `storage_path` value (**not** `public_url`):

```json
{
  "video_type": "carousel",
  "carousel_images": [
    "carousel-images/org_xxx/bnd_a1b2c3d4/slide1-1707660000.jpg",
    "carousel-images/org_xxx/bnd_a1b2c3d4/slide2-1707660000.jpg"
  ],
  "description": "Swipe through our latest collection",
  "target_publish_date": "2026-03-15"
}
```

***

## Presigned Upload Response Fields

| Field                | Type    | Description                                                                                                        |
| -------------------- | ------- | ------------------------------------------------------------------------------------------------------------------ |
| `upload_url`         | string  | The temporary presigned URL to upload the file to.                                                                 |
| `public_url`         | string  | The permanent public URL for the file after upload.                                                                |
| `storage_path`       | string  | The storage path (images only). Use this for `carousel_images` and `profile_picture_url`.                          |
| `bucket`             | string  | The bucket the object was stored in (images only): `carousel-images` or `profile-pictures`, selected by `purpose`. |
| `method`             | string  | HTTP method to use for the upload (always `PUT`).                                                                  |
| `content_type`       | string  | The MIME type to set in the upload `Content-Type` header.                                                          |
| `expires_in_seconds` | integer | Seconds until the presigned URL expires (typically `3600`).                                                        |
| `instructions`       | string  | Human-readable instructions for completing the upload.                                                             |

***

## Accepted Formats

### Video

| Format | MIME Type         | Extension |
| ------ | ----------------- | --------- |
| MP4    | `video/mp4`       | `.mp4`    |
| MOV    | `video/quicktime` | `.mov`    |
| WebM   | `video/webm`      | `.webm`   |

### Image

| Format | MIME Type                 | Extension       |
| ------ | ------------------------- | --------------- |
| JPEG   | `image/jpeg`, `image/jpg` | `.jpg`, `.jpeg` |
| PNG    | `image/png`               | `.png`          |
| WebP   | `image/webp`              | `.webp`         |
| GIF    | `image/gif`               | `.gif`          |

The extension column is what the declared `content_type` resolves to when the object is stored.

## Presigned URL Expiration

Presigned URLs expire after the time indicated in `expires_in_seconds` (typically **1 hour** / 3600 seconds). If the URL expires before you upload, request a new one.

## Error Handling

**Unsupported file type:**

```json
{
  "error": {
    "code": "UNSUPPORTED_FILE_TYPE",
    "message": "The content type 'video/avi' is not supported.",
    "details": {
      "supported_types": ["video/mp4", "video/quicktime", "video/webm"]
    }
  }
}
```

**Missing required field:**

```json
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "bundle_id is required."
  }
}
```
