DEV Community

Cover image for Uploading Attachments to Jira Service Management Cloud Assets Without Public API Documentation
Mihai Perdum
Mihai Perdum

Posted on Originally published at leanzero.net

Uploading Attachments to Jira Service Management Cloud Assets Without Public API Documentation

Uploading Attachments to Jira Service Management Cloud Assets Without Public API Documentation

The Three-Step Process Overview

Attachment uploads to Jira Assets require three separate API calls. You cannot simply POST a file to an object. The process works as follows:

  1. Get upload credentials from the Assets API
  2. Upload the file to Atlassian's media service using those credentials
  3. Link the uploaded file to the specific Assets object

This three-step approach exists because the media service and Assets service are separate systems. The media service handles the file storage, while the Assets service manages the metadata and associations.

Step 1: Getting Upload Credentials

The first step is to request upload credentials for a specific Assets object. This tells the system you intend to upload something and provides you with temporary access tokens.

async getUploadCredentials(objectId) {
  const url = `https://api.atlassian.com/jsm/assets/workspace/${this.workspaceId}/v1/attachments/object/${objectId}/credentials`;

  return new Promise((resolve, reject) => {
    const options = {
      method: "GET",
      headers: {
        Authorization: `Basic ${this.apiToken}`,
        Accept: "application/json",
        "Content-Type": "application/json",
      },
    };

    const req = https.request(url, options, (res) => {
      let data = "";

      res.on("data", (chunk) => {
        data += chunk;
      });

      res.on("end", () => {
        if (res.statusCode === 200) {
          try {
            const credentials = JSON.parse(data);
            resolve(credentials);
          } catch (error) {
            reject(
              new Error(
                `Failed to parse credentials response: ${error.message}`,
              ),
            );
          }
        } else {
          reject(
            new Error(
              `Failed to get upload credentials: HTTP ${res.statusCode} - ${data}`,
            ),
          );
        }
      });
    });

    req.on("error", (error) => {
      reject(new Error(`Request failed: ${error.message}`));
    });

    req.end();
  });
}

Enter fullscreen mode Exit fullscreen mode

The response from this endpoint provides three critical pieces of information:

  • clientId: An identifier for the client making the upload
  • mediaBaseUrl: The base URL for the media service where files are stored
  • mediaJwtToken: A JWT token for authenticating with the media service

This endpoint uses Basic authentication (your email and API token), not the JWT token. The JWT token is only used when uploading to the media service in step 2.

Step 2: Uploading to Media Service

Once you have credentials, you upload the actual file to Atlassian's media service. This service handles file storage separately from Assets.

async uploadFileToMediaService(filePath, filename, credentials) {
  const { clientId, mediaBaseUrl, mediaJwtToken } = credentials;

  return new Promise((resolve, reject) => {
    // Check if file exists and get its stats
    if (!fs.existsSync(filePath)) {
      reject(new Error(`File not found: ${filePath}`));
      return;
    }

    const fileStats = fs.statSync(filePath);

    // Check file size
    if (fileStats.size > this.config.maxFileSize) {
      reject(
        new Error(
          `File too large: ${fileStats.size} bytes (max: ${this.config.maxFileSize} bytes)`,
        ),
      );
      return;
    }

    const encodedFilename = encodeURIComponent(filename);
    // Fixed endpoint: Atlassian changed from /file/binary to /file
    const uploadUrl = `${mediaBaseUrl}/file?name=${encodedFilename}`;

    const form = new FormData();
    form.append("file", fs.createReadStream(filePath));

    const options = {
      method: "POST",
      headers: {
        "X-Client-Id": clientId,
        Authorization: `Bearer ${mediaJwtToken}`,
        ...form.getHeaders(),
      },
    };

    const parsedUrl = new URL(uploadUrl);
    const isHttps = parsedUrl.protocol === "https:";
    const requestModule = isHttps ? https : http;

    const req = requestModule.request(uploadUrl, options, (res) => {
      let data = "";

      res.on("data", (chunk) => {
        data += chunk;
      });

      res.on("end", () => {
        if (res.statusCode === 200 || res.statusCode === 201) {
          try {
            const response = JSON.parse(data);
            resolve({
              mediaId: response.data?.id || response.id,
              mediaSize: response.data?.size || response.size || fileStats.size,
            });
          } catch (error) {
            reject(
              new Error(`Failed to parse upload response: ${error.message}`),
            );
          }
        } else {
          reject(
            new Error(
              `Failed to upload file: HTTP ${res.statusCode} - ${data}`,
            ),
          );
        }
      });
    });

    req.on("error", (error) => {
      reject(new Error(`Upload request failed: ${error.message}`));
    });

    form.pipe(req);
  });
}

