Sync Contract Review with a Procurement Process

Link a contract workflow with a business process in another platform (in this case, JIRA).

Using Ironclad for vendor contracts and a system like JIRA for purchase requests allows teams to leverage Ironclad's features for negotiation and signing, as well as JIRA's features for ticket management. However, without some level of synchronization, switching between platforms can be confusing to users.

In this guide, we will demonstrate how an Ironclad workflow can be linked to a purchase request workflow in a system like JIRA. While the Purchase Order use-case and JIRA are examples, the patterns in this guide could potentially be leveraged to synchronize with other workflow products.

The patterns this guide will cover include:

  • Launching an Ironclad workflow using URL parameters, embedding the link in the JIRA ticket.
  • Linking the Ironclad workflow and JIRA ticket together using rich commenting.
  • Pushing JIRA approval through to Ironclad.
15751575

A purchase order initiated in JIRA, with contract review managed in Ironclad.

Set-Up

To start, you will need user and API access to both JIRA and Ironclad accounts. In JIRA, you can use the sample Purchase Order project, or set up a project with at least three stages: "Requested," "In Review," and "Approved."

In Ironclad, go to Workflow Designer, and click "Create New." In the top left, rename the workflow to something like "Procurement Request." In the "Document" tab, select "Counterparty Paper" as the paper source. In the "Create" tab, add a "JIRA ID" field to the launch form. Finally, in the "Archive" tab, create a new Record Type called "Vendor Contract." Once there are no errors left, click "Publish" in the top right.

14161416

A simple vendor contract workflow in Workflow Designer.

By abstracting the Vendor Contract workflow into Ironclad, contract managers can manage the request, review, and signature process themselves, without changing a line of code.

Launch a Workflow via URL

Our users will start a purchase order by opening a ticket in JIRA, at which point we will direct them to Ironclad to submit their contract for review. To do this, we will construct a launch URL, and post it in a comment on the JIRA ticket.

574574

Launch URLs can be used to direct users to the correct Ironclad workflow, and to pre-fill information.

You can also use APIs to directly launch an Ironclad workflow, which may be preferable, depending on the desired user experience (you may not want to force users to switch applications) and security requirements (using query parameters to transfer sensitive information should be avoided).

In this case, using the launch URL is preferable. It allows us to collect additional information from the user, like the vendor contract itself. Additionally, the information we are transferring is not sensitive (vendor name and JIRA ticket number).

const jiraActions = require('./jira-actions');
const qs = require('querystring');
const express = require("express");
const app = express();
const config = require("./config");

app.use(express.json());

// JIRA webhook listener
app.post("/events/receive-procurement-request", function(req, res) {
  var event = req.body;
  if (event.issue.fields.status.name === 'In Review') {
    handleJiraIssueInReview(event.issue);
  }
  res.send();
});

async function handleJiraIssueInReview(issue) {
  console.log(`Issue ${issue.key} moved to 'In Review'`);

  const attributes = {
    [config.app.ironcladJiraIdField]: issue.key,
    counterpartyName: issue.fields.customfield_10106,
  };

  const message = 'Please submit the contract for this request for legal review via Ironclad: ';
  var targetURL = `https://demo.ironcladapp.com/workflows/launch/${config.app.ironcladTemplateId}#${qs.stringify(attributes)}`;
  await jiraActions.postCommentIfNotPosted(issue.key, message, targetURL);
}

app.listen(config.app.port, () => {
  console.log(`Server is running at http://localhost:${config.app.port}`);
});
const axios = require('axios');
const config = require("./config");

async function postCommentIfNotPosted(jiraId, message, linkHref) {
  const comments = await findComments(jiraId, predicateForMatchingText(message));
  if (comments.length === 0) {
    await jiraActions.postComment(jiraId, message, linkHref);
  } 
}

async function postComment(jira, commentText, linkHref) {
  console.log('posting comment');
  await axios.post(
    `${config.app.jiraBaseURL}/issue/${jira}/comment`,
    {
      body: createCommentBody(commentText, linkHref),
    },
    {
      headers: config.app.jiraHeaders,
    },
  );
}

