Layers
Partner APIAPI referenceMedia

POST /v1/projects/:projectId/media/presign

Get a signed PUT URL for uploading media directly to Layers object storage.

View as Markdown
POST/v1/projects/:projectId/media/presign
Phase 1stable
Auth
Bearer
Scope
projects:write

Returns a short-lived signed URL you can PUT media to directly, without routing bytes through the Layers API. Use it for any file over 256KB — logos, reference images, uploaded video, audio. For smaller files, POST /v1/projects/:projectId/media takes base64 inline.

The signed URL points at Layers' managed object storage. After the PUT succeeds, call POST /v1/projects/:projectId/media/finalize with the uploadId to register the file in the media library — without finalize, the upload is orphaned and garbage-collected after 24 hours.

Path
  • projectId
    string (uuid)required
    Project ID.
Body
  • kind
    stringrequired
    Media class — determines allowed content types and downstream handling.
    One of: image, video, audio, logo, reference_image
  • filename
    stringoptional
    Original filename (1-256 chars). Optional — when provided, the storage key uses its extension.
  • mimeType
    stringrequired
    MIME type (e.g. `image/png`, `video/mp4`). Must match what your PUT sends. `contentType` also accepted for backwards compat.
  • sizeBytes
    integeroptional
    Declared file size in bytes. Optional — when provided, finalize verifies the PUT body matches this and presign returns the matching `Content-Length` header. `byteSize` also accepted.
  • checksumSha256
    string (64 hex)optional
    Optional sha256 to record on the asset metadata.

Example request

curl -X POST https://api.layers.com/v1/projects/{projectId}/media/presign \
  -H "X-Api-Key: $LAYERS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "reference_image",
    "filename": "founder-shot.jpg",
    "mimeType": "image/jpeg",
    "sizeBytes": 482112
  }'
const res = await fetch(
  `https://api.layers.com/v1/projects/${projectId}/media/presign`,
  {
    method: 'POST',
    headers: {
      'X-Api-Key': process.env.LAYERS_API_KEY!,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      kind: 'reference_image',
      filename: file.name,
      mimeType: file.type,
      sizeBytes: file.size,
    }),
  },
);
const { uploadId, url, headers } = await res.json();

await fetch(url, { method: 'PUT', body: file, headers });

const finalize = await fetch(
  `https://api.layers.com/v1/projects/${projectId}/media/finalize`,
  {
    method: 'POST',
    headers: {
      'X-Api-Key': process.env.LAYERS_API_KEY!,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ uploadId }),
  },
);
const { assetId } = await finalize.json();
import os, httpx

presign = httpx.post(
    f"https://api.layers.com/v1/projects/{project_id}/media/presign",
    headers={
        "X-Api-Key": os.environ["LAYERS_API_KEY"],
        "Content-Type": "application/json",
    },
    json={
        "kind": "reference_image",
        "filename": "founder-shot.jpg",
        "mimeType": "image/jpeg",
        "sizeBytes": len(body),
    },
).json()

httpx.put(presign["url"], content=body, headers=presign["headers"])

finalize = httpx.post(
    f"https://api.layers.com/v1/projects/{project_id}/media/finalize",
    headers={
        "X-Api-Key": os.environ["LAYERS_API_KEY"],
        "Content-Type": "application/json",
    },
    json={"uploadId": presign["uploadId"]},
).json()

Response

200Presigned upload handle. `url` and `signedUrl` are aliases — use either.
{
  "uploadId": "upl_01HXA3KMNP4RSTUVWXYZABCDEF",
  "url": "https://r2.layers.com/uploads/.../upl_01HXA3.../founder-shot.jpg?X-Amz-...",
  "signedUrl": "https://r2.layers.com/uploads/.../upl_01HXA3.../founder-shot.jpg?X-Amz-...",
  "method": "PUT",
  "headers": {
    "Content-Type": "image/jpeg",
    "Content-Length": "482112"
  },
  "expiresAt": "2026-04-18T19:40:42Z"
}

Notes

  • url and signedUrl are the same value. Both keys are returned so older SDKs (which expected signedUrl) continue to work. Prefer url going forward.
  • expiresAt is the signed URL expiry (~15 min). The uploadId itself survives 24 hours until garbage collection, but the PUT must happen within the signed-URL window.
  • Size caps are per-kind. image/logo/reference_image: 50 MB. audio: 200 MB. video: 1 GB. Exceeding the cap is a 413 PAYLOAD_TOO_LARGE.

Errors

StatusCodeWhen
401UNAUTHENTICATEDMissing or invalid key.
403FORBIDDEN_SCOPEKey lacks projects:write.
404NOT_FOUNDProject does not exist.
413PAYLOAD_TOO_LARGEsizeBytes exceeds the per-kind limit.
422VALIDATION_FAILEDUnknown kind, missing mimeType, bad sizeBytes, etc.
429RATE_LIMITEDWrite budget exhausted.

See also

On this page