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:
- Get upload credentials from the Assets API
- Upload the file to Atlassian's media service using those credentials
- 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();
});
}
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);
});
}
Key aspects of this step:
- Uses the JWT token for authentication, not Basic auth
- Uploads as
multipart/form-datawith a field namedfile - Returns a
mediaIdthat identifies the uploaded file - The endpoint is
{mediaBaseUrl}/file?name={encodedFilename}- note the change from/file/binaryto/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();
});
}
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
};
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 };
}
}
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
};
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";
}
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;
}
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)`);
}
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:
- You must request credentials for each object
- Files go to a separate media service
- Linking is a separate API call after upload
- Authentication varies between steps
- 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)