Microsoft Dynamics 365 and Jira Cloud sync


Intro video is not displayed because you have disallowed functional cookies.
Get Started

Not the template you're looking for? Browse more.

About the template


Sync Microsoft 365 cases to Jira Cloud as issues, including comments and attachments. Get started to learn more.

About ScriptRunner Connect


What is ScriptRunner Connect?

ScriptRunner Connect is an AI assisted code-first (JavaScript/TypeScript) integration platform (iPaaS) for building complex integrations and automations.

Can I try it out for free?

Yes. ScriptRunner Connect comes with a forever free tier.

Can I customize the integration logic?

Absolutely. The main value proposition of ScriptRunner Connect is that you'll get full access to the code that is powering the integration, which means you can make any changes to the the integration logic yourself.

Can I change the integration to communicate with additional apps?

Yes. Since ScriptRunner Connect specializes in enabling complex integrations, you can easily change the integration logic to connect to as many additional apps as you need, no limitations.

What if I don't feel comfortable making changes to the code?

First you can try out our AI assistant which can help you understand what the code does, and also help you make changes to the code. Alternatively you can hire our professionals to make the changes you need or build new integrations from scratch.

Do I have to host it myself?

No. ScriptRunner Connect is a fully managed SaaS (Software-as-a-Service) product.

What about security?

ScriptRunner Connect is ISO 27001 and SOC 2 certified. Learn more about our security.

Template Content


README

Scripts

TypeScriptOAuth/GenerateUrl
TypeScriptOAuth/ProcessOAuthCallback
Async HTTP Event

README


📋 Overview

This template demonstrates how you can set up a basic sync between Microsoft Dynamics 365 and Jira Cloud. This template will create a new issue in Jira Cloud when a new case is created in Microsoft Dynamics, copying over some basic fields. Comments are synced bi-directionally. When attachment in Microsoft Dynamics side is included in a comment, this will be copied over as well. When issue is transitioned in Jira Cloud side the corresponding case in Dynamics 365 will be resolved. This is a very basic example and is not intended to be out-of-the-box template to be used, but mainly exists for learning purposes.

