Heroku Slack notifications using Webhooks and AWS Lambda
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.
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:
Slack Webhooks
Go to https://api.slack.com/apps and type webhook
in the search bar:
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:
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 theDesigner
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:
- CLI: full docs
- Heroku’s Dashboard.
For simplicity, I chose to create it using Heroku’s dashboard:
- Click
More
dropdown and selectView webhooks:
2. Click on Create Webhook:
3. In the Payload URL paste the AWS API Gateway endpoint
from the previous section.
Select api:release
And that’s it!
From now on, you will receive a slack notification every time you release :)
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
:
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!