# upload_media_batch

Upload up to 10 photos in one request.

`POST /v1/media/upload-batch` · REST only · scope `write` · beta · since 2026-09-30

multipart/form-data with one form field per file and a manifest field: a JSON array with one item per file, naming the form field (file) and the same fields as upload_media ({ file, ownerType, ownerId, caption, credit, alt, ... } or { file, target: "blob" }). Every file counts against the upload limit and succeeds or fails on its own; the response lists each result. The status is 201 when at least one file was stored and 400 when every file failed, with the same body. At most 10 files and 19 MB per request.

## Form fields

- `manifest` (array of values, required): JSON array, one item per file: { file (the form field name), ownerType, ownerId, and any upload_media field }, or { file, target: "blob" }.
- `<file field>` (string, required): One form field per file, named as the manifest item's file.

## Returns

201: BatchUpload.

- `uploaded` (number, required): Files stored.
- `failed` (number, required): Files refused.
- `results` (array of objects, required): One result per manifest item, in order.
  - `file` (string or null, required): The form field name from the manifest.
  - `ok` (boolean, required): Whether this file was stored.
  - `mediaId` (string, optional): The new photo's id (media uploads).
  - `storageId` (string, optional): The stored file's id (target blob).
  - `url` (string or null, optional): The stored photo.
  - `thumbUrl` (string or null, optional): The thumbnail.
  - `ownerType` (string, optional): What the photo belongs to. One of `spot`, `product`, `collection`, `listing`.
  - `ownerId` (string, optional): The owner row's id.
  - `caption` (string or null, optional): Caption.
  - `credit` (string or null, optional): Photographer credit.
  - `order` (number, optional): Position in the owner's gallery.
  - `contentType` (string, optional): The stored file's type.
  - `width` (number, optional): Stored width in pixels.
  - `height` (number, optional): Stored height in pixels.
  - `bytes` (number, optional): Stored size in bytes.
  - `thumbBytes` (number or null, optional): The thumbnail's size in bytes.
  - `originalBytes` (number, optional): The uploaded file's size in bytes.
  - `optimized` (boolean, optional): false when the original was kept.
  - `metadataStripped` (boolean, optional): true when metadata was removed.
  - `capturedOn` (string or null, optional): Capture date.
  - `use` (string, optional): Where a storageId goes.
  - `warnings` (array of strings, optional): Notes about things the write accepted but you should look at (a plain http link). Present only when there are any.
  - `error` (object, optional): Why the file was refused.
    - `code` (string, required): The error code (see Errors).
    - `message` (string, required): What went wrong.

## Examples

### Two spot photos and a chapter cover in one request

```bash
curl -X POST "https://sceniq.earth/api/v1/media/upload-batch" \
  -H "Authorization: Bearer $SCENIQ_API_KEY" \
  -F 'manifest=[{"file":"overlook","ownerType":"spot","ownerId":"k1709e2vj4sk8q6x0d3m5r7t9w1y3b5c","caption":"Grand Prismatic Spring from the overlook, mid morning","credit":"Mara Lindgren","alt":"A deep blue pool ringed with orange and yellow, steam drifting over the boardwalk below","shows":{"kind":"spot"},"takenAt":{"lat":44.5199,"lon":-110.8406,"precision":"gps"}},{"file":"boardwalk","ownerType":"spot","ownerId":"k1709e2vj4sk8q6x0d3m5r7t9w1y3b5c","caption":"Grand Prismatic Spring and Midway Geyser Basin from above","credit":"Brocken Inaglory","licence":"CC BY-SA 3.0","sourcePageUrl":"https://commons.wikimedia.org/wiki/File:Grand_Prismatic_Spring_and_Midway_Geyser_Basin_from_above.jpg","creditUrl":"http://commons.wikimedia.org/wiki/User:Brocken_Inaglory"},{"file":"yellowstone-cover","target":"blob"}]' \
  -F "overlook=@overlook.jpg" \
  -F "boardwalk=@boardwalk.jpg" \
  -F "yellowstone-cover=@yellowstone-cover.jpg"
```

Response 201:

```json
{
  "uploaded": 3,
  "failed": 0,
  "results": [
    {
      "file": "overlook",
      "ok": true,
      "mediaId": "kg71s3d5f7g9h1j3k5k7z9x1c3v5b7n9",
      "url": "https://quiet-heron-512.convex.cloud/api/storage/grand-prismatic-overlook.webp",
      "thumbUrl": "https://quiet-heron-512.convex.cloud/api/storage/grand-prismatic-overlook-640.webp",
      "ownerType": "spot",
      "ownerId": "k1709e2vj4sk8q6x0d3m5r7t9w1y3b5c",
      "caption": "Grand Prismatic Spring from the overlook, mid morning",
      "credit": "Mara Lindgren",
      "width": 2048,
      "height": 1365,
      "order": 1024,
      "contentType": "image/webp",
      "bytes": 684312,
      "thumbBytes": 61240,
      "originalBytes": 5412880,
      "optimized": true,
      "metadataStripped": true,
      "capturedOn": "2026-09-14"
    },
    {
      "file": "boardwalk",
      "ok": true,
      "mediaId": "kg7p2p4j6v8y0t2r4e6w8q0a2s4d6f8g",
      "url": "https://quiet-heron-512.convex.cloud/api/storage/grand-prismatic-aerial.webp",
      "thumbUrl": "https://quiet-heron-512.convex.cloud/api/storage/grand-prismatic-aerial-640.webp",
      "ownerType": "spot",
      "ownerId": "k1709e2vj4sk8q6x0d3m5r7t9w1y3b5c",
      "caption": "Grand Prismatic Spring and Midway Geyser Basin from above",
      "credit": "Brocken Inaglory",
      "width": 2048,
      "height": 1365,
      "order": 2048,
      "contentType": "image/webp",
      "bytes": 597004,
      "thumbBytes": 55872,
      "originalBytes": 2811264,
      "optimized": true,
      "metadataStripped": false,
      "capturedOn": null,
      "warnings": [
        "creditUrl uses http, not https: http://commons.wikimedia.org/wiki/User:Brocken_Inaglory"
      ]
    },
    {
      "file": "yellowstone-cover",
      "ok": true,
      "storageId": "kg2a8c4e0g6j2k8m4p0q6s2v8w4y0a6c",
      "contentType": "image/webp",
      "width": 2048,
      "height": 1152,
      "bytes": 402118,
      "originalBytes": 3170912,
      "optimized": true,
      "metadataStripped": true,
      "use": "Pass storageId as coverStorageId (update_chapter) or photoStorageId (set_chapter_intro sections)"
    }
  ]
}
```

### Each file succeeds or fails on its own: an oversized panorama is refused

```bash
curl -X POST "https://sceniq.earth/api/v1/media/upload-batch" \
  -H "Authorization: Bearer $SCENIQ_API_KEY" \
  -F 'manifest=[{"file":"tunnel-view","ownerType":"spot","ownerId":"k17bc3xz8h6f4d2s0q9w7e5r3t1y2v4j","caption":"Tunnel View after a storm cleared","credit":"Mara Lindgren"},{"file":"tunnel-view-panorama","ownerType":"spot","ownerId":"k17bc3xz8h6f4d2s0q9w7e5r3t1y2v4j","credit":"Mara Lindgren"}]' \
  -F "tunnel-view=@tunnel-view.jpg" \
  -F "tunnel-view-panorama=@tunnel-view-panorama.jpg"
```

Response 201:

```json
{
  "uploaded": 1,
  "failed": 1,
  "results": [
    {
      "file": "tunnel-view",
      "ok": true,
      "mediaId": "kg7m9n1b3v5c7x9z1k3k5j7h9g1f3d5s",
      "url": "https://quiet-heron-512.convex.cloud/api/storage/tunnel-view-after-storm.webp",
      "thumbUrl": "https://quiet-heron-512.convex.cloud/api/storage/tunnel-view-after-storm-640.webp",
      "ownerType": "spot",
      "ownerId": "k17bc3xz8h6f4d2s0q9w7e5r3t1y2v4j",
      "caption": "Tunnel View after a storm cleared",
      "credit": "Mara Lindgren",
      "width": 1365,
      "height": 2048,
      "order": 1024,
      "contentType": "image/webp",
      "bytes": 548770,
      "thumbBytes": 47316,
      "originalBytes": 4102555,
      "optimized": true,
      "metadataStripped": true,
      "capturedOn": "2026-06-12"
    },
    {
      "file": "tunnel-view-panorama",
      "ok": false,
      "error": {
        "code": "image_too_large",
        "message": "Images must be at most 6000 px per side (got 9120x2560); resize to 2048 px on the long side"
      }
    }
  ]
}
```

## Errors

- `invalid_argument` (400): A required field is missing, a field is unknown, or a value does not match its type (the Convex validator's path and value are in the message).
- `invalid_request` (400): A writer refused a value: a map link on a host that is not allowed, a title that is too long, a date that does not exist, too many items in a list. The message is the writer's own sentence.
- `unsupported_image` (400): An upload's bytes are not a JPEG, PNG or WebP file, or their dimensions cannot be read. The bytes are checked, not the Content-Type header.
- `image_too_large` (400): An upload is more than 6,000 px on a side.
- `unauthenticated` (401): The request carries no key. Send it as Authorization: Bearer sk_sceniq_... (or X-Api-Key).
- `invalid_api_key` (401): The key is unknown, revoked or expired.
- `api_scope_required` (403): A read-only key called a write operation, or a key without the publish option called publish_changes.
- `forbidden` (403): The key belongs to a creator account that is no longer active.
- `payload_too_large` (413): A JSON body over 2 MB, an image over 6 MB, a batch upload over 19 MB, or an MCP message over 2 MB.
- `rate_limited` (429): Over a limit: requests per key per minute, per creator per hour, uploads per key per minute, or check_links per guide per hour.
- `internal_error` (500): An unexpected failure. The message is hidden on purpose.

Reference: https://developers.sceniq.earth/reference/upload_media_batch