Notes for workspace

  • This template copies over the following fields from dyanmics to Jira:
    • Case ID -> Custom Field (used to keep items sync'd)
    • Case Title -> Summary
    • Description -> Description
    • Created By -> Reporter (uses email addresses)
    • Resolve By -> Due Date

Oauth process:

  • Go to Azure Portal https://portal.azure.com/#home

  • Select the menu in the far top left

  • Access Entra

  • In the Menu item on the lefthand side, select "App registrations"

  • Select 'New Registration'

  • Give it a name

  • Select 'Accounts in any organizational directory (Any Azure AD directory Multitenant)'

  • Come back to ScriptRunner Connect, and get the URL from the Oauth/GetAccessToken Generic Event Listener.

  • Select 'Web' in the dropdown for Redirect URI

  • In the endpoint field, enter the URL from the Generic Event Listener

  • Get the Client ID and Tenant ID field from the application, and enter them into the parameters.

  • Go to 'Certificates & Secrets'

  • Select to create 'New client secret'

  • Give it a description, and give the secret a suitable length.

  • Copy the value, and enter that into the CLIENT_SECRET parameter field (Be careful, once you leave this page the value will be obscured)

  • Go to 'API permissions'

  • Select to 'Add a permission'

  • Select 'Dynamics CRM'

  • Select 'Delegated permissions' and 'user_impersonation'

  • 'Add permissions'

  • Setup with the Scheduled Trigger to run every hour

  • Configure the Scheduled Trigger to execute the OAuth/RefreshAccessToken

  • Do not enable the trigger yet.

Creating Event Listeners in Dynamics:

OnDynamicsCaseCreated:

  • Finish the Event Listener, and get the URL.
  • In Dyanmics365, go to the menu, and select Power Automate.
  • Select 'Create' from the left hand menu
  • From the 'Start from blank' section, select 'Automated Cloud Flow'
  • Give your flow a name
  • For the trigger, search for 'Row is added' and select the "When a row is added, modified or deleted'
  • Select 'Create'
  • For the parameters for the trigger:
        Change type: Added
        Table name: Cases
        Scope: Organisation (this can be changed for your needs)
    
  • Select the '+' button under the trigger to add an Event
  • Search 'http'. Select 'HTTP' under the 'HTTP' section (Green icon)
  • For the parameters of the HTTP call:
        URI: Enter the URL from the Generic Event Listener
        Method: POST
        Headers: Content*Type & application/json
        Body:   
        "           {
        "               "caseTitle": @{triggerOutputs()?['body/title']},
        "               "caseId": @{triggerOutputs()?['body/incidentid']},
        "               "caseDescription": @{triggerOutputs()?['body/description']},
        "               "caseReporter": @{triggerOutputs()?['body/_createdby_value']},
        "               "caseDropdown": @{triggerOutputs()?['body/new_customsingleselect']},
        "               "caseDueDate": @{triggerOutputs()?['body/resolveby']}
        "           }
    
  • For the body you can copy and paste, but if you want to find the values manually, type '/', select 'Insert dynamic content' and then select the field name

OnDynamicsCommentCreated:

  • Finish the Event Listener, and get the URL.
  • In Dyanmics365, go to the menu, and select Power Automate.
  • Select 'Create' from the left hand menu
  • From the 'Start from blank' section, select 'Automated Cloud Flow'
  • Give your flow a name
  • For the trigger, search for 'Row is added' and select the "When a row is added, modified or deleted'
  • Select 'Create'
  • For the parameters for the trigger:
        Change type: Added
        Table name: Notes
        Scope: Organisation (this can be changed for your needs)
    
  • Select the '+' button under the trigger to add an Event
  • Search 'http'. Select 'HTTP' under the 'HTTP' section (Green icon)
  • For the parameters of the HTTP call:
        URI: Enter the URL from the Generic Event Listener
        Method: POST
        Headers: Content*Type & application/json
        Body:   
        "        {
        "            "commentID": @{triggerOutputs()?['body/annotationid']},
        "            "commentTitle": @{triggerOutputs()?['body/subject']},
        "            "commentText": @{triggerOutputs()?['body/notetext']},
        "            "commentCreator": @{triggerOutputs()?['body/_createdby_value']},
        "            "incidentId": @{triggerOutputs()?['body/_objectid_value']},
        "            "containsAttachment": @{triggerOutputs()?['body/isdocument']},
        "            "commentDocument": @{triggerOutputs()?['body/documentbody']},
        "            "commentFileName": @{triggerOutputs()?['body/filename']},
        "            "commentMimeType": @{triggerOutputs()?['body/mimetype']}
        "        }
    
  • For the body you can copy and paste, but if you want to find the values manually, type '/', select 'Insert dynamic content' and then select the field name

Creating Event Listeners in Jira

For OnJiraCloudIssueTransitioned

Follow the instructions, and add the Webhook to the transition that closes the issue.

For OnJiraCloudCommentCreated

Follow the instructions. Optionally add a JQL query to filter the amount of events sent to SRC.

To start the template

We need to trigger the oauth process for this template. Manually execute the 'GenerateUrl' script and copy the URL from the console. Ensure you are logged in as the user that you want to use to communicate with 365 Navigate the to the URL you have copied and Accept permissions You will see 'Access & refresh token saved in Record Storage.' in the console log. You now have the access token stored in secure local Storage Now we will need to keep the token refreshing, so now we can enable the scheduled trigger

API Connections


TypeScriptOAuth/GenerateUrl

export default async function(event: any, context: Context<EV>): Promise<void> {
    const { CALLBACK_URL, CLIENT_ID, TENANT_ID, SCOPES} = context.environment.vars.Dynamics365OAuth;

    const tenantId = encodeURIComponent(TENANT_ID);
    const clientId = encodeURIComponent(CLIENT_ID);
    const callbackUrl = encodeURIComponent(CALLBACK_URL);
    const scope = encodeURIComponent(SCOPES.join(' '));

    const url = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize?client_id=${clientId}&response_type=code&redirect_uri=${callbackUrl}&response_mode=query&scope=${scope}`

    console.log(url);
}
TypeScriptOAuth/ProcessOAuthCallback

import { HttpEventRequest } from '@sr-connect/generic-app/events/http';
import { RecordStorage } from '@sr-connect/record-storage';

export default async function (event: HttpEventRequest, context: Context<EV>): Promise<void> {
    const { CALLBACK_URL, CLIENT_ID, CLIENT_SECRET, TENANT_ID } = context.environment.vars.Dynamics365OAuth;

    const tenantId = encodeURIComponent(TENANT_ID);
    const clientId = encodeURIComponent(CLIENT_ID);
    const clientSecret = encodeURIComponent(CLIENT_SECRET);
    const callbackUrl = encodeURIComponent(CALLBACK_URL);

    const code = event.queryStringParams.code;

    // Check if code query param is included in response
    if (!code) {
        throw new Error('No code in event payload.');
    }

    const storage = new RecordStorage({ secure: true });

    const params = new URLSearchParams();
    params.append('grant_type', 'authorization_code');
    params.append('client_id', clientId);
    params.append('client_secret', clientSecret);
    params.append('code', code);
    params.append('redirect_uri', CALLBACK_URL);

    console.log(callbackUrl)

    const response = await fetch<OAuthCallbackResponse>(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/x-www-form-urlencoded'
        },
        body: params.toString()
    });

    if (!response.ok) {
        throw Error(`Unexpected error while processing Dynamics 365 OAuth callback: ${response.text()} - ${await response.text()}`);
    }

    const body = await response.json();

    storage.setValue('dyanmics_365_access_token', body.access_token);
    storage.setValue('dyanmics_365_refresh_token', body.refresh_token);
    
    console.log('Access & refresh token saved in Record Storage.');
}

interface OAuthCallbackResponse {
    readonly access_token: string;
    readonly refresh_token: string;
}
TypeScriptOAuth/RefreshAccessToken

import { RecordStorage } from '@sr-connect/record-storage';

export default async function (event: any, context: Context<EV>): Promise<void> {
    const { CLIENT_ID, CLIENT_SECRET, TENANT_ID } = context.environment.vars.Dynamics365OAuth;

    const storage = new RecordStorage({ secure: true })
    const refreshToken = await storage.getValue('dyanmics_365_refresh_token') as string;

    const tenantId = encodeURIComponent(TENANT_ID);
    const clientId = encodeURIComponent(CLIENT_ID);
    const clientSecret = encodeURIComponent(CLIENT_SECRET);

    const params = new URLSearchParams();
    params.append('grant_type', 'refresh_token');
    params.append('refresh_token', refreshToken);
    params.append('client_id', clientId);
    params.append('client_secret', clientSecret);

    const response = await fetch<OAuthRefreshResponse>(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/x-www-form-urlencoded'
        },
        body: params.toString()
    })

    if (!response.ok) {
        throw Error(`Unexpected response while refreshing Dynamics 365 OAuth access token: ${response.status} - ${await response.text()}`);
    }

    const body = await response.json()

    storage.setValue('dyanmics_365_access_token', body.access_token);
    storage.setValue('dyanmics_365_refresh_token', body.refresh_token);

    console.log('Access & refresh token saved in Record Storage.');
}

interface OAuthRefreshResponse {
    readonly access_token: string;
    readonly refresh_token: string;
}
TypeScriptOnDynamicsCaseCreated

import { HttpEventRequest, isJSON } from '@sr-connect/generic-app/events/http';
import { DynamicsCase } from './Utils/Dynamics365';
import { createJiraIssueFromDynamicsCase } from './Utils/Jira';

export default async function(event: HttpEventRequest, context: Context<EV>): Promise<void> {
    if (!isJSON(event)) {
        throw Error('Incoming event is not in JSON format');
    }

    const caseData: DynamicsCase = event.body;

    await createJiraIssueFromDynamicsCase(context, caseData);
}
TypeScriptOnDynamicsCommentCreated

import JiraCloud from './api/jira/cloud';
import { HttpEventRequest, isJSON } from '@sr-connect/generic-app/events/http';
import { DynamicsAttachment, copyAttachmentFromDynamicsToJira, getCaseJiraTicketId, getUserEmailAddress } from './Utils/Dynamics365';
import { createCommentParagraph } from './Utils/Jira';

export default async function (event: HttpEventRequest, context: Context<EV>): Promise<void> {
    if (!isJSON(event)) {
        throw Error('Incoming event is not in JSON format');
    }

    const commentData: CommentEvent = event.body;

    const dynamicsConnectorEmail = context.environment.vars.Connector.CONNECTOR_EMAIL_DYNAMICS;

    const creatorEmail = await getUserEmailAddress(context, commentData.commentCreator);

    if (creatorEmail === dynamicsConnectorEmail) {
        console.log("Ignoring comment to avoid event-loop.");
        return;
    }

    const issueKey = await getCaseJiraTicketId(context, commentData.incidentId);

    await JiraCloud.Issue.Comment.addComment({
        issueIdOrKey: issueKey,
        body: {
            body: createCommentParagraph(commentData.commentTitle, stripHtmlTags(commentData.commentText))
        }
    });

    if (commentData.containsAttachment) {
        const newAttachment: DynamicsAttachment = {
            documentbody: commentData.commentDocument,
            filename: commentData.commentFileName,
            mimetype: commentData.commentMimeType
        }

        await copyAttachmentFromDynamicsToJira(newAttachment, issueKey)
    }
}

function stripHtmlTags(input: string): string {
    // Use a regular expression to remove HTML tags
    return input.replace(/<\/?[^>]+(>|$)/g, "").trim();
}

interface CommentEvent {
    readonly commentID: string;
    readonly commentTitle: string;
    readonly commentText: string;
    readonly commentCreator: string;
    readonly incidentId: string;
    readonly containsAttachment: boolean;
    readonly commentDocument: string;
    readonly commentFileName: string;
    readonly commentMimeType: string;
}
TypeScriptOnJiraCloudCommentCreated

import { IssueCommentCreatedEvent } from '@sr-connect/jira-cloud/events';
import { addCommentToCase } from './Utils/Dynamics365';

export default async function (event: IssueCommentCreatedEvent, context: Context<EV>): Promise<void> {
    const customFieldId = context.environment.vars.JIRA_SYNC_FIELD_ID;
    const jiraConnectorEmail = context.environment.vars.Connector.CONNECTOR_EMAIL_JIRA;

    if (event.comment.author.emailAddress === jiraConnectorEmail) {
        console.log("Ignoring comment to avoid event-loop.")
        return
    }

    await addCommentToCase(context, event.issue.key, customFieldId, {
        commentTitle: "Comment from Jira",
        commentBody: event.comment.body
    });
}
TypeScriptOnJiraCloudIssueTransitioned

import { IssueTransitionedEvent } from '@sr-connect/jira-cloud/events';
import { resolveCase } from './Utils/Dynamics365';

export default async function(event: IssueTransitionedEvent, context: Context<EV>): Promise<void> {
    const syncCustomFieldId = context.environment.vars.JIRA_SYNC_FIELD_ID;

    const caseId = event.issue.fields[syncCustomFieldId];

    if (!caseId) {
        console.log('No case ID found, skipping...');
    }

    await resolveCase(context, caseId, (event as any)['comment']);
}
TypeScriptUtils/Dynamics365

import JiraCloud from '../api/jira/cloud';
import { Convert } from '@sr-connect/convert';
import { RecordStorage } from '@sr-connect/record-storage';

export async function getUserEmailAddress(context: Context<EV>, userId: string): Promise<string> {
    const oAuthDetails = await getOAuthDetails(context);

    const response = await fetch<GetUserEmailResponse>(`${oAuthDetails.baseUrl}/systemusers(${userId})?$select=internalemailaddress`, {
        headers: {
            "Authorization": `Bearer ${oAuthDetails.token}`,
            "Accept": "application/json"
        }
    });

    if (!response.ok) {
        throw Error(`Unexpected response while getting Dynamics 365 user email address: ${response.status} - ${await response.text()}`);
    }

    const body = await response.json();

    return body.internalemailaddress;
}

export async function updateCase(context: Context<EV>, caseId: string, updateBody: CaseUpdateBody) {
    const oAuthDetails = await getOAuthDetails(context);

    const response = await fetch(`${oAuthDetails.baseUrl}/incidents(${caseId})`, {
        method: 'PATCH',
        headers: {
            "Authorization": `Bearer ${oAuthDetails.token}`,
            "Accept": "application/json",
            "Content-Type": "application/json; charset=utf-8",
            "OData-MaxVersion": "4.0",
            "OData-Version": "4.0"
        },
        body: JSON.stringify(updateBody)
    });

    if (!response.ok) {
        throw Error(`Unexpected response while updating Dynamics 365 case: ${response.status} - ${await response.text()}`)
    }
}

export async function getCaseJiraTicketId(context: Context<EV>, incidentId: string): Promise<string> {
    const oAuthDetails = await getOAuthDetails(context);

    const response = await fetch<GetIncidentResponse>(`${oAuthDetails.baseUrl}/incidents(${incidentId})`, {
        headers: {
            "Authorization": `Bearer ${oAuthDetails.token}`,
            "Accept": "application/json",
            "OData-MaxVersion": "4.0",
            "OData-Version": "4.0"
        }
    });

    if (!response.ok) {
        throw Error(`Unexpected response while getting incident from Dynamics 365 (${incidentId}): ${response.status} - ${await response.text()}`);
    }

    const body = await response.json();

    return body.new_jiraticketid;
}

export async function addCommentToCase(context: Context<EV>, jiraTicketKey: string, syncFieldId: string, commentData: CaseComment) {
    const oAuthDetails = await getOAuthDetails(context);

    const issueData = await JiraCloud.Issue.getIssue({
        issueIdOrKey: jiraTicketKey
    });

    const caseId = issueData.fields?.[syncFieldId];

    const response = await fetch(`${oAuthDetails.baseUrl}/annotations`, {
        method: 'POST',
        headers: {
            "Authorization": `Bearer ${oAuthDetails.token}`,
            "Accept": "application/json",
            "Content-Type": "application/json; charset=utf-8",
            "OData-MaxVersion": "4.0",
            "OData-Version": "4.0"
        },
        body: JSON.stringify({
            "subject": commentData.commentTitle,
            "notetext": commentData.commentBody,
            "objectid_incident@odata.bind": `/incidents(${caseId})`
        })
    })

    if (!response.ok) {
        throw Error(`Unexpected response while adding comment to Dynamics 365 case: ${response.status} - ${await response.text()}`);
    }
}

export async function resolveCase(context: Context<EV>, caseId: string, comment: string) {
    const oAuthDetails = await getOAuthDetails(context);

    const response = await fetch(`${oAuthDetails.baseUrl}/CloseIncident`, {
        method: 'POST',
        headers: {
            'Authorization': `Bearer ${oAuthDetails.token}`,
            'Content-Type': 'application/json',
            'OData-MaxVersion': '4.0',
            'OData-Version': '4.0'
        },
        body: JSON.stringify({
            IncidentResolution: {
                subject: "Case Resolved",
                'incidentid@odata.bind': `/incidents(${caseId})`,
                description: comment,
                timespent: 10 // Time spent on resolution in minutes
            },
            Status: 5
        })
    });

    if (!response.ok) {
        throw Error(`Unexpected response while resolving Dynamics 365 case: ${response.status} - ${await response.text()}`);
    }
}

export async function copyAttachmentsFromDynamicsToJira(context: Context<EV>, caseId: string) {
    const oAuthDetails = await getOAuthDetails(context);

    const response = await fetch<GetDynamicsAttachmentsResponse>(`${oAuthDetails.baseUrl}/annotations?$filter=_objectid_value eq ${caseId}`, {
        headers: {
            'Authorization': `Bearer ${oAuthDetails.token}`,
            'Accept': 'application/json'
        }
    });

    if (!response.ok) {
        throw Error(`Unexpected response while fetching attachments from Dynamics (${caseId}): ${response.status} - ${await response.text()}`);
    }

    const body = await response.json();

    const issueId = await getCaseJiraTicketId(context, caseId);

    for (const attachment of body.value) {
        try {
            await copyAttachmentFromDynamicsToJira(attachment, issueId);
        } catch (e) {
            console.error(`Failed to copy attachment for Dynamics case: ${caseId}`, e, attachment);
        }
    }
}

export async function copyAttachmentFromDynamicsToJira(attachment: DynamicsAttachment, issueId: string) {
    await JiraCloud.Issue.Attachment.addAttachments({
        issueIdOrKey: issueId,
        body: [{
            content: Convert.base64ToBuffer(attachment.documentbody),
            fileName: attachment.filename
        }]
    });
}

export async function getOAuthDetails(context: Context<EV>): Promise<OAuthDetails> {
    const storage = new RecordStorage({ secure: true })
    const accessToken = await storage.getValue('dyanmics_365_access_token')

    const setupData: OAuthDetails = {
        baseUrl: `${context.environment.vars.BASE_URL}/api/data/v9.0`,
        token: accessToken
    }

    return setupData
}

interface OAuthDetails {
    readonly baseUrl: string,
    readonly token: any
}

interface GetUserEmailResponse {
    readonly internalemailaddress: string;
}

interface CaseUpdateBody {
    readonly new_jiraticketid: string;
}

interface GetIncidentResponse {
    readonly new_jiraticketid: string;
}

interface CaseComment {
    readonly commentTitle: string;
    readonly commentBody: string;
    readonly commentOwner?: string;
}

interface GetDynamicsAttachmentsResponse {
    readonly value: DynamicsAttachment[];
}

export interface DynamicsAttachment {
    readonly documentbody: string;
    readonly filename: string;
    readonly mimetype: string;
}

export interface DynamicsCase {
    readonly caseId: string;
    readonly caseTitle: string;
    readonly caseDescription: string;
    readonly caseDropdown: string;
    readonly caseDueDate: string;
    readonly caseReporter: string;
}
TypeScriptUtils/Jira

import JiraCloud from '../api/jira/cloud';
import { DynamicsCase, getUserEmailAddress, updateCase } from './Dynamics365';

export async function createJiraIssueFromDynamicsCase(context: Context<EV>, caseItem: DynamicsCase) {
    const { JIRA_SYNC_FIELD_ID, JIRA_ISSUE_TYPE, JIRA_PROJECT_KEY } = context.environment.vars;

    const reporterEmail = await getUserEmailAddress(context, caseItem.caseReporter);

    const users = await JiraCloud.User.Search.findUsers({
        query: `${reporterEmail}`
    });

    let reporterAccountId: string | undefined;
    let description = '';

    if (users.length > 0) {
        reporterAccountId = users[0]?.accountId;
    } else {
        const currentUser = await JiraCloud.Myself.getCurrentUser();

        reporterAccountId = currentUser.accountId;

        description = `Unable to find reporter in Jira with email: ${reporterEmail} - `
    }

    if (caseItem.caseDescription) {
        description += caseItem.caseDescription;
    } else {
        description += 'Issue created from Dyanmic 365 Customer Service Desk'
    }

    const issue = await JiraCloud.Issue.createIssue({
        body: {
            fields: {
                issuetype: {
                    name: JIRA_ISSUE_TYPE
                },
                project: {
                    key: JIRA_PROJECT_KEY
                },
                summary: caseItem.caseTitle,
                description: createDescriptionParagraph(description),
                reporter: reporterAccountId ? {
                    accountId: reporterAccountId
                } : undefined,
                [JIRA_SYNC_FIELD_ID]: caseItem.caseId,
                duedate: caseItem.caseDueDate
            }
        }
    });

    await updateCase(context, caseItem.caseId, {
        new_jiraticketid: issue.key ?? ''
    });
}

function createDescriptionParagraph(text: string) {
    return {
        type: 'doc' as const,
        version: 1 as const,
        content: [
            {
                type: 'paragraph' as const,
                content: [
                    {
                        type: 'text' as const,
                        text: text
                    }
                ]
            }
        ]
    }
}

export function createCommentParagraph(title: string, body: string) {
    return {
        version: 1 as const,
        type: 'doc' as const,
        content: [
            {
                type: 'paragraph' as const,
                content: [
                    {
                        type: 'text' as const,
                        text: title,
                        marks: [
                            {
                                type: 'strong' as const
                            }
                        ]
                    }
                ]
            },
            {
                type: 'paragraph' as const,
                content: [
                    {
                        type: 'text' as const,
                        text: body
                    }
                ]
            }
        ]
    }
}

© 2025 ScriptRunner · Terms and Conditions · Privacy Policy · Legal Notice · Cookie Preferences