function createCommentBody(commentText, linkHref) {
  return {
    type: 'doc',
    version: 1,
    content: [
      {
        type: 'paragraph',
        content: [
          {
            text: commentText,
            type: 'text',
          },
          linkHref ? {
            type: 'text',
            text: linkHref,
            marks: [{
              type: 'link',
              attrs: {
                href: linkHref,
                title: linkHref,
              },
            }],
          } : undefined,
        ].filter((c) => c),
      }
    ]
  };
}

function predicateForMatchingText(substring) {
  return (comment) => {
    if (comment.body && comment.body.type === 'doc') {
      return comment.body.content.some((elt) => elt.content && elt.content.some(({ text }) => text && text.indexOf(substring) > -1));
    } else {
      return false;
    }
  }
}

async function findComments(jira, predicate) {
  console.log('finding matching comments for jira ticket', jira);
  const { data } = await axios.get(
    `${config.app.jiraBaseURL}/issue/${jira}/comment`,
    {
      headers: config.app.jiraHeaders,
    }
  );
  return data.comments.filter(predicate);
}

module.exports = {
  postComment,
  postCommentIfNotPosted,
};

Most of the code shown above is actually using the JIRA API to listen for webhook events and post a comment. The main piece integrating with Ironclad is actually the construction of the targetURL. In this case, we construct it based on the ironcladTemplateId, and two attributes: counterpartyName, and the ironcladJiraIdField. While the counterpartyName is common across Ironclad workflows, the ironcladTemplateId and ironcladJiraIdField are set in configuration, and can be retrieved from the launch form page in Ironclad, as shown below.

10941094

Retrieving the ironcladTemplateId (6022aa7b0d67d7292ad7a3e5 in this case) and the ironcladJiraIdField (custom70e1438f383949ccafe619f2f9f33c7d in this case) from the workflow launch page.

For information on creating JIRA API tokens, see Manage API Tokens for your Atlassian Account, and for setting up JIRA webhooks, consider the webhooks guide on JIRA's developer page.

Publish Ironclad Events and Data to JIRA

Now that users can launch contract workflows, we need to tie the two workflows together. We will accomplish this by keying off of the "JIRA ID" field in the Ironclad contract workflow, and listening for a workflow_launched webhook event from Ironclad.

693693

Ironclad comments are used to add convenient links back to the corresponding ticket in JIRA.

To start, we will set up an Ironclad webhook. This can be done from Ironclad, as described in Hello World of Contracts.

Next, we need to add a custom URL field to JIRA, called "Ironclad Workflow." To add this field, follow the Adding a Custom Field guide.

When a workflow is launched, we will add a link to the workflow in a JIRA comment, then add the link to a special JIRA field, and then add a link to the ticket in an Ironclad comment. The server.js code below demonstrates this in the handleWorkflowLaunch function. The API call for posting a comment to Ironclad is imported from ironclad-actions.js . We have two new configuration items, the ironcladApiKey and the ironcladBotUserEmail. The API key can be generated in Ironclad, and you can use your own user account email (or create a dummy account) for the ironcladBotUserEmail.

const jiraActions = require('./jira-actions');
const ironcladActions = require('./ironclad-actions');
const qs = require('querystring');
const express = require("express");
const app = express();
const config = require("./config");

app.use(express.json());

app.post("/events/receive-procurement-request", function(req, res) {
  // Omitted
});

app.post('/events/receive-workflow-update', function (req, res) {
  var { event, workflowID, templateID } = req.body.payload;
  if (templateID === config.app.ironcladTemplateId) {
    switch (event) {
      case 'workflow_launched':
        handleWorkflowLaunch(workflowID);
        break;
    }
  }
  res.send();
});

async function handleWorkflowLaunch(workflowID) {
  console.log('handling workflow launch');
  const data = await ironcladActions.getWorkflow(workflowID);
  const { attributes } = data;
  const jiraId = attributes[config.app.ironcladJiraIdField];
  if (jiraId) {
    const ironcladLink = `https://demo.ironcladapp.com/workflow/${workflowID}`;
    await jiraActions.postComment(jiraId, 'Contract review request has been submitted in Ironclad: ', ironcladLink);
    await jiraActions.setField(jiraId, config.app.jiraIroncladLinkField, ironcladLink);
    await ironcladActions.postComment(workflowID, `This review request is linked to ${jiraId}: https://ironclad.atlassian.net/browse/${jiraId}`);
  }
}