Enter fullscreen mode Exit fullscreen mode

Key aspects of this step:

  • Uses the JWT token for authentication, not Basic auth
  • Uploads as multipart/form-data with a field named file
  • Returns a mediaId that identifies the uploaded file
  • The endpoint is {mediaBaseUrl}/file?name={encodedFilename} - note the change from /file/binary to /file

The response contains the mediaId which is the reference you'll need in the final step.

Step 3: Linking to Asset Object

The final step is to tell Assets that a specific file should be associated with a specific object. This creates the relationship between the uploaded file and the Assets object.

async linkAttachmentToObject(objectId, attachmentData) {
  const url = `https://api.atlassian.com/jsm/assets/workspace/${this.workspaceId}/v1/attachments/object/${objectId}`;

  const payload = {
    attachments: [attachmentData],
  };

  return new Promise((resolve, reject) => {
    const postData = JSON.stringify(payload);

    const options = {
      method: "POST",
      headers: {
        Authorization: `Basic ${this.apiToken}`,
        Accept: "application/json",
        "Content-Type": "application/json",
        "Content-Length": Buffer.byteLength(postData),
      },
    };

    const req = https.request(url, options, (res) => {
      let data = "";

      res.on("data", (chunk) => {
        data += chunk;
      });

      res.on("end", () => {
        if (res.statusCode === 200 || res.statusCode === 201) {
          try {
            const response = JSON.parse(data);
            resolve(response);
          } catch (error) {
            reject(
              new Error(`Failed to parse link response: ${error.message}`),
            );
          }
        } else {
          reject(
            new Error(
              `Failed to link attachment: HTTP ${res.statusCode} - ${data}`,
            ),
          );
        }
      });
    });

    req.on("error", (error) => {
      reject(new Error(`Link request failed: ${error.message}`));
    });

    req.write(postData);
    req.end();
  });
}

Enter fullscreen mode Exit fullscreen mode

The attachmentData object must include:

const attachmentData = {
  contentType: "application/pdf", // MIME type of the file
  filename: "document.pdf", // Original filename
  mediaId: "abc123xyz", // The mediaId returned from step 2
  size: 1234567, // File size in bytes
  comment: "Optional comment" // Optional attachment comment
};

Enter fullscreen mode Exit fullscreen mode

This endpoint also uses Basic authentication. The response confirms the attachment has been linked successfully.

Putting It All Together

Here's how the complete upload process works for a single attachment:

async uploadSingleAttachment(objectId, attachmentMetadata, filePath) {
  const { filename, comment = "" } = attachmentMetadata;

  try {
    console.log(`📎 Uploading: ${filename}`);

    // Step 1: Get upload credentials
    const credentials = await this.getUploadCredentials(objectId);
    console.log(`  ✓ Got upload credentials`);

    // Step 2: Upload file to media service
    const uploadResult = await this.uploadFileToMediaService(
      filePath,
      filename,
      credentials,
    );
    console.log(`  ✓ Uploaded to media service (ID: ${uploadResult.mediaId})`);

    // Step 3: Link attachment to object
    const attachmentData = {
      contentType: getMimeType(filename),
      filename: filename,
      mediaId: uploadResult.mediaId,
      size: uploadResult.mediaSize,
      comment: comment,
    };

    const linkResult = await this.linkAttachmentToObject(
      objectId,
      attachmentData,
    );
    console.log(`  ✅ Successfully linked attachment to object`);

    return { success: true, result: linkResult };
  } catch (error) {
    console.error(`  ❌ Failed to upload ${filename}: ${error.message}`);
    return { success: false, error: error.message };
  }
}

