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.
Note: This guide and any software contained in it should be used at your own risk by individuals qualified to evaluate its effectiveness. IT IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL IRONCLAD BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH YOUR USE OF THIS GUIDE OR ANY SOFTWARE IT CONTAINS.
Your use of the Ironclad API must comply with Ironclad’s API Terms of Use (available at https://legal.ironcladapp.com/api-terms-of-use) and the terms of the Enterprise Services Agreement (or equivalent) entered into between you and 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.
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.
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.
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.
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.
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."
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,
};
Updated 10 months ago