upload_media

Upload a photo (raw bytes or multipart).

POST/v1/media/upload
REST onlyScope: writeStableSince 2026-09-17
Markdown

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.

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

Query parameters

  • ownerTypestringoptional

    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.

    spotproductcollectionlisting
  • ownerIdstringoptional

    Id of the owner row (spotId, productId, collectionId or listingId). Required unless target is blob.

  • targetstringoptional

    media (the default) registers a photo; blob stores the file and returns a storageId for coverStorageId (update_chapter) or photoStorageId (set_chapter_intro).

    mediablob
  • captionstringoptional

    Caption buyers read under the photo.

  • creditstringoptional

    Photographer credit, rendered as Photo: Name. Every photo that is not the creator's own carries one.

  • altstringoptional

    Alt text: what the photo shows, for screen readers.

  • focalXnumberoptional

    Focal point 0 (left) to 1 (right); the apps keep it in frame when they crop.

  • focalYnumberoptional

    Focal point 0 (top) to 1 (bottom).

  • sourcePageUrlstringoptional

    The page the photo was licensed from (not the image file).

  • licencestringoptional

    Licence name (Unsplash License, CC BY 4.0).

  • creditUrlstringoptional

    The photographer's profile page.

  • capturedOnstringoptional

    Capture date (YYYY-MM-DD). Read from the file's metadata when absent.

  • locationVerifiedBystringoptional

    How the photo's location was confirmed.

    geotaglandmarkphotographer_captioncreatorother
  • reviewNotesstringoptional

    Notes for the creator's review; never shown to buyers.

  • metaobjectoptional

    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

  • filestringoptional

    Multipart only: the image file.

Returns

201 Created with UploadedPhoto or UploadedBlob.

Response 201
{
  "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"
}

Errors

Every error is { error: { code, message } }. Act on the code.

  • 400invalid_argumentA 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).
  • 400invalid_requestA 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.
  • 400unsupported_imageAn 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.
  • 400image_too_largeAn upload is more than 6,000 px on a side.
  • 401unauthenticatedThe request carries no key. Send it as Authorization: Bearer sk_sceniq_... (or X-Api-Key).
  • 401invalid_api_keyThe key is unknown, revoked or expired.
  • 403api_scope_requiredA read-only key called a write operation, or a key without the publish option called publish_changes.
  • 403forbiddenThe key belongs to a creator account that is no longer active.
  • 404not_foundThe id does not exist or belongs to another creator. The two answers are identical on purpose, so ids never reveal what exists.
  • 413payload_too_largeA JSON body over 2 MB, an image over 6 MB, a batch upload over 19 MB, or an MCP message over 2 MB.
  • 429rate_limitedOver a limit: requests per key per minute, per creator per hour, uploads per key per minute, or check_links per guide per hour.
  • 500internal_errorAn unexpected failure. The message is hidden on purpose.