Enter fullscreen mode Exit fullscreen mode

Important Considerations

No Timeouts for File Uploads

File uploads can take significant time, especially for large files or slow connections. The implementation uses zero timeouts:

const options = {
  method: "POST",
  headers: { ... },
  // NO TIMEOUT - file uploads must complete naturally regardless of size
};

Enter fullscreen mode Exit fullscreen mode

Do not impose timeouts on the file upload request. Let it complete naturally. If it fails, handle the error rather than timing out.

MIME Type Detection

Proper MIME type detection is important for the link step:

function getMimeType(filename) {
  const ext = path.extname(filename).toLowerCase();
  const mimeTypes = {
    ".pdf": "application/pdf",
    ".jpg": "image/jpeg",
    ".jpeg": "image/jpeg",
    ".png": "image/png",
    ".gif": "image/gif",
    ".doc": "application/msword",
    ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
    ".xls": "application/vnd.ms-excel",
    ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
    ".txt": "text/plain",
    ".zip": "application/zip",
    ".7z": "application/x-7z-compressed",
    ".tar": "application/x-tar",
    ".gz": "application/gzip",
  };

  return mimeTypes[ext] || "application/octet-stream";
}

Enter fullscreen mode Exit fullscreen mode

Error Handling

Each step can fail independently. Handle errors at each step rather than trying a single try-catch for the entire process:

try {
  const credentials = await this.getUploadCredentials(objectId);
} catch (error) {
  // Handle credentials failure specifically
  console.error('Failed to get credentials:', error.message);
  return;
}

try {
  const uploadResult = await this.uploadFileToMediaService(filePath, filename, credentials);
} catch (error) {
  // Handle upload failure specifically
  console.error('Failed to upload file:', error.message);
  return;
}

try {
  const linkResult = await this.linkAttachmentToObject(objectId, attachmentData);
} catch (error) {
  // Handle link failure specifically
  console.error('Failed to link attachment:', error.message);
  return;
}

Enter fullscreen mode Exit fullscreen mode

This way, you can provide better error messages and potentially retry only the step that failed.

Authentication Differences

Remember the authentication pattern:

  • Step 1 (Get credentials): Basic auth (email + API token)
  • Step 2 (Upload file): JWT token (from step 1 response)
  • Step 3 (Link attachment): Basic auth (email + API token)

Mixing these up will cause failures.

File Size Limits

Check file sizes before uploading to avoid wasting bandwidth on files that will be rejected:

const fileStats = fs.statSync(filePath);
const maxFileSize = 100 * 1024 * 1024; // 100 MB

if (fileStats.size > maxFileSize) {
  throw new Error(`File too large: ${fileStats.size} bytes (max: ${maxFileSize} bytes)`);
}

Enter fullscreen mode Exit fullscreen mode

Conclusion

The three-step attachment upload process for Jira Assets is not documented publicly but is straightforward once you understand it. The key insights are:

  1. You must request credentials for each object
  2. Files go to a separate media service
  3. Linking is a separate API call after upload
  4. Authentication varies between steps
  5. Do not timeout file uploads

This pattern ensures separation of concerns between storage (media service) and metadata (Assets service), which is a common pattern in cloud architectures.


Originally published on leanzero.net. More Atlassian, Forge and local-AI write-ups at leanzero.net/blog, and if you're planning a migration or a Forge app, that's what we do: leanzero.net/services.

Top comments (0)