# upload_media

Upload a photo (raw bytes or multipart).

`POST /v1/media/upload` · REST only · scope `write` · stable · since 2026-09-17

Send the image as the raw body (Content-Type image/jpeg, image/png or image/webp) with the fields on the query string, or as multipart/form-data with a file field plus the same fields. The server checks the real file type, applies the EXIF rotation, scales the long side to 2048 px, stores WebP without EXIF, GPS, XMP or IPTC metadata (the capture date is kept as capturedOn) and makes a 640 px thumbnail for media rows. At most 6 MB and 6000 px per side: resize and compress first. target=blob returns a storageId for chapter covers and intro photos instead of a media row. Upload each photo once; update_media changes its fields later.

## Query parameters

- `ownerType` (string, optional): What the photo belongs to: a spot, the guide (store thumbnail and sales page material), a chapter, or the sales page. Required unless target is blob. One of `spot`, `product`, `collection`, `listing`.
- `ownerId` (string, optional): Id of the owner row (spotId, productId, collectionId or listingId). Required unless target is blob.
- `target` (string, optional): media (the default) registers a photo; blob stores the file and returns a storageId for coverStorageId (update_chapter) or photoStorageId (set_chapter_intro). One of `media`, `blob`.
- `caption` (string, optional): Caption buyers read under the photo.
- `credit` (string, optional): Photographer credit, rendered as Photo: Name. Every photo that is not the creator's own carries one.
- `alt` (string, optional): Alt text: what the photo shows, for screen readers.
- `focalX` (number, optional): Focal point 0 (left) to 1 (right); the apps keep it in frame when they crop.
- `focalY` (number, optional): Focal point 0 (top) to 1 (bottom).
- `sourcePageUrl` (string, optional): The page the photo was licensed from (not the image file).
- `licence` (string, optional): Licence name (Unsplash License, CC BY 4.0).
- `creditUrl` (string, optional): The photographer's profile page.
- `capturedOn` (string, optional): Capture date (YYYY-MM-DD). Read from the file's metadata when absent.
- `locationVerifiedBy` (string, optional): How the photo's location was confirmed. One of `geotag`, `landmark`, `photographer_caption`, `creator`, `other`.
- `reviewNotes` (string, optional): Notes for the creator's review; never shown to buyers.
- `meta` (object, optional): A JSON object with any of the fields above plus shows ({ kind, label? }) and takenAt ({ lat, lon, precision }). On the query string it is JSON text.

## Form fields

- `file` (string, optional): Multipart only: the image file.

## Returns

201: UploadedPhoto or UploadedBlob.

## Examples

### Upload a spot photo as the raw body, fields on the query string

```bash
curl -X POST "https://sceniq.earth/api/v1/media/upload?ownerType=spot&ownerId=k1709e2vj4sk8q6x0d3m5r7t9w1y3b5c&caption=Grand%20Prismatic%20Spring%20from%20the%20overlook%2C%20mid%20morning&credit=Mara%20Lindgren&alt=A%20deep%20blue%20pool%20ringed%20with%20orange%20and%20yellow%2C%20steam%20drifting%20over%20the%20boardwalk%20below&focalX=0.52&focalY=0.46" \
  -H "Authorization: Bearer $SCENIQ_API_KEY" \
  -H "Content-Type: image/jpeg" \
  --data-binary @grand-prismatic-overlook.jpg
```

Response 201:

```json
{
  "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",
  "order": 1024,
  "contentType": "image/webp",
  "width": 2048,
  "height": 1365,
  "bytes": 684312,
  "originalBytes": 5412880,
  "optimized": true,
  "metadataStripped": true,
  "thumbBytes": 61240,
  "capturedOn": "2026-09-14"
}
```

### Structured fields (shows, takenAt) travel in meta as JSON text

```bash
curl -X POST "https://sceniq.earth/api/v1/media/upload?ownerType=spot&ownerId=k17bc3xz8h6f4d2s0q9w7e5r3t1y2v4j&credit=Mara%20Lindgren&meta=%7B%22shows%22%3A%7B%22kind%22%3A%22view_from_spot%22%7D%2C%22takenAt%22%3A%7B%22lat%22%3A37.7156%2C%22lon%22%3A-119.677%2C%22precision%22%3A%22gps%22%7D%7D" \
  -H "Authorization: Bearer $SCENIQ_API_KEY" \
  -H "Content-Type: image/jpeg" \
  --data-binary @tunnel-view.jpg
```

Response 201:

```json
{
  "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": null,
  "credit": "Mara Lindgren",
  "order": 1024,
  "contentType": "image/webp",
  "width": 1365,
  "height": 2048,
  "bytes": 548770,
  "originalBytes": 4102555,
  "optimized": true,
  "metadataStripped": true,
  "thumbBytes": 47316,
  "capturedOn": "2026-06-12"
}
```

### Store a chapter cover (target=blob returns a storageId, no media row)

```bash
curl -X POST "https://sceniq.earth/api/v1/media/upload?target=blob" \
  -H "Authorization: Bearer $SCENIQ_API_KEY" \
  -H "Content-Type: image/jpeg" \
  --data-binary @yellowstone-cover.jpg
```

Response 201:

```json
{
  "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)"
}
```

## 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.
- `not_found` (404): The id does not exist or belongs to another creator. The two answers are identical on purpose, so ids never reveal what exists.
- `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
