Heroku Slack notifications using Webhooks and AWS Lambda
By: Oron Ben-David
Tags: Github, Webhooks, API, Slack, Heroku

At Loadmill, we deploy 1–2 releases a day. We decided that it would be helpful to notify everyone using Slack, about new features every time we release.

slack notification

The main goal was to send a notification that contains a Github URL with all the new content added to the release, something like:

This story was written to save you much pain on configurations, edge cases, and security.

First Try

We tested Heroku ChatOps (Slack Integration) which uses the power of Heroku Pipelines to bring a collaborative deploy workflow to Slack.

The only option we could find for routing pipeline notifications to a channel was:

/h route PIPELINE_NAME to #CHANNEL_NAME

The main issue is that it sends too many events, and we were looking for production only events, but for now, it seems unsupported.

In addition, ChatOps doesn’t send the github deployment hash in the payload and we needed this value in order to build the github diff URL.

Second Try

Slack Webhooks!

Heroku's App webhooks enable you to receive notifications whenever particular changes are made to your Heroku app.

Desired architecture:

Webhooks Integration

Go to https://api.slack.com/apps and type webhook in the search bar:

search for incoming webhooks

Choose a channel:

Choose a channel

Now you will see your slack API webhook URL, which supposed to look something like https://hooks.slack.com/services/…

Testing webhook using CURL :

curl -X POST --data-urlencode "payload={\"channel\": \"#channel-name\", \"username\": \"webhookbot\", \"text\": \"This is posted to #channel-name and comes from a bot named webhookbot.\", \"icon_emoji\": \":ghost:\"}" https://hooks.slack.com/services/${TOKEN1}/${TOKEN2}/${TOKEN3}

Next mission: configure AWS Lambda:

AWS Lambda

In order to create a Lambda Function, go to Lambda page on AWS dashboard:

Click on Create function , on the next page you will be able to select the language to use to write your function:

The simplest example would be to implement a sendNotificationToSlack function and to call it from handler :

const http = require('https'); exports.handler = async (event, context) => {

try {

const body = JSON.parse(event.body);

await sendNotificationToSlack(body);

}

}

catch (err) {

return Promise.resolve(`${err}`);

}

};

sendNotificationToSlack function:

async function sendNotificationToSlack(body) {

return new Promise((resolve, reject) => { const data = { channel: "#channel-name", username: "aws-lambda", text: `New Production Release.`, "icon_emoji": ":beers:" }; const options = {

host: 'hooks.slack.com',

path: `/services/${token1}/${token2}/${token3}`,

port: 443,

method: 'POST',

headers: {

'Content-Type': 'application/x-www-form-urlencoded',

}

}; const req = http.request(options, (res) => {

resolve(`statusCode: ${res.statusCode}`);

}); req.on('error', (e) => {

reject(e.message);

}); req.write(JSON.stringify(data));

req.end();

});

}

AWS API GATEWAY

Now let’s add a trigger to our lambda , the simplest way to do that is to click on +Add trigger and create new API Gateway:

API Gateway as a trigger to lambda

Select or Create API Gateway:

After a few more clicks your API Gateway will be connected to lambda and expose a public URL which will activate the handler function.

The API Endpoint url can be displayed after clicking on API Gateway in the Designer section.

Heroku Webhooks

Heroku’s App webhooks enable you to receive notifications whenever particular changes are made to your Heroku app. You can subscribe to notifications for a wide variety of events, including:

App builds

App releases

Add-on changes

Dyno formation changes

Webhook notifications are sent as HTTP POST requests to a URL of your choosing. To integrate with webhooks, you need to implement a server endpoint that receives and handles these requests.

In our implementation, we decided to use AWS API Gateway as server endpoint.

There are two options to configure webhooks:

For simplicity, I chose to create it using Heroku’s dashboard:

Click More dropdown and select View webhooks:

Heroku View webhooks

2. Click on Create Webhook:

Create Webhook

