POST /v1/projects/:projectId/media/presign
Get a signed PUT URL for uploading media directly to Layers object storage.
POST
/v1/projects/:projectId/media/presignPhase 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
projectIdstring (uuid)requiredProject ID.
Body
kindstringrequiredMedia class — determines allowed content types and downstream handling.One of:image,video,audio,logo,reference_imagefilenamestringoptionalOriginal filename (1-256 chars). Optional — when provided, the storage key uses its extension.mimeTypestringrequiredMIME type (e.g. `image/png`, `video/mp4`). Must match what your PUT sends. `contentType` also accepted for backwards compat.sizeBytesintegeroptionalDeclared 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.checksumSha256string (64 hex)optionalOptional 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
urlandsignedUrlare the same value. Both keys are returned so older SDKs (which expectedsignedUrl) continue to work. Preferurlgoing forward.expiresAtis the signed URL expiry (~15 min). TheuploadIditself 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 a413 PAYLOAD_TOO_LARGE.
Errors
| Status | Code | When |
|---|---|---|
| 401 | UNAUTHENTICATED | Missing or invalid key. |
| 403 | FORBIDDEN_SCOPE | Key lacks projects:write. |
| 404 | NOT_FOUND | Project does not exist. |
| 413 | PAYLOAD_TOO_LARGE | sizeBytes exceeds the per-kind limit. |
| 422 | VALIDATION_FAILED | Unknown kind, missing mimeType, bad sizeBytes, etc. |
| 429 | RATE_LIMITED | Write budget exhausted. |
See also
POST /v1/projects/:projectId/media/finalize— register the uploaded filePOST /v1/projects/:projectId/media— inline upload for files under 256KB