Get mapping data flowing from Data Center to your cloud app in 30 minutes.
A minimal Simplified App Migration (SAM) integration that:
By the end, your cloud app will receive Jira issue or Confluence page ID mappings during a migration.
Time: ~30 minutes
Difficulty: Beginner
Export type: Mappings (Preview)
You'll need:
How SAM works: Simplified App Migration packages data during a Data Center to Cloud migration. When you run a migration using JCMA, the platform exports your app's data (as specified in your manifest) and notifies your cloud app when it's ready.
For mappings, the data becomes available after the product migration completes (because DC-to-Cloud ID mappings only exist once entities have been migrated to cloud).
Mappings are in Preview and available for production migrations. Other SAM features (binary, AO tables) are Early Access only.
Tell the migration platform to package mapping data for your app.
Add to your Forge manifest.yml:
1 2 3 4 5 6 7modules: migration:managedExports: - key: mapping entityTypes: - "jira:issue" - "confluence:page"
Choose the entity types your app needs mappings for. Common types:
jira:issue - Jira issue ID mappingsjira:project - Jira project ID mappingsconfluence:page - Confluence page ID mappingsconfluence:space - Confluence space ID mappingsSee the full list in Mappings reference.
Deploy the updated manifest to development environment and install on your test cloud site:
1 2 3forge deploy --environment development forge install --site your-test-site.atlassian.net --environment development
The --environment development flag explicitly deploys to your development environment (this is also the CLI default). Always test SAM integrations in development or staging before deploying to production with --environment production.
Your cloud app needs to listen for the event-file-list-uploaded notification.
Add to manifest.yml:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19app: id: [your-app-id] triggers: - key: migration-event-trigger function: migration-handler events: - avi:ecosystem.migration:uploaded:event_file_list - avi:ecosystem.migration:listener_triggered - avi:ecosystem.migration:transfer_settled endpoint: - key: remote-event remote: remote-app-ref route: path: /api/forge/events auth: appSystemToken: enabled: true
Create an HTTP endpoint to receive events at /api/forge/events (as defined in the manifest):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30// Express.js endpoint handler app.post('/api/forge/events', async (req, res) => { const event = req.body; const { eventType } = event; console.log(`Received migration event: ${eventType}`); try { if (eventType === 'event-file-list-uploaded') { await handleFileListUploaded(event); } // Handle other events (listener-triggered, transfer-settled, etc.) res.status(200).send({ success: true }); } catch (error) { console.error('Event handling failed:', error); res.status(500).send({ error: error.message }); } }); async function handleFileListUploaded(event) { const { key, label, isComplete, transferId, movementDetails } = event; console.log(`File list uploaded - Label: ${label}, IsComplete: ${isComplete}`); if (label === 'MAPPINGS') { // TODO: Process mappings in next step } }
When you receive the event-file-list-uploaded event with label: MAPPINGS, download the file list.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114async function handleFileListUploaded(event) { const { key, label, movementDetails } = event; if (label !== 'MAPPINGS') { throw new Error(`Expected label 'MAPPINGS', got '${label}'`); } // 1. Request presigned URL for the file list const listingUrl = await getFileListingUrl(key); // 2. Download the file list const fileListing = await downloadFileListing(listingUrl); // 3. Batch fetch download URLs for all event files (up to 100 at a time) const fileIds = fileListing.map(ef => ef.fileId); const urlsMap = await getEventFileUrls(fileIds); // 4. Process files in parallel (10 at a time for performance) const CONCURRENCY = 10; for (let i = 0; i < fileListing.length; i += CONCURRENCY) { const batch = fileListing.slice(i, i + CONCURRENCY); await Promise.all( batch.map(eventFile => processEventFile(eventFile, urlsMap.get(eventFile.fileId), movementDetails) ) ); } } async function getFileListingUrl(fileKey) { const response = await fetch( 'https://api.atlassian.com/app/migration/forge/v1/file-listing/download-url', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${accessToken}` // Get from Forge context }, body: JSON.stringify({ fileKey }) } ); const { url } = await response.json(); return url; } async function downloadFileListing(presignedUrl) { // This file can be up to 5GB, make sure your production code can handle this const response = await fetch(presignedUrl); // Stream the JSONL file line-by-line to handle large listings efficiently const eventFiles = []; const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (value) { buffer += decoder.decode(value, { stream: true }); // Process complete lines const lines = buffer.split('\n'); buffer = lines.pop(); // Keep incomplete line in buffer for (const line of lines) { if (line.trim()) { eventFiles.push(JSON.parse(line)); } } } if (done) { // Process any remaining data if (buffer.trim()) { eventFiles.push(JSON.parse(buffer)); } break; } } return eventFiles; } async function getEventFileUrls(fileIds) { // Batch fetch up to 100 URLs at once (API limit) const BATCH_SIZE = 100; const urlsMap = new Map(); for (let i = 0; i < fileIds.length; i += BATCH_SIZE) { const batch = fileIds.slice(i, i + BATCH_SIZE); const response = await fetch( 'https://api.atlassian.com/app/migration/forge/v1/event-file/urls', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${accessToken}` }, body: JSON.stringify({ eventFileIds: batch }) } ); const { eventFiles } = await response.json(); eventFiles.forEach(({fileId, presignedUrl}) => { urlsMap.set(fileId, presignedUrl); }); } return urlsMap; }
Each event file contains mapping records in JSONL format.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37async function processEventFile(eventFile, presignedUrl, movementDetails) { const { fileId } = eventFile; console.log(`Processing event file: ${fileId}`); try { // 1. Download the event file (URL already fetched in batch) const mappingRecords = await downloadEventFile(presignedUrl); // 2. Process the mappings await importMappings(mappingRecords, movementDetails); // 3. Acknowledge successful processing await acknowledgeEventFile(fileId, movementDetails.appDataMovementId, 'SUCCESS'); } catch (error) { console.error(`Failed to process ${fileId}:`, error); await acknowledgeEventFile(fileId, movementDetails.appDataMovementId, 'FAILED'); throw error; } } async function downloadEventFile(presignedUrl) { const response = await fetch(presignedUrl); const text = await response.text(); // Parse JSONL format const lines = text.trim().split('\n'); // First line is metadata header const metadata = JSON.parse(lines[0]); console.log(`Event file metadata:`, metadata); // Remaining lines are mapping records return lines.slice(1).map(line => JSON.parse(line)); }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28async function importMappings(records, movementDetails) { const { cloudUrl } = movementDetails.migrationDetails; for (const record of records) { const { sourceId, targetId, entityType } = record; // Store the mapping in your app's database await storeMappingInYourDatabase({ cloudUrl, dcId: sourceId, cloudId: targetId, entityType: entityType }); console.log(`Mapped ${entityType}: ${sourceId} → ${targetId}`); } console.log(`Imported ${records.length} mappings`); } // Example mapping record structure: // { // "sourceId": "10001", // DC issue ID // "targetId": "10234", // Cloud issue ID // "entityType": "jira:issue", // "namespace": "ari:cloud:jira::site/abc123" // }
After successfully importing the mappings, acknowledge the event file.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20async function acknowledgeEventFile(eventFileId, appDataMovementId, status) { await fetch( 'https://api.atlassian.com/app/migration/forge/v1/event-files/status', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${accessToken}` }, body: JSON.stringify({ appDataMovementId, eventFileIds: [eventFileId], status // 'SUCCESS' or 'FAILED' }) } ); console.log(`Acknowledged event file ${eventFileId} with status: ${status}`); }
Important:
SUCCESS for files processed successfullyFAILED only for unrecoverable errors (this fails the entire migration)Test the integration by running an actual DC→Cloud migration.
Before testing, deploy all code changes to the development environment:
1 2forge deploy --environment development
Always test your SAM integration in the development environment before promoting to staging or production.
Important timing: For mappings, the event-file-list-uploaded notification is sent after the product migration completes. This is because DC-to-Cloud ID mappings are only created once Jira has migrated issues/pages to cloud.
1 2 3 4 5 6 7 8 9 10 11 12 13 141. JCMA starts migration ↓ 2. Product migrates issues/pages to cloud ↓ (DC IDs now mapped to Cloud IDs) 3. Platform generates mapping files ↓ 4. event-file-list-uploaded sent to your cloud app (label: MAPPINGS) ↓ 5. Your cloud app downloads and processes mappings ↓ 6. Your cloud app acknowledges event files ↓ 7. Migration completes
Check your cloud app logs for:
1 2 3 4 5 6 7✓ Received migration event: event-file-list-uploaded ✓ File list uploaded - Label: MAPPINGS, IsComplete: true ✓ Processing event file: event_file/abc-123-def ✓ Mapped jira:issue: 10001 → 10234 ✓ Imported 150 mappings ✓ Acknowledged event file with status: SUCCESS
Query your cloud app's database to confirm:
jira:issue, jira:project)Congratulations! You now have a working SAM integration. 🎉
Expand your integration:
Learn more:
Production readiness:
listener-triggered, transfer-settled, transfer-cancellation-requested)No event-file-list-uploaded event received?
migration:managedExportsisComplete: false on all events?
isComplete: true before considering data completeisComplete: trueNeed help?
Rate this page: