Integrating Linear Webhooks with Discord Using AWS Lambda

Integrating Linear Webhooks with Discord Using AWS Lambda

Workflow automation tools such as Zapier and Make are extremely popular and make life easier. However, if you are a small team trying to automate small tasks such as syncing updates from Jira or Linear for task management, paying $10-20 or more per month can feel excessive.

That's why in this blog post, we’ll show you how to build a seamless and serverless integration between Linear and Discord using AWS Lambda at only a fraction of the cost of popular automation tools.

Prerequisites:

  • An AWS account to create and deploy serverless Lambda functions with API Gateway.
  • Admin access to a Linear workspace to create and manage webhooks.
  • Admin permissions in a Discord server to set up and use webhooks.

Table of contents

  1. Introduction
  2. Create Discord Webhook
  3. Create AWS Lambda Function
  4. Deploy Lambda Function and Create Linear Webhook
  5. Conclusion

Introduction

AWS Lambda is a serverless computing service that charges only for the actual compute time your code uses, with no charge when your code is not running. For infrequent events like Linear webhook updates, this can mean paying just pennies per month or even operating within AWS’s free tier.

In this tutorial, we will be using AWS Lambda to create a super cost-efficient integration between Linear and Discord. We will cover how to create a lambda function in AWS, how to deploy it as an API, and to integrate your Linear workspace and your Discord server using this API in a secure and practical way.

Let's get started!

Create Discord Webhook

Our first step is to create a Discord webhook. As mentioned in the prerequisites, this requires admin permissions to your target server. Assuming you are the admin of this server, simply navigate to "Server Settings" and find the "Integrations" section. Create a new webhook by giving it a name and choosing which channel to send messages to from Linear. Copy and save the webhook url as we will need this in the final step.

Create AWS Lambda Function

We can now move on to creating a serverless lambda function in AWS Console. Just search 'lambda' in the search bar and hit 'Create function' button:

We will not be using any custom container image, so giving a name to our Lambda function and opting for Python 3.12 as the runtime is enough for setting up the Lambda function:

This will create a dummy script, which will be displayed in the code editor. Keep in mind that the script is required to contain a main function named lambda_handler which will process the request body and return the response. We must not alter this function's signature.

We present a sample lambda handler which does not depend on any third party packages so you can copy/paste and use it straight away. For our problem, we will also be using two helper functions: format_discord_message and send_message_to_discord. Let's start with format_discord_message:

def format_discord_message(payload):
    entity_type = payload.get("type", "Unknown")
    action = payload.get("action", "action performed")
    entity_url = payload.get("url", "#")
    created_at = payload.get("createdAt", "unknown")

    if entity_type == "Issue":
        issue = payload['data']
        message = (
            f"🔧 **Issue {action.capitalize()}**: {issue.get('title', 'No title')}\n"
            f"📋 **Description**: {str(issue.get('description', 'No description'))}\n"
            f"📊 **Status**: {issue.get('state', {}).get('name', 'Unknown')}\n"
            f"🔥 **Priority**: {issue.get('priority', 'None')}\n"
            f"👤 **Assignee**: {issue.get('assignee', {}).get('name', 'Unassigned')}\n"
            f"🔗 [View Issue]({entity_url})\n"
            f"⏰ **Updated At**: {issue.get('updatedAt', created_at)}\n"
        )
    elif entity_type == "Comment":
        comment = payload['data']
        message = (
            f"💬 **Comment {action.capitalize()}**\n"
            f"📄 **Comment Body**: {comment.get('body', 'No body')}\n"
            f"🗒️ **Issue ID**: {comment.get('issueId', 'No issue ID')}\n"
            f"👤 **User**: {comment.get('userId', 'Unknown user')}\n"
            f"🔗 [View Comment]({entity_url})\n"
            f"⏰ **Created At**: {created_at}"
        )
    else:
        message = (
            f"📢 **{entity_type} {action.capitalize()}**\n"
            f"⏰ **Created At**: {created_at}\n"
            f"🔗 [View Details]({entity_url})"
        )

    return message

This function takes paylaod of the request body of the linear webhook and processes it to make it a prettified / formatted string. The second and last utility function we use is the send_message_to_discord function. This takes a message as its argument which is of string type. All it does is to send that message in content field of a json to the Discord webhook we created. Pay attention that it uses DISCORD_WEBHOOK_URL which we will define outside the scope of this function.

def send_message_to_discord(message):
    discord_message = {
            "content": message
        }
    data_bytes = json.dumps(discord_message).encode('utf-8')
    req = urllib.request.Request(
        DISCORD_WEBHOOK_URL,
        data=data_bytes,
        headers={'Content-Type': 'application/json', 'User-Agent': 'Mozilla/5.0'},
        method='POST'
    )

    with urllib.request.urlopen(req) as response:
        if response.status == 204:
            return {
                'statusCode': 200,
                'body': json.dumps('Message sent to Discord successfully!')
            }
        else:
            return {
                'statusCode': 500,
                'body': json.dumps(f"Failed to send message: {response.read().decode('utf-8')}")
            }

The main function of the lambda we created is the lambda_handler function, which must be named as this and must have event and context arguments. The request body and headers will be parts of the event:

def lambda_handler(event, context):
    try:
        body_str = event.get('body', '')
        body = json.loads(body_str)
        signature = event['headers'].get('linear-signature', '')

        # check if the signature is valid
        expected_signature = hmac.new(
            key=bytes(LINEAR_WEBHOOK_SECRET, 'utf-8'),
            msg=body_str.encode('utf-8'),
            digestmod=hashlib.sha256
        ).hexdigest()
        if not hmac.compare_digest(signature, expected_signature):
            resp = send_message_to_discord("signature is not valid.")
            return resp

        resp = send_message_to_discord(
            format_discord_message(body)
        )
        return resp

    except Exception as e:
        resp = send_message_to_discord(
            str(f"Ops...: {str(e)}")
        )
        return resp

This function gets first checks the signature provided in the request header is valid, format the request body and send the formatted message to the discord webhook.

Bringing everything together, the whole content of the file should be:

import os 
import json 
import hmac 
import hashlib 
import urllib.request 

LINEAR_WEBHOOK_SECRET = os.environ.get("LINEAR_WEBHOOK_SECRET", "")
DISCORD_WEBHOOK_URL = os.environ.get("DISCORD_WEBHOOK_URL", "")

def send_message_to_discord(message):
    discord_message = { "content": message }
    data_bytes = json.dumps(discord_message).encode('utf-8')
    req = urllib.request.Request(
        DISCORD_WEBHOOK_URL,
        data=data_bytes,
        headers={'Content-Type': 'application/json', 'User-Agent': 'Mozilla/5.0'},
        method='POST'
    )
    with urllib.request.urlopen(req) as response:
        if response.status == 204:
            return {
                'statusCode': 200,
                'body': json.dumps('Message sent to Discord successfully!')
            }
        else:
            return {
                'statusCode': 500,
                'body': json.dumps(f"Failed to send message: {response.read().decode('utf-8')}")
            }

def format_discord_message(payload):
    entity_type = payload.get("type", "Unknown")
    action = payload.get("action", "action performed")
    entity_url = payload.get("url", "#")
    created_at = payload.get("createdAt", "unknown")
    if entity_type == "Issue":
        issue = payload['data']
        message = (
            f"🔧 **Issue {action.capitalize()}**: {issue.get('title', 'No title')}\n"
            f"📋 **Description**: {str(issue.get('description', 'No description'))}\n"
            f"📊 **Status**: {issue.get('state', {}).get('name', 'Unknown')}\n"
            f"🔥 **Priority**: {issue.get('priority', 'None')}\n"
            f"👤 **Assignee**: {issue.get('assignee', {}).get('name', 'Unassigned')}\n"
            f"🔗 [View Issue]({entity_url})\n"
            f"⏰ **Updated At**: {issue.get('updatedAt', created_at)}\n"
        )
    elif entity_type == "Comment":
        comment = payload['data']
        message = (
            f"💬 **Comment {action.capitalize()}**\n"
            f"📄 **Comment Body**: {comment.get('body', 'No body')}\n"
            f"🗒️ **Issue ID**: {comment.get('issueId', 'No issue ID')}\n"
            f"👤 **User**: {comment.get('userId', 'Unknown user')}\n"
            f"🔗 [View Comment]({entity_url})\n"
            f"⏰ **Created At**: {created_at}"
        )
    else:
        message = (
            f"📢 **{entity_type} {action.capitalize()}**\n"
            f"⏰ **Created At**: {created_at}\n"
            f"🔗 [View Details]({entity_url})"
        )
    return message

def lambda_handler(event, context):
    try:
        body_str = event.get('body', '')
        body = json.loads(body_str)
        signature = event['headers'].get('linear-signature', '')

        # check if the signature is valid
        expected_signature = hmac.new(
            key=bytes(LINEAR_WEBHOOK_SECRET, 'utf-8'),
            msg=body_str.encode('utf-8'),
            digestmod=hashlib.sha256
        ).hexdigest()
        if not hmac.compare_digest(signature, expected_signature):
            resp = send_message_to_discord("signature is not valid.")
            return resp

        resp = send_message_to_discord(
            format_discord_message(body)
        )
        return resp

    except Exception as e:
        resp = send_message_to_discord(
            str(f"Ops...: {str(e)}")
        )
        return resp

Please note that LINEAR_WEBHOOK_SECRET and DISCORD_WEBHOOK_URL environment variables must be set for this function to work properly. We will be defining these later on.

Deploy Lambda Function and Create Linear Webhook

We have now successfully created our lambda function. The next step is to attach a API Gateway trigger to be able to send requests to this Lambda function using a fixed url.

To do this, click "+ Add trigger" and choose "API Gateway", set security to "Open" and hit "Add":

Now any http request sent to the url of the API Gateway endpoint will trigger the Lambda function. Note that this url is presented in the "API endpoint" copy that url for the next step.

As the very last step, we will create the Linear webhook and configure the Lambda function with necessary environment variables. In your linear workspace, go to "Workspace Settings" and navigate to the "API" section. There, we need to create a new webhook by defining a label, setting the webhook url to that of the API Gateway that you just copied. For this tutorial, we implemented only "Issue" and "Comment" events, so let's opt for only those. The webhook creation gives us a "Signing secret", copy and save it for the last step.

Now that we have the discord webhook url and Linear webhook secrets, let's define those environment variables in the lambda function using the code editor's "Environment Variables" menu on the buttom-left corner and deploy the Lambda function.

Everything is all set! Now, whenever there is an update to a Linear issue, the Discord server will receive the update almost instantly:

Conclusion

We hope you found this useful! We went through a step-by-step tutorial on how to set up an AWS Lambda function with API Gateway to process Linear webhooks and send real-time notifications to a Discord server. The initial implementation of the AWS Lambda function is designed to handle basic Linear webhook types, such as issues and comments. However, Linear provides webhooks for various other events, including project updates, cycle changes, and more. Adapting the current solution to accommodate a broader range of webhooks is straightforward and simply requires updating format_discord_message function inside the lambda handler.

With AWS’s pricing model, the cost-effectiveness becomes more obvious. The API Gateway free tier allows up to 1 million calls per month at no charge, and AWS Lambda’s pricing of $0.0000000021 per millisecond for 128 MB RAM results in minimal expenses for most use cases. For instance, if each invocation of the Lambda function has a billed duration of 1000 ms and uses 50 MB RAM, handling 1000 webhook calls would cost approximately $0.0021. (~50 MB RAM is the peak RAM usage and ~1000ms is the duration we measured during the tests.)

Moreover, this setup delivers notifications almost instantly, bypassing the delays commonly experienced with third-party integration tools.

To stay tuned for in-depth tutorials and technical blog posts, follow us on Twitter: @devmhat, @neuralwork, and LinkedIn.

Read more