How to add functions in ClickUp

Automations with Azure Functions

In this article we explain how we use Azure Functions to extend ClickUp with automations that cannot be realized with the ClickUp Automations feature. We cover the setup process of Azure Functions and ClickUp Webhooks and the resulting cost. The example is written in python but can be programmed in any available programming language for Azure Functions.

Use Case

Before a software is developed, it's important to think about the problem you want to solve with this software. We want to automate a previously manual step when creating task representing an abscence (we track holidays, sickdays and other off days with ClickUp tasks). This cannot be achieved with ClickUp Automations because we cannot create an ClickUp Automation that assigns tags based on the task name. When a task is created in the abscence-list, the following thing should happen:

  1. Is no tag assigned, a new tag is assigned based on the task name
    • when the task name contains "illness" or similar keywords, "krankenstand" (german word for sick day) is assigned as tag
    • when the task name contains "holiday" or similar keywords, "urlaub" (german word for vacation) is assigned as tag
    • when the task name contains phrases associated with special leave (birth of a child, moving day, etc.), "ef" is assigned as tag
  2. Is the task not assigned to someone, it is automatically assigned to the person creating the task

Implementation

To implement automations, it is possible to setup webhooks in ClickUp. This can either be done using a ClickUp Automation or the webhook API. As we want to keep the Azure Function independent of the ClickUp settings we are going to use the webhook API (this will have an impact on the code. see Update task).

Setting Up the Azure Function

For setting up the Azure Function we use the official Microsoft documentation. As trigger we are using a HTTP Trigger and for the authLevel setting we use anonymous. As ClickUp webhooks only send HTTP POST requests, we remove the get method from function.json. Other than that, all the auto generated settings stay the same.

Setting Up the Webhook

A ClickUp Webhook that is created using the API is secured by a signature that comes with the request to verify that this request really originiates from ClickUp. To use the webhook spanning multiple function executions we need to define it outside the main function. The following steps are therefore necessary when the Function App starts.

Setting up the ClickUp API

Most API endpoints need an authentications header as well as a team ID. The authentication method can be different depending on the use case (see jsapi.apiary.io). While developing and testing, the personal token system is preferable to rule out errors in the authentification process. To request the team ID from ClickUp we use the ClickUp Teams endpoint.

Loading an existing webhook

If there already is a webhook registered, we can get it with the ClickUp API. We asume that there is only one webhook per endpoint, otherwise the following function must be able to handle multiple webhooks per endpoint:

def get_webhook():
    data = clickup.get_webhooks()
    for webhook in data["webhooks"]:
        if "endpoint" in webhook and webhook["endpoint"] == "myEndpointURL":
            return webhook
    return None

Creating a new webhook

In case there is no webhook for our endpoint, we need to create a new one:

def create_webhook():
    # this webhook should be triggered when a task is created
    values = {
        "endpoint": "myEndpointURL",
        "events": [
            "taskCreated"
        ]   
    }
    
    # clickup is a wrapper around the API calls 
    resp = clickup.post_webhook(json.dumps(values))
    if resp.status_code == 200:
        data = json.loads(resp.text)
        if "webhook" in data:
            return data["webhook"]
    return None

Removing existing webhooks

If there was an error during loading and creating a new webhook, we need a way to clean up. This way we make sure that we don't have any webhooks registered to a failing function. To do this, we write a function that removes all webhooks registered for this endpoint:

def cleanup_webhooks():
    # clickup is a wrapper around the API calls
    data = clickup.get_webhooks()
    for webhook in data["webhooks"]:
        if "endpoint" in webhook and webhook["enpoint"] == "myEndpointURL":
            clickup.delete_webhook(webhook["id"])

Read Environment Variables

Settings and secrets can be saved as environment variables for Azure Functions. Together with the already defined function the code running outside of the main function looks like this:

token = os.getenv("TOKEN", "")
urlaub_list_id = os.getenv("URLAUB_LIST_ID", "")
bitperfect_space_id = os.getenv("BITPERFECT_SPACE_ID", "")

clickup = ClickupAPI(token, bitperfect_space_id)
task_webhook = get_webhook()
if not task_webhook:
    task_webhook = create_webhook()
    if not task_webhook:
        cleanup_webhooks()
        raise EnvironmentError("could not establish webhook")

