Writing Webhooks with Spin

Webhooks provide a simple tool for integrating with services like GitHub, Trello, Slack, and Discord. The system is a simple way of linking a service event (a new issue being opened, a card being moved…) with an action on a remote URL. In this article, we’ll create a Spin application that uses webhooks to link two services together:

  • Trello sends an event when a card is moved
  • Spin accepts the event, and then sends a message to a Slack channel
  • Slack receives and displays the message

We’ll be building a simple Spin app called “Bookhook” to track a reading list. Bookhook will receive a notification from Trello any time I change my reading list, and then will send a notification to Slack to keep my friends updated on what I am reading. In the process, we’ll see an example of an inbound webhook (where we write the listener) and an outbound webhook (where we call someone else’s endpoint). While we’ll be writing some simple Rust code, the principles are the same in any programming language.

Before diving into this tutorial, you may prefer to check out this short video that shows the code in action. Then read on for the details of how we built the “Bookhook” webhook application.

What is a Webhook?

A webhook is a simple mechanism by which an event in one web service results in calling a URL to another web service.

  • An inbound webhook requires you to write the application that receives data from a remote service. We will make an inbound webhook for Trello.
  • An outbound webhook requires you to write code that sends data to a remote URL when your app experiences an event.

We will write code that watches a Trello board for an inbound webhook and then calls an outbound webhook to send a message to Slack.

Setting Up Spin

Before we get into the details of this tutorial, there are a few prerequisites. We’ll be using Rust for this tutorial.

Prerequisites

  • Make sure Spin is installed
  • Install Rust
  • Install the wasm32-wasi Rust target: rustup target add wasm32-wasi

A Public Server

If you are receiving an inbound webhook, you are going to need to be running your Spin service on a public server. That is, it must be accessible on the public internet. One quick way to get started is to set up a Digital Ocean Droplet with Spin.

Spin Setup

In addition to having Spin and Rust installed, we’ll do just a little bit of extra work to make it easier on ourselves. Specifically, we’ll install some Spin starter templates.

If you haven’t done so already, install the basic Spin starter templates:

$ spin templates install --git https://github.com/fermyon/spin
Copying remote template source
Installing template redis-rust...
Installing template http-rust...
Installing template http-go...
Installed 3 template(s)

+--------------------------------------------------+
| Name         Description                         |
+==================================================+
| http-go      HTTP request handler using (Tiny)Go |
| http-rust    HTTP request handler using Rust     |
| redis-rust   Redis message handler using Rust    |
+--------------------------------------------------+

This will make it quick and easy to create a new project.

Template support was added in Spin v0.2.0.

To get started, let’s create a directory to hold the new project:

$ mkdir bookhook
$ cd bookhook

Now we will run spin new http-rust to create our bookhook webhook application. Spin will prompt with several questions. Here’s how I answered:

$ spin new http-rust
Project description: Webhook for notifying people about my reading list
Project name: bookhook
HTTP base: /
HTTP path: /...

With that done, we have a nicely scaffolded project:

$ tree .
.
├── Cargo.toml
├── spin.toml
└── src
    └── lib.rs

1 directory, 3 files

The initial configuration required for all Spin apps has been done for us. So with that, we’re ready to start coding in the src/lib.rs file.

Trello Setup

The first part of our application will receive an inbound webhook from Trello. That means we need to set up a Trello board.

I created a reading board. It has three columns:

  1. To read: Stuff I want to read at some point
  2. Reading: What I am currently reading
  3. Done: Books I have now finished

I made the board public so you can take a look.

I started the board with all of the books in the To read column so that we have something to test with as we get going.

In a few minutes, we’ll hook up the Trello board to our app. Now it’s time to write some code.

The Inbound Webhook

To get started, we are just going to accept an inbound webhook and print the content to the log.

use anyhow::Result;
use spin_sdk::{
    http::{Request, Response},
    http_component,
};

/// A simple Spin HTTP component.
#[http_component]
fn bookhook(req: Request) -> Result<Response> {
    println!("{:?}", req.body());
    Ok(http::Response::builder()
        .status(200)
        .body(Some("Received".into()))?)
}

Really, we just removed a few things from the auto-generated code. So nothing here should be surprising.

All we do is print the body we received, and then return an HTTP 200 OK with the body Received.

Compiling and starting a server with spin build --up, we can test our work so far:

$ curl localhost:3000 -d "Hello there"
Received

(The -d flag sends the string Hello there as the request body)

If we look at the bookhook_stdout.txt log, we’ll see the message Some(b"Hello there"), which tells us that the server received an optional value for the body, and that value was Hello there. All is as it should be.

The default location for logs will be $HOME/.spin/bookhook/logs

Next, let’s connect our board by telling Trello that we want to register this as a webhook. Remember that we need a publicly accessible endpoint to run this because Trello will need to be able to reach our service. There are many options for making your service publicly available. One is to create a Digital Ocean Droplet running Spin. That is how I am running things for this tutorial.

Registering a Webhook with Trello

Creating a webhook in Trello is a manual affair. And it is tedious.

The first thing you will need is an API token. You can generate one from your Trello API Key page. Then you will need to generate your own API Token by substituting that API Key for {YourAPIKey} in the URL below. Then follow that URL.

https://trello.com/1/authorize?expiration=1day&name=MyPersonalToken&scope=read&response_type=token&key={YourAPIKey}

If at any point this gets confusing, the Trello docs are great. At the end of this process, you should have:

  • A token for your permissions
  • A key for your account.

A token and key are as sensitive as a username and password. Treat them accordingly.

You need one more piece of information before creating the webhook, and that is the ID of your Trello board. The best way to get this information is from a request against the Trello API.

$ curl https://api.trello.com/1/members/me/boards?fields=name,url&key={apiKey}&token={apiToken}

(Again, replace {apiKey} and {apiToken} with your token and key.)

Find the board by name, and then get the board ID from the id field.

Next, you will need to register your webhook. This is also a manual process. The easiest way to do it is with curl. Send a request to create a webhook using your API token and key, as well as the ID from your Trello board.

Substitute the following information in the command below:

  • {APIToken} should be your token
  • {APIKey} should be your key
  • {Callback} should be the URL to your Spin app that we just created above. Remember that it must be on a public IP.
  • {BoardID} is the ID for your board (which you retrieved in the previous step)
$ curl -X POST -H "Content-Type: application/json" \
https://api.trello.com/1/tokens/{APIToken}/webhooks/ \
-d ‘{
  "key": "{APIKey}",
  "callbackURL": "{Callback}",
  "idModel":"{BoardID}",
  "description": "My first webhook"
}’

Note that your Spin instance must be running before you make this call, for it may trigger a request from Trello to your server.

Once you have a success response from the curl command above, it’s time to move on to the next step.

A Look at the Webhook Data

At this point, you should be able to see the JSON data logged in your bookhook-stdout.txt log. You should see something like this (probably all on one line):

Some(b"")

This means that we got an empty response from Trello, which is totally fine right now.

Next, we can head back over to the Trello board and drag a card from one column to another. I am going to drag Frankenstein to Reading. Now a look at the logs will show a gigantic JSON record. Let’s trim it down to just the part we care about:

{
    "action": {
        "data": {
            "card": {
                "name": "Frankenstein, Shelley",
            },
            "listBefore": {
                "name": "To Read"
            },
            "listAfter": {
                "name": "Reading"
            }
        },
    }
}

In the example above, we trimmed out hundreds of fields. The Trello API docs provide descriptions of every field in the JSON. For us, we just want to find out what card moved where. So we only need a tiny bit.

Now in Rust, we’ll model just the information we need. Since we will be parsing JSON data, add the following two lines to Cargo.toml’s dependencies list:

serde = {version = "1.0", features=["derive"]}
serde_json = "1.0"

And now in lib.rs, we can add the new structs we need to model the JSON above.

#[derive(Deserialize)]
struct Webhook {
    action: Action,
}

#[derive(Deserialize)]
struct Action {
    data: Data,
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Data {
    card: Item,
    list_before: Option<Item>,
    list_after: Option<Item>,
}

#[derive(Deserialize)]
struct Item {
    name: String,
}

That gives us a minimal set of data for processing our webhook. Now we’ve only got one part of code left. Let’s rewrite our main function, breaking it into two:

use anyhow::Result;
use serde::Deserialize;
use spin_sdk::{
    http::{Request, Response},
    http_component,
};

/// A simple Spin HTTP component.
#[http_component]
fn bookhook(req: Request) -> Result<Response> {
    
    // Try to handle the webhook data that we received.
    // But if anything goes wrong, log the error and move on.
    if let Err(e) = handle_webhook(req) {
        eprintln!("Error handling webhook: {}", e)
    }

    // We simply need to tell Trello that we received the data.
    // Trello does not care if we succeeded. So we always return 200.
    Ok(http::Response::builder()
        .status(200)
        .body(Some("OK".into()))?)
}

/// Handle the webhook request from Trello
fn handle_webhook(req: Request) -> Result<()>{
    // Get and parse the body
    let body = req.body().clone().unwrap_or_default();
    let webhook: Webhook = serde_json::from_slice(&body)?;

    // Print out a string with the data we care about.
    let data = webhook.action.data;
    let old_list = data.list_before.map(|i| i.name).unwrap_or_else(||"Unknown list".to_string());
    let new_list = data.list_after.map(|i| i.name).unwrap_or_else(||"Unknown list".to_string());
    let msg = format!("Moved '{}' from '{}' to '{}'", data.card.name, old_list, new_list);

    Ok(())
}

This code tries to parse the incoming webhook event and then print a message to the log. If anything fails, it logs an error but returns a 200 message to Trello. (Why? Because Trello only cares that we got the payload, not that we correctly processed it.)

Using spin build —up —listen {YourIP}:3000 (where {YourIP} is your server’s public IP address), we can start the server and then go back to Trello and drag a card.

If this is successful, you’ll see something like this in the bookhook-stdout.txt log:

Moved 'Pride and Prejudice, Austen' from 'To Read' to 'Reading'

Success! We have now written an inbound webhook. Now we can create an outbound webhook that will send a message to Slack.

Setting Up Inbound Slack Webhooks

While configuring webhooks for Trello is a labor-intensive manual process, setting one up in Slack is easy. The directions on Slack’s website walk you through the process. Follow the instructions there to:

  1. Create a new Slack App
  2. Click on the Incoming Webhooks button
  3. Click on Add New Webhook to Workspace
  4. Choose a channel to send a message to.

At the end of this workflow, it will give you a simple curl command that looks something like this:

curl -X POST -H 'Content-type: application/json' --data '{"text":"Hello, World!"}' https://hooks.slack.com/services/AAAAAA/BBBBBBBBB/CCCCCCC

If you run that command, you should see the message Hello, World! in the Slack channel you just configured.

Setting up Slack webhooks is easy. Now we have just one more minor modification to our Rust code.

Calling an Outbound Webhook in Rust

Now to run the entire thing, we need to add an environment variable to Spin:

$ spin build --up --listen {YourIP}:3000 --env SLACK_URL=https://hooks.slack.com/services/AAAAAA/BBBBBBBBB/CCCCCCC

(Don’t forget to add your server’s public IP in {YourIP} and set SLACK_URL to the URL that Slack gave you.)

Using an environment variable allows us to avoid hard-coding a security-sensitive URL into our application code.

We also need to tell Spin that it’s okay for our module to send HTTP requests to Slack. This is part of the security model of WebAssembly. To do this, we’ll open spin.toml and add allowed_http_hosts for the Slack webhook URL.

spin_version = "1"
authors = ["root"]
description = "Webhook for notifying people about my reading list"
name = "bookhook"
trigger = { type = "http", base = "/" }
version = "0.1.0"

[[component]]
id = "bookhook"
source = "target/wasm32-wasi/release/bookhook.wasm"
## RIGHT HERE!!! ################################
## This allows us to contact Slack
allowed_http_hosts = ["https://hooks.slack.com/"]
#################################################
[component.trigger]
route = "/..."
[component.build]
command = "cargo build --target wasm32-wasi --release"

With that, we have only one more set of edits to do in our code. We will update our handle_webhook() function to send a message to Slack. That message must be encoded in JSON, so we add one more small struct called SlackMessage.

// Handle the webhook request from Trello
fn handle_webhook(req: Request) -> Result<()>{
    // Get and parse the body
    let body = req.body().clone().unwrap_or_default();
    let webhook: Webhook = serde_json::from_slice(&body)?;
    
    // Create a string to send to Slack
    let data = webhook.action.data;
    let old_list = data.list_before.map(|i| i.name).unwrap_or_else(||"Unknown list".to_string());
    let new_list = data.list_after.map(|i| i.name).unwrap_or_else(||"Unknown list".to_string());
    let msg = format!("Moved '{}' from '{}' to '{}'", data.card.name, old_list, new_list);
    let slack_msg = SlackMessage{text: msg};
    let body = serde_json::to_string(&slack_msg)?;

    // Get the Slack webhook URL from an environment variable
    let slack_url = std::env::var("SLACK_URL")?;

    // Send it to Slack
    let mut _res = spin_sdk::outbound_http::send_request(
        http::Request::builder()
            .method("POST")
            .uri(slack_url)
            .body(Some(body.into()))?,
    )?;

    Ok(())
}

#[derive(Serialize)]
struct SlackMessage {
    text: String,
}

Once more, we need to spin build --up:

$ spin build --up --listen {YourIP}:3000 --env SLACK_URL=https://hooks.slack.com/services/AAAAAA/BBBBBBBBB/CCCCCCC

With this all wired up, we can drag cards from column to column in Trello and watch messages appear in Slack. Again, check out the video to see this in action.

Conclusion

While setting up webhooks can be a bit tedious, the Spin code is nice and compact. It took fewer than 100 lines of code to receive data from Trello, format it, and send it to Slack — all using webhooks.

If you enjoyed our video, please subscribe to our YouTube channel.

Interested in learning more?

Get Updates