Adjuntos

Los ficheros de una tarea. Se suben en dos pasos y se descargan con enlaces que caducan: el almacenamiento nunca es público.

La subida, en dos pasos

Los bytes no pasan por la API. Primero se pide una URL firmada, luego el fichero se sube directamente al almacenamiento con un PUT, y por último se registra en la tarea. Es lo que permite subir 25.0 MB sin que nada intermedio tenga que sostener el fichero en memoria.

Ejemplo
TASK=9b2f6c3a-…
FILE=informe.pdf

# 1. Pedir la URL de subida.
TARGET=$(curl -s -X POST "$PLANELY_URL/api/v1/tasks/$TASK/attachments/upload-url" \
  -H "Authorization: Bearer $PLANELY_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"name\": \"$FILE\",
    \"contentType\": \"application/pdf\",
    \"size\": $(stat -c%s "$FILE")
  }")

# 2. Subir el fichero. El Content-Type debe ser EXACTAMENTE el declarado:
#    forma parte de lo que se firmó.
curl -X PUT "$(echo "$TARGET" | jq -r .uploadUrl)" \
  -H "Content-Type: application/pdf" \
  --data-binary "@$FILE"

# 3. Registrarlo en la tarea.
curl -X POST "$PLANELY_URL/api/v1/tasks/$TASK/attachments" \
  -H "Authorization: Bearer $PLANELY_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"key\": $(echo "$TARGET" | jq .key),
    \"name\": \"$FILE\",
    \"contentType\": \"application/pdf\",
    \"size\": $(stat -c%s "$FILE")
  }"

Si te quedas a medias no pasa nada grave: un objeto subido y no registrado no aparece en ningún sitio. Lo que no se puede hacer es registrar primero y subir después — el fichero constaría en la tarea sin existir.

La key la elige el servidor

upload-url devuelve la ruta con la que se guardará el objeto. No es un detalle decorativo: al registrarlo se comprueba que esa key esté bajo el prefijo de esa tarea concreta, y cualquier otra cosa responde 403. Devuélvela tal cual, sin retocarla.

Lo que sí es tu palabra son size y contentType: nadie los verifica contra el fichero subido. Comprobarlos costaría una consulta al almacenamiento en cada subida para evitar algo que ya puede hacer cualquiera con permiso de escritura en el proyecto.

Límites y ausencias

  • 25.0 MB por fichero. Pasarse es un 400 en el primer paso, antes de firmar nada.
  • Sin almacenamiento no hay adjuntos. Un despliegue sin almacenamiento configurado responde 503 storage_unavailable en vez de fallar de una forma menos clara. El listado sigue funcionando y devuelve lo que hubiera.
  • No se pueden borrar por API. Eliminar un fichero es cosa de quien lo subió o del propietario del proyecto, y una key no es ninguna de las dos: no tiene una persona detrás. Es la misma razón por la que tampoco se moderan comentarios desde aquí.

Eliminar la tarea definitivamente (ver Papelera) sí se lleva sus ficheros por delante.

Listar los ficheros de una tarea

GET/api/v1/tasks/{taskId}/attachmentsLectura (cualquier key)

Del más antiguo al más nuevo.

Request
curl https://planely.dev/api/v1/tasks/9b2f6c3a-…/attachments \
  -H "Authorization: Bearer $PLANELY_API_KEY"
200 OK
{
  "attachments": [
    {
      "id": "b71c…",
      "taskId": "9b2f…",
      "name": "informe.pdf",
      "contentType": "application/pdf",
      "size": 284119,
      "uploadedByName": "Marta",
      "createdAt": "2026-07-25T12:02:10.000Z"
    }
  ]
}

Pedir una URL de subida

POST/api/v1/tasks/{taskId}/attachments/upload-urlPermiso: Editar tareas

Paso 1 de 2. Autoriza la subida y devuelve una URL firmada para hacer un PUT directo al almacenamiento, además de la key que habrá que devolver en el paso 2.

Cuerpo (JSON)

  • namestringobligatorio

    Nombre del fichero, hasta 200 caracteres

  • contentTypestringobligatorio

    El PUT debe enviar exactamente este Content-Type: forma parte de lo que se firma

  • sizeintegerobligatorio

    Tamaño en bytes, hasta 25 MB

  • La key la construye el servidor a partir del proyecto y la tarea. No se puede elegir: en el paso 2 se comprueba que esté bajo el prefijo de esa tarea.
  • Si el despliegue no tiene almacenamiento configurado, responde 503 storage_unavailable.
Request
curl -X POST https://planely.dev/api/v1/tasks/9b2f6c3a-…/attachments/upload-url \
  -H "Authorization: Bearer $PLANELY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "informe.pdf",
    "contentType": "application/pdf",
    "size": 284119
  }'
200 OK
{
  "uploadUrl": "https://…r2.cloudflarestorage.com/…&X-Amz-Signature=…",
  "key": "projects/1f8e…/tasks/9b2f…/7c1a…-informe.pdf",
  "expiresIn": 600,
  "contentType": "application/pdf"
}

Registrar el fichero subido

POST/api/v1/tasks/{taskId}/attachmentsPermiso: Editar tareas

Paso 2 de 2, después de que el PUT haya terminado. Deja el fichero visible en la tarea y anota la subida en su historial.

Cuerpo (JSON)

  • keystringobligatorio

    La devuelta por upload-url, sin modificar

  • namestringobligatorio
  • contentTypestringobligatorio
  • sizeintegerobligatorio

    Hasta 25 MB

  • Una key que no esté bajo el prefijo de esta tarea responde 403: es lo que impide registrar el fichero de otro.
  • Nadie comprueba que size y contentType coincidan con lo subido de verdad — es la palabra de quien sube, dentro de un proyecto en el que ya puede escribir.
Request
curl -X POST https://planely.dev/api/v1/tasks/9b2f6c3a-…/attachments \
  -H "Authorization: Bearer $PLANELY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "key": "projects/1f8e…/tasks/9b2f…/7c1a…-informe.pdf",
    "name": "informe.pdf",
    "contentType": "application/pdf",
    "size": 284119
  }'
201 Created
{ "attachment": { "id": "b71c…", "name": "informe.pdf",} }

Descargar un fichero

GET/api/v1/attachments/{attachmentId}Lectura (cualquier key)

Devuelve un enlace firmado de 60 segundos. El almacenamiento es privado: no hay URL permanente que compartir.

  • Devuelve el enlace en el cuerpo en vez de redirigir: una redirección a otro dominio deja en manos de cada cliente HTTP qué pasa con la cabecera Authorization.
  • Pide el enlace justo antes de usarlo; no tiene sentido guardarlo.
Request
curl https://planely.dev/api/v1/attachments/b71c… \
  -H "Authorization: Bearer $PLANELY_API_KEY"
200 OK
{
  "url": "https://…r2.cloudflarestorage.com/…&X-Amz-Signature=…",
  "expiresIn": 60
}