Uploading Attachments to Jira Service Management Cloud Assets Without Public API Documentation
Mihai Perdum
Author
5 min readJanuary 30, 2026
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.
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.
1asyncuploadFileToMediaService(filePath, filename, credentials){2const{ clientId, mediaBaseUrl, mediaJwtToken }= credentials;34returnnewPromise((resolve, reject)=>{5// Check if file exists and get its stats6if(!fs.existsSync(filePath)){7reject(newError(`File not found: ${filePath}`));8return;9}1011const fileStats = fs.statSync(filePath);1213// Check file size14if(fileStats.size>this.config.maxFileSize){15reject(16newError(17`File too large: ${fileStats.size} bytes (max: ${this.config.maxFileSize} bytes)`,18),19);20return;21}2223const encodedFilename =encodeURIComponent(filename);24// Fixed endpoint: Atlassian changed from /file/binary to /file25const uploadUrl =`${mediaBaseUrl}/file?name=${encodedFilename}`;2627const form =newFormData();28 form.append("file", fs.createReadStream(filePath));2930const options ={31method:"POST",32headers:{33"X-Client-Id": clientId,34Authorization:`Bearer ${mediaJwtToken}`,35...form.getHeaders(),36},37};3839const parsedUrl =newURL(uploadUrl);40const isHttps = parsedUrl.protocol==="https:";41const requestModule = isHttps ? https : http;4243const req = requestModule.request(uploadUrl, options,(res)=>{44let data ="";4546 res.on("data",(chunk)=>{47 data += chunk;48});4950 res.on("end",()=>{51if(res.statusCode===200|| res.statusCode===201){52try{53const response =JSON.parse(data);54resolve({55mediaId: response.data?.id || response.id,56mediaSize: response.data?.size || response.size|| fileStats.size,57});58}catch(error){59reject(60newError(`Failed to parse upload response: ${error.message}`),61);62}63}else{64reject(65newError(66`Failed to upload file: HTTP ${res.statusCode} - ${data}`,67),68);69}70});71});7273 req.on("error",(error)=>{74reject(newError(`Upload request failed: ${error.message}`));75});7677 form.pipe(req);78});79}80
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.
1asynclinkAttachmentToObject(objectId, attachmentData){2const url =`https://api.atlassian.com/jsm/assets/workspace/${this.workspaceId}/v1/attachments/object/${objectId}`;34const payload ={5attachments:[attachmentData],6};78returnnewPromise((resolve, reject)=>{9const postData =JSON.stringify(payload);1011const options ={12method:"POST",13headers:{14Authorization:`Basic ${this.apiToken}`,15Accept:"application/json",16"Content-Type":"application/json",17"Content-Length":Buffer.byteLength(postData),18},19};2021const req = https.request(url, options,(res)=>{22let data ="";2324 res.on("data",(chunk)=>{25 data += chunk;26});2728 res.on("end",()=>{29if(res.statusCode===200|| res.statusCode===201){30try{31const response =JSON.parse(data);32resolve(response);33}catch(error){34reject(35newError(`Failed to parse link response: ${error.message}`),36);37}38}else{39reject(40newError(41`Failed to link attachment: HTTP ${res.statusCode} - ${data}`,42),43);44}45});46});4748 req.on("error",(error)=>{49reject(newError(`Link request failed: ${error.message}`));50});5152 req.write(postData);53 req.end();54});55}56
The attachmentData object must include:
1const attachmentData ={2contentType:"application/pdf",// MIME type of the file3filename:"document.pdf",// Original filename4mediaId:"abc123xyz",// The mediaId returned from step 25size:1234567,// File size in bytes6comment:"Optional comment"// Optional attachment comment7};8
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:
1asyncuploadSingleAttachment(objectId, attachmentMetadata, filePath){2const{ filename, comment =""}= attachmentMetadata;34try{5console.log(`📎 Uploading: ${filename}`);67// Step 1: Get upload credentials8const credentials =awaitthis.getUploadCredentials(objectId);9console.log(` ✓ Got upload credentials`);1011// Step 2: Upload file to media service12const uploadResult =awaitthis.uploadFileToMediaService(13 filePath,14 filename,15 credentials,16);17console.log(` ✓ Uploaded to media service (ID: ${uploadResult.mediaId})`);1819// Step 3: Link attachment to object20const attachmentData ={21contentType:getMimeType(filename),22filename: filename,23mediaId: uploadResult.mediaId,24size: uploadResult.mediaSize,25comment: comment,26};2728const linkResult =awaitthis.linkAttachmentToObject(29 objectId,30 attachmentData,31);32console.log(` ✅ Successfully linked attachment to object`);3334return{success:true,result: linkResult };35}catch(error){36console.error(` ❌ Failed to upload ${filename}: ${error.message}`);37return{success:false,error: error.message};38}39}40
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:
1const options ={2method:"POST",3headers:{...},4// NO TIMEOUT - file uploads must complete naturally regardless of size5};6
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:
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.