3. In the Payload URL paste the AWS API Gateway endpoint from the previous section.

New Heroku Webhook

Select api:release

And that’s it!

From now on, you will receive a slack notification every time you release :)

slack notification

In case you survived and got till here, you might be asking yourself:

״Wait! Anyone can call our public API?״

Yes! You are correct!

AWS provides a variety of options for securing your API, but in this article, I’m going to focus on how to verify requests from Heroku :

Securing webhook requests

In order to protect our API, we can verify that the requests are coming from Heroku . We are going to use a shared secret, which will be used to sign each request. Let's improve our lambda function to take a request and secret to determine if the request is coming from Heroku:

exports.handler = async (event, context) => {

try {

let body = JSON.parse(event.body);

const isEqual = await compareHash(body, event.headers); if (isEqual) {

await sendNotificationToSlack(body);

}

else {

return Promise.resolve(`Notification not sent`);

}

}

catch (err) {

return Promise.resolve(`${err}`);

}

}; function compareHash(body, headers) {

const sharedSecret = process.env.sharedSecret; const calculatedHmac = crypto.createHmac('sha256', sharedSecret)

.update(JSON.stringify(body))

.digest('base64'); const heroku_hmac = headers['heroku-webhook-hmac-sha256'];

return calculatedHmac === heroku_hmac;

}

The secret on Heroku’s side can be added on the same page that we created the webhook, you can see the Secret optional input in the above image.

The same secret value should be set Using AWS Lambda environment variables

Avoid multiple notifications on the same release

Heroku will trigger minimum two notifications for each event type:

action: create

action: update

The payload of each notification contains a lot of data, which is very helpful for filtering.

I added the following if statement to avoid multiple slack notifications for each release:

exports.handler = async (event, context) => {

try {

let body = JSON.parse(event.body);

const isEqual = await compareHash(body, event.headers);

const isUpdate = body.action === "update";

const isDeploy = body.data.description.includes("Deploy");



if (isEqual && isUpdate && isDeploy) {

await sendNotificationToSlack(body);

}

else {

return Promise.resolve(`Notification not sent.`);

}

}

catch (err) {

return Promise.resolve(`${err}`);

}

};

Send Github diff URL

On each release, the webhook will contain the deployed github hash in the payload. We store it on s3 bucket, and retrieve the previous hash value in order to build the github compare url .

Let’s add this to our export.handler function:

let body = JSON.parse(event.body);

const isEqual = await compareHash(body, event.headers);

const isUpdate = body.action === "update";

const isDeploy = body.data.description.includes("Deploy"); if (isEqual && isUpdate && isDeploy) { const { url, newRelease } = await getGithubDiffUrl(s3, body);

await uploadNewReleaseToS3(s3, newRelease);

await sendNotificationToSlack(githubDiffUrl);

}

Let’s get the previous hash from s3 , and build github diff url :



const Bucket = process.env.s3Bucket;

const Key = process.env.s3File;

const params = { Bucket, Key };

const file = await s3.getObject(params).promise();

const previousRelease = file.Body.toString();

const newRelease = body.data.description.split(' ')[1];

const urlParam = `${previousRelease}...${newRelease}`;

const url = `

return { url, newRelease };

} async function getGithubDiffUrl(s3, body) {const Bucket = process.env.s3Bucket;const Key = process.env.s3File;const params = { Bucket, Key };const file = await s3.getObject(params).promise();const previousRelease = file.Body.toString();const newRelease = body.data.description.split(' ')[1];const urlParam = `${previousRelease}...${newRelease}`;const url = ` https://github.com/loadmill/.../compare/${urlParam}` return { url, newRelease };

And lastly, store the new hash in s3 :

async function uploadNewReleaseToS3(s3, newRelease) {

const Bucket = process.env.s3Bucket;

const Key = process.env.s3File;

const destparams = { Bucket, Key, Body: newRelease };

await s3.putObject(destparams).promise();

}

Enjoy!