Executing the function

The code discussed above is only executed once during the initial start of the Azure Function App. In the following we look at the code that will be executed every time the Azure Function is triggered.

Check Authorization

Before we look at the request in detail, we need to check if it originates at a registered webhook. We define the verfiy_signature function for this purpose:

def verify_signature(msg, sec, sig):
    message = bytes(msg, 'utf-8')
    secret = bytes(sec, 'utf-8')
    hash = hmac.new(secret, message, hashlib.sha256)
    return sig == hash.hexdigest()

Using this verify_signature function we can setup the main function like this:

def main(req: func.HttpRequest) -> func.HttpResponse:
    logging.info("Function is beeing executed")
    signature = req.headers.get("X-Signature")
    body = req.get_json()
    if signature:
        if not verify_signature(json.dumps(body, separators=(',', ':'), ensure_ascii=False), task_webhook["secret"], signature):
            logging.warning(f"Request with Invalid Signature: {req}")
            # to prevent external services to try accessing the endpoint again we send status_code 200
            return func.HttpResponse("Signature Invalid", status_code=200)
        else:
            request_handler = AbscenseRequestHandler(body, clickup, list_id)
            if request_handler.handle():
                logging.info("Request handled successfully")
                return func.HttpResponse("ok", status_code=200)
            else:
                logging.warning(f"Error while handling request: {body}")
                # this is status_code 200 because otherwise we would receive the same request again
                return func.HttpResponse("unable to handle request", status_code=200)
    else:
        logging.warning("Request without Signature received")
        return func.HttpResponse("Unauthorized", status_code=401)

Update task

After a successful authorization in the main function we instanciate a AbscenseRequestHandler. This class is used to handle an incoming request from this endpoint (handle method). First we check if the task is part of the abscense-list because this automation should only change tasks in this list. If that's the case, we assign tags based on previously defined keywords and afterwards assign the task to a person, if that is not already the case. Our class looks like this:

class AbscenseRequestHandler:
    def __init__(self, request_body, clickup, urlaub_list_id):
        self.request_body = request_body
        self.clickup: ClickupAPI = clickup
        self.urlaub_list_id = urlaub_list_id
        self.urlaub_keywords = ["urlaub", "holiday"]
        self.illness_keywords = ["krank", "krankenstand", "illness"]
        self.ef_keywords = ["ef", "erlaubte fehlstunden", "fehlstunden", "erlaubt", "geburt", "umzug", "tod", "beerdigung"]

    def handle(self):
        if self.is_in_abscence_list():
            task = self.clickup.get_task(self.request_body["task_id"])
            if not self.set_tag(task):
                return False
            if not self.add_assignee(task):
                return False
            return True
        else:
            return False

    def set_tag(self, task):
        if self.is_in_list(self.urlaub_keywords, task["name"].lower()):
            return self.clickup.add_tag(task["id"], "urlaub")
        elif self.is_in_list(self.illness_keywords, task["name"].lower()):
            return self.clickup.add_tag(task["id"], "krankenstand")
        elif self.is_in_list(self.ef_keywords, task["name"].lower()):
            return self.clickup.add_tag(task["id"], "ef")
        else:
            return True

    @staticmethod
    def is_in_list(keywords, name):
        for keyword in keywords:
            if keyword in name:
                return True
        return False

    def add_assignee(self, task):
        if not task["assignees"]:
            return self.clickup.update_tasks(task["id"], json.dumps({"assignees": {"add": [task["creator"]["id"]]}}))
        return True

    def is_in_abscence_list(self):
        if "history_items" in self.request_body:
            for entry in self.request_body["history_items"]:
                return "parent_id" in entry and entry["parent_id"] == self.urlaub_list_id
        return False

Deployment

The Azure Function is deployed with Visual Studio Code or using a Github Action. Now tasks that are created in the defined list are automatically given the correct tag and can no longer be unassigned.

Cost

The cost of these automations always depends on the complexity of the executed function. We recommend to track the average duration of the Azure Function in the Azure Portal. This value can then be entered in the pricing calculator. In our case this function is executed less than 1000 times a month and takes on average less than 1000ms. This means no cost is accumulated.

Do you need assistance automating your processes? Reach out to schedule an informal meeting.