Download a workspace page attachment
Get the file behind an attachment. The API doesn't stream the bytes. It answers 302 Found with a Location header that points at a signed URL in object storage, and your client fetches the file from there. This is the one endpoint in the attachment resource that doesn't return JSON on success.
The signed URL is short-lived. It expires after the number of seconds in the instance's SIGNED_URL_EXPIRATION setting, 3600 by default. Request a fresh redirect each time you need the file instead of storing the URL. Storage serves the file as a download, with the attachment's name as the file name. On a self-hosted instance with USE_STORAGE_PROXY turned on, the URL points at the Plane server's download proxy instead of the storage host.
The attachment must be confirmed. An attachment whose upload you haven't confirmed returns 400 invalid_request with detail The attachment has not been uploaded.
Downloads work on locked and archived pages. You need read access to the page, the same as for Get a workspace page attachment.
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 the attachment belongs to.
pk:requiredstring (uuid)The id of the attachment to download.
Scopes
wiki.pages:read
Errors
| Status | Code | Cause |
|---|---|---|
400 | invalid_request | The attachment's upload hasn't been confirmed yet. |
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 read this page. |
404 | not_found | No such wiki page or attachment, the attachment is on another page, or it was deleted. |
406 | not_acceptable | The Accept header asks for a representation the API can't produce. |
429 | rate_limited | Throttled. Honor the Retry-After header before retrying. |
Follow the redirect without your API key
Most HTTP clients follow a 302 automatically. The signed URL carries its own authorization, so storage doesn't need your Plane credentials. The examples read the Location header instead of following it, so the X-Api-Key header never reaches storage.
curl -s -o /dev/null -w "%{redirect_url}\n" \
"https://api.plane.so/api/v2/workspaces/my-team/pages/9c1f0b3d-6d2e-4c9a-8b41-2f7e5a0d6c88/attachments/8f4c2b1e-0d3a-4f7b-9c21-6e5a8b7d4f13/download/" \
-H "X-Api-Key: $PLANE_API_KEY"import requests
response = requests.get(
"https://api.plane.so/api/v2/workspaces/my-team/pages/9c1f0b3d-6d2e-4c9a-8b41-2f7e5a0d6c88"
"/attachments/8f4c2b1e-0d3a-4f7b-9c21-6e5a8b7d4f13/download/",
headers={"X-Api-Key": "your-api-key"},
allow_redirects=False,
)
signed_url = response.headers["Location"]
file_bytes = requests.get(signed_url).content
with open("architecture-diagram.png", "wb") as handle:
handle.write(file_bytes)const response = await fetch(
"https://api.plane.so/api/v2/workspaces/my-team/pages/9c1f0b3d-6d2e-4c9a-8b41-2f7e5a0d6c88" +
"/attachments/8f4c2b1e-0d3a-4f7b-9c21-6e5a8b7d4f13/download/",
{
headers: {
"X-Api-Key": "your-api-key",
},
redirect: "manual",
},
);
const signedUrl = response.headers.get("location");
const file = await fetch(signedUrl);
const bytes = await file.arrayBuffer();HTTP/1.1 302 Found
Location: https://plane-uploads.s3.amazonaws.com/a3d1c5e7-2b4f-4a69-8c0d-7e5f1b3a9d24/5e8b1f0c9d2a4b7e8f3c6a1d0b9e2f47-architecture-diagram.png?response-content-disposition=attachment%3B%20filename%3D%22architecture-diagram.png%22&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=3600&X-Amz-Signature=...{
"type": "invalid_request",
"code": "invalid_request",
"detail": "The attachment has not been uploaded."
}