app.listen(config.app.port, () => {
  console.log(`Server is running at http://localhost:${config.app.port}`);
});
const axios = require('axios');
const config = require("./config");

async function postComment(workflowId, text) {
  await axios.post(
    `https://demo.ironcladapp.com/public/api/v1/workflows/${workflowId}/comment`,
    {
      creator: {
        type: 'email',
        email: config.app.ironcladBotUserEmail,
      },
      comment: text,
    },
    {
      headers: {
        Authorization: `Bearer ${config.app.ironcladApiKey}`,
      },
    },
  );
}

module.exports = {
  postComment,
};
const axios = require('axios');
const config = require("./config");

async function postCommentIfNotPosted(jiraId, message, linkHref) {
  // Omitted
}

async function postComment(jira, commentText, linkHref) {
  // Omitted
}

async function setField(jira, field, value) {
  console.log('setting field for jira ticket', jira, field, value);
  await axios.put(
    `${config.app.jiraBaseURL}/issue/${jira}`,
    {
      fields: {
        [field]: value,
      }
    },
    {
      headers: config.app.jiraHeaders,
    }
  );
}

module.exports = {
  postComment,
  postCommentIfNotPosted,
  setField,
};

Push Approval from JIRA to Ironclad

In Ironclad, contracts must be approved at the "Review Step," before being sent for signature. Using the API to drive an automatic approval can be useful in a variety of situations. In this case, we will assume that purchase requests are approved by moving them to the "Approved" state in JIRA. This will drive an approval in Ironclad.

897897

API-driven approvals in Ironclad can be used to keep the contracting process in sync with other systems like JIRA.

Our first step is to add a special approval from JIRA to the Ironclad workflow. Go to Workflow Designer, and click on the Vendor Agreement workflow. If you click on the "Review" tab, you can add a new reviewer (eg. "JIRA") and assign it to the bot user. Once you have configured this, click "Publish."

14181418

Adding a reviewer in Workflow Designer allows API-driven approval to happen alongside contract approval policies.

Next, let's add logic to our JIRA webhook listener, so that when the ticket enters the "Approved" step, we trigger that approval in Ironclad.

const jiraActions = require('./jira-actions');
const ironcladActions = require('./ironclad-actions');
const qs = require('querystring');
const express = require("express");
const app = express();
const config = require("./config");

app.use(express.json());

app.post("/events/receive-procurement-request", function(req, res) {
  var event = req.body;
  if (event.issue.fields.status.name === 'In Review') {
    handleJiraIssueInReview(event.issue);
  }
  if (event.issue.fields.status.name === 'Approved') {
    handleJiraIssueApproved(event.issue);
  }
  res.send();
});

async function handleJiraIssueInReview(issue) {
  // Omitted
}

async function handleJiraIssueApproved(issue) {
  console.log(`Issue ${issue.key} moved to 'Approved'`);

  const ironcladLink = issue.fields[config.app.jiraIroncladLinkField];
  if (ironcladLink) {
    const ironcladWorkflowId = ironcladLink.substr(ironcladLink.lastIndexOf('/') + 1);
    await ironcladActions.approve(ironcladWorkflowId);
  }
}

app.post('/events/receive-workflow-update', function (req, res) {
  // Omitted
});

app.listen(config.app.port, () => {
  console.log(`Server is running at http://localhost:${config.app.port}`);
});
const axios = require('axios');
const config = require("./config");

async function postComment(workflowId, text) {
  // Omitted
}

async function approve(workflowId) {
  await axios.patch(
    `https://demo.ironcladapp.com/public/api/v1/workflows/${workflowId}/approvals`,
    {
      user: {
        type: 'email',
        email: config.app.ironcladBotUserEmail,
      },
      status: 'approved',
      approval: config.app.ironcladApproverRole,
    },
    {
      headers: {
        Authorization: `Bearer ${config.app.ironcladApiKey}`,
      },
    }
  )
}

