Skip to content

Create a workspace page attachment upload ​

POST/api/v2/workspaces/{slug}/pages/{page_id}/attachments/

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_data holds the storage url and the form fields to send with the file.
  • asset_id is the new attachment's id. Use it as {pk} when you confirm, read, or delete.
  • asset_url is the attachment's relative asset path.
  • attachment is 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:requiredstring

The 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:requiredstring

The file name, for example architecture-diagram.png. It becomes the attachment's name, and the download uses it as the saved file name.

size:requiredinteger

The 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:optionalstring

The 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:optionalstring

Comma-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 ​

StatusCodeCause
400invalid_requestname or size is missing, size is below 1, the type isn't an allowed file type, or the page is locked or archived.
401unauthorizedMissing or invalid credentials.
402payment_requiredThe feature this endpoint belongs to isn't enabled on your plan, or is switched off.
403forbiddenYour role or token scope can't edit this page, or the page is private and you lack Edit access to it.
404not_foundNo such wiki page in this workspace, or you can't see it.
406not_acceptableThe Accept header asks for a representation the API can't produce.
409conflictAll write endpoints declare this status. This endpoint has no conflict case of its own.
413payload_too_largeThe request body is over the size limit.
415unsupported_media_typeThe Content-Type isn't one this endpoint accepts.
429rate_limitedThrottled. Honor the Retry-After header before retrying.
Create a workspace page attachment upload
bash
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
}'
Response200
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"
  }
}
Response400
json
{
  "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.

bash
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"
python
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.