Create a workspace page attachment upload
Start an upload. This is the first of three steps. Send the file's metadata, not the file. The API creates the attachment with is_uploaded: false and returns a signed storage credential in upload_data. You then upload the file straight to storage and confirm the upload. See Upload a file in three steps.
A successful call returns 200, not 201, and the body is an envelope rather than the bare attachment:
upload_dataholds the storageurland the formfieldsto send with the file.asset_idis the new attachment's id. Use it as{pk}when you confirm, read, or delete.asset_urlis the attachment's relative asset path.attachmentis the attachment object.
The page must be active and unlocked, and you need edit access to it. See Locked and archived pages.
Path Parameters
slug:requiredstringThe workspace slug. It appears in your Plane URLs. In https://app.plane.so/my-team/projects/, the slug is my-team.
page_id:requiredstring (uuid)The wiki page to attach the file to. A project page returns 404.
Body Parameters
name:requiredstringThe file name, for example architecture-diagram.png. It becomes the attachment's name, and the download uses it as the saved file name.
size:requiredintegerThe file size in bytes. Minimum 1. The attachment stores the smaller of this value and your plan's file size limit, and storage refuses an upload larger than the stored value.
type:optionalstringThe file's MIME type, for example image/png or application/pdf. Defaults to application/octet-stream when you omit it or send an empty string. A type outside the allowed attachment types returns 400 with detail Invalid file type. The signed credential accepts only an upload with this exact Content-Type.
Response shaping
fields:optionalstringComma-separated list of fields to return on the attachment object in the envelope. upload_data, asset_id, and asset_url always come back. Unrequested keys are omitted, not returned as null. id always comes back whether or not you name it.
Pass all for every requestable field. An unknown name is a 400 that lists the valid set and suggests the closest match.
Requestable here: asset_url, attributes, content_type, created_at, created_by_id, id, is_uploaded, name, page_id, size, updated_at, updated_by_id.
See Sparse fields.
Scopes
wiki.pages:write
Errors
| Status | Code | Cause |
|---|---|---|
400 | invalid_request | name or size is missing, size is below 1, the type isn't an allowed file type, or the page is locked or archived. |
401 | unauthorized | Missing or invalid credentials. |
402 | payment_required | The feature this endpoint belongs to isn't enabled on your plan, or is switched off. |
403 | forbidden | Your role or token scope can't edit this page, or the page is private and you lack Edit access to it. |
404 | not_found | No such wiki page in this workspace, or you can't see it. |
406 | not_acceptable | The Accept header asks for a representation the API can't produce. |
409 | conflict | All write endpoints declare this status. This endpoint has no conflict case of its own. |
413 | payload_too_large | The request body is over the size limit. |
415 | unsupported_media_type | The Content-Type isn't one this endpoint accepts. |
429 | rate_limited | Throttled. Honor the Retry-After header before retrying. |
curl -X POST \
"https://api.plane.so/api/v2/workspaces/my-team/pages/9c1f0b3d-6d2e-4c9a-8b41-2f7e5a0d6c88/attachments/" \
-H "X-Api-Key: $PLANE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "architecture-diagram.png",
"type": "image/png",
"size": 482133
}'import requests
response = requests.post(
"https://api.plane.so/api/v2/workspaces/my-team/pages/9c1f0b3d-6d2e-4c9a-8b41-2f7e5a0d6c88/attachments/",
headers={"X-Api-Key": "your-api-key"},
json={
"name": "architecture-diagram.png",
"type": "image/png",
"size": 482133,
},
)
upload = response.json()
print(upload["asset_id"])const response = await fetch(
"https://api.plane.so/api/v2/workspaces/my-team/pages/9c1f0b3d-6d2e-4c9a-8b41-2f7e5a0d6c88/attachments/",
{
method: "POST",
headers: {
"X-Api-Key": "your-api-key",
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "architecture-diagram.png",
type: "image/png",
size: 482133,
}),
},
);
const upload = await response.json();{
"upload_data": {
"url": "https://plane-uploads.s3.amazonaws.com/",
"fields": {
"Content-Type": "image/png",
"key": "a3d1c5e7-2b4f-4a69-8c0d-7e5f1b3a9d24/5e8b1f0c9d2a4b7e8f3c6a1d0b9e2f47-architecture-diagram.png",
"x-amz-algorithm": "AWS4-HMAC-SHA256",
"x-amz-credential": "AKIAIOSFODNN7EXAMPLE/20260114/us-east-1/s3/aws4_request",
"x-amz-date": "20260114T092241Z",
"policy": "eyJleHBpcmF0aW9uIjoiMjAyNi0wMS0xNFQxMDoyMjo0MVoifQ==",
"x-amz-signature": "6f1a9c2e4b7d0835a1e9f2c6b4d8a0e3f5c7b9d1a2e4f6081c3b5d7e9f0a2c4b"
}
},
"asset_id": "8f4c2b1e-0d3a-4f7b-9c21-6e5a8b7d4f13",
"asset_url": "/api/assets/v2/workspaces/my-team/projects/None/8f4c2b1e-0d3a-4f7b-9c21-6e5a8b7d4f13/",
"attachment": {
"id": "8f4c2b1e-0d3a-4f7b-9c21-6e5a8b7d4f13",
"page_id": "9c1f0b3d-6d2e-4c9a-8b41-2f7e5a0d6c88",
"name": "architecture-diagram.png",
"content_type": "image/png",
"size": 482133.0,
"attributes": {
"name": "architecture-diagram.png",
"type": "image/png",
"size": 482133
},
"asset_url": "/api/assets/v2/workspaces/my-team/projects/None/8f4c2b1e-0d3a-4f7b-9c21-6e5a8b7d4f13/",
"is_uploaded": false,
"created_at": "2026-01-14T09:22:41.478363Z",
"updated_at": "2026-01-14T09:22:41.478363Z",
"created_by_id": "16c61a3a-512a-48ac-b0be-b6b46fe6f430",
"updated_by_id": "16c61a3a-512a-48ac-b0be-b6b46fe6f430"
}
}{
"type": "invalid_request",
"code": "invalid_request",
"detail": "Page is locked."
}Upload the file to storage
Step 2 doesn't touch the Plane API. Send a multipart/form-data POST to upload_data.url. Copy every key and value from upload_data.fields into the form unchanged, and add the file last, in a field named file. Don't send your Plane API key to storage.
curl -X POST "https://plane-uploads.s3.amazonaws.com/" \
-F "Content-Type=image/png" \
-F "key=a3d1c5e7-2b4f-4a69-8c0d-7e5f1b3a9d24/5e8b1f0c9d2a4b7e8f3c6a1d0b9e2f47-architecture-diagram.png" \
-F "x-amz-algorithm=AWS4-HMAC-SHA256" \
-F "x-amz-credential=AKIAIOSFODNN7EXAMPLE/20260114/us-east-1/s3/aws4_request" \
-F "x-amz-date=20260114T092241Z" \
-F "policy=eyJleHBpcmF0aW9uIjoiMjAyNi0wMS0xNFQxMDoyMjo0MVoifQ==" \
-F "x-amz-signature=6f1a9c2e4b7d0835a1e9f2c6b4d8a0e3f5c7b9d1a2e4f6081c3b5d7e9f0a2c4b" \
-F "file=@architecture-diagram.png"import requests
with open("architecture-diagram.png", "rb") as handle:
storage = requests.post(
upload["upload_data"]["url"],
data=upload["upload_data"]["fields"],
files={"file": ("architecture-diagram.png", handle, "image/png")},
)
storage.raise_for_status()The credential is signed for one object key and expires. If storage refuses the upload, request a new credential with another create call. After the upload succeeds, confirm the upload with asset_id.