module.exports = {
  postComment,
  approve,
};

Note that ironclad-actions.js adds a new configuration variable: ironcladApproverRole. This is the role ID for the approver we just added to the workflow. One way to get this role ID is to launch a workflow, and query the GET approvals endpoint, and inspect the return information, as shown below.

curl --include \
     --header "Authorization: Bearer {ironcladApiKey}" \
  'https://demo.ironcladapp.com/public/api/v1/workflows/{workflowId}/approvals'
{
  "workflowId": "{workflowId}",
  "title": "Vendor Contract with Verasept, Inc.",
  "approvalGroups": [
    {
      "reviewers": [
        {
          "role": "approver394554294d43438fa735d9b3a0d84f5a",
          "displayName": "Legal",
          "reviewerType": "role",
          "status": "pending"
        },
        {
          "role": "approver657375d3843a40aea852151f396d0f68",
          "displayName": "JIRA",
          "reviewerType": "role",
          "status": "pending"
        }
      ]
    }
  ],
  "roles": [
        // Omitted
  ]
}

Wrapping Up: Push Context to JIRA

As a final step, let's post updates on the contract status to JIRA. To do this, we will use the GET workflow endpoint to check which step the contract is in, and the GET approvals endpoint to check what the status of Legal review is.

const jiraActions = require('./jira-actions');
const ironcladActions = require('./ironclad-actions');
const qs = require('querystring');
const express = require("express");
const app = express();
const config = require("./config");

app.use(express.json());

app.post("/events/receive-procurement-request", function(req, res) {
  // Omitted
});

app.post('/events/receive-workflow-update', function (req, res) {
  var { event, workflowID, templateID } = req.body.payload;
  if (templateID === config.app.ironcladTemplateId) {
    switch (event) {
      case 'workflow_launched':
        handleWorkflowLaunch(workflowID);
        break;
      case 'workflow_updated':
        handleWorkflowUpdated(workflowID);
        break;
    }
  }
  res.send();
});

async function handleWorkflowLaunch(workflowID) {
  // Omitted
}

async function handleWorkflowUpdated(workflowID) {
  console.log('handling workflow update');
  const data = await ironcladActions.getWorkflow(workflowID);
  const { attributes } = data;
  const jiraId = attributes[config.app.ironcladJiraIdField];
  if (jiraId) {
    // Post to JIRA about whether legal has reviewed.
    const reviewers = await ironcladActions.getReviewers(workflowID);
    if (reviewers && reviewers.approvalGroups.flatMap((g) => g.reviewers).some((r) => r.displayName === 'Legal' && r.status === 'approved')) {
      await jiraActions.postCommentIfNotPosted(jiraId, 'Legal approved the contract.');
    }
    
    // Post to JIRA about whether signature has been sent
    if (data.step === 'Sign') {
      await jiraActions.postCommentIfNotPosted(jiraId, 'Contract is out for signature.');
    }

    // Post to JIRA when signature is completed.
    if (data.step === 'Archive' || data.isComplete) {
      await jiraActions.postCommentIfNotPosted(jiraId, 'Contract has been signed.');
    }
  }
}

app.listen(config.app.port, () => {
  console.log(`Server is running at http://localhost:${config.app.port}`);
});
const axios = require('axios');
const config = require("./config");

async function getWorkflow(workflowId) {
  const { data } = await axios.get(`${config.app.ironcladApiUrl}/workflows/${workflowId}`, {
    headers: {
      Authorization: `Bearer ${config.app.ironcladApiKey}`,
    },
  });
  return data;
}

async function getReviewers(workflowId) {
  const { data } = await axios.get(`${config.app.ironcladApiUrl}/workflows/${workflowId}/approvals`, {
    headers: {
      Authorization: `Bearer ${config.app.ironcladApiKey}`,
    },
  });
  return data;
}

async function postComment(workflowId, text) {
  // Omitted
}

async function approve(workflowId) {
  // Omitted
}

module.exports = {
  getWorkflow,
  getReviewers,
  postComment,
  approve,
};

Did this page help you?