Source Code

This is a step by step guide on how to receive webhooks from QStash in your Lambda function on AWS.

1. Create a new project

Let’s create a new folder called aws-lambda and initialize a new project with your favourite package manager. This example uses npm because everyone will have it installed with node already.

mkdir aws-lambda
cd aws-lambda
npm init -y

2. Install Dependencies

Using typescript requires installing a few dependencies. Here we are using esbuild to bundle our code, so we can upload it to AWS later.

npm install -D @types/aws-lambda @types/node esbuild

3. Creating the handler function

In this example we will show how to receive a webhook from QStash and verify the signature without any additional dependencies.

First, let’s import everything we need:

import type {
  APIGatewayEvent,
  APIGatewayProxyResult,
  Context,
} from "aws-lambda";
import { createHash, createHmac } from "node:crypto";

As you can see, we only need the type definitions from AWS and some crypto functions from the node standard library.

Next we create the handler function. Ignore the verify function for now. We will add that next. In the handler we will prepare all necessary variables that we need for verification. This includes the signature, the signing keys and the url of the lambda function. Then we try to verify the request using the current signing key and if that fails we will try the next one. If the signature could be verified, we can start processing the request.

export const handler = async (
  event: APIGatewayEvent,
  context: Context
): Promise<APIGatewayProxyResult> => {
  const signature = event.headers["upstash-signature"]!;
  const currentSigningKey = process.env["QSTASH_CURRENT_SIGNING_KEY"];
  const nextSigningKey = process.env["QSTASH_NEXT_SIGNING_KEY"];
  const url = `https://${event.requestContext.domainName}`;

  try {
    // Try to verify the signature with the current signing key and if that fails, try the next signing key
    // This allows you to roll your signing keys once without downtime
    await verify(signature, currentSigningKey, event.body, url).catch((err) => {
      console.error(
        `Failed to verify signature with current signing key: ${err}`
      );
      return verify(signature, nextSigningKey, event.body, url);
    });
  } catch (err) {
    return {
      statusCode: 400,
      body: err instanceof Error ? err.toString() : err,
    };
  }

  // Add your business logic here

  return {
    statusCode: 200,
    body: "OK",
  };
};

The verify function will handle the actual verification of the signature. The signature itself is actually a JWT and includes claims about the request. See here.

/**
 * @param jwt - The content of the `upstash-signature` header
 * @param signingKey - The signing key to use to verify the signature (Get it from Upstash Console)
 * @param body - The raw body of the request
 * @param url - The public URL of the lambda function
 */
async function verify(
  jwt: string,
  signingKey: string,
  body: string | null,
  url: string
): Promise<void> {
  const split = jwt.split(".");
  if (split.length != 3) {
    throw new Error("Invalid JWT");
  }
  const [header, payload, signature] = split;

  if (
    signature !=
    createHmac("sha256", signingKey)
      .update(`${header}.${payload}`)
      .digest("base64url")
  ) {
    throw new Error("Invalid JWT signature");
  }

  // Now the jwt is verified and we can start looking at the claims in the payload
  const p: {
    sub: string;
    iss: string;
    exp: number;
    nbf: number;
    body: string;
  } = JSON.parse(Buffer.from(payload, "base64url").toString());

  if (p.iss !== "Upstash") {
    throw new Error(`invalid issuer: ${p.iss}, expected "Upstash"`);
  }
  if (p.sub !== url) {
    throw new Error(`invalid subject: ${p.sub}, expected "${url}"`);
  }

  const now = Math.floor(Date.now() / 1000);
  if (now > p.exp) {
    throw new Error("token has expired");
  }
  if (now < p.nbf) {
    throw new Error("token is not yet valid");
  }

  if (body != null) {
    if (
      p.body.replace(/=+$/, "") !=
      createHash("sha256").update(body).digest("base64url")
    ) {
      throw new Error("body hash does not match");
    }
  }
}

You can find the complete file here.

That’s it, now we can create the function on AWS and test it.

4. Create a Lambda function on AWS

Create a new Lambda function from scratch by going to the AWS console. (Make sure you select your desired region)

Give it a name and select Node.js 16.x as runtime, then create the function.

Afterwards we will add a public URL to this lambda by going to the Configuration tab:

Select Auth Type = NONE because we are handling authentication ourselves.

After creating the url, you should see it on the right side of the overview of your function:

5. Set Environment Variables

Get your current and next signing key from the Upstash Console

On the same Configuration tab from earlier, we will now set the required environment variables:

6. Deploy your Lambda function

We need to bundle our code and zip it to deploy it to AWS.

Add the following script to your package.json file:

{
  "scripts": {
    "build": "rm -rf ./dist; esbuild index.ts --bundle --minify --sourcemap --platform=node --target=es2020 --outfile=dist/index.js && cd dist && zip -r index.zip index.js*"
  }
}

When calling npm run build this will build and zip the code.

Afterwards we can click the Upload from button in the lower right corner and deploy the code to AWS. Select ./dist/index.zip as upload file.

7. Publish a message

Open a different terminal and publish a message to QStash. Note the destination url is the URL from step 4.

curl --request POST "https://qstash.upstash.io/v2/publish/https://aacn3pedteibt5gg72xr77wnma0poteq.lambda-url.us-east-1.on.aws" \
     -H "Authorization: Bearer <QSTASH_TOKEN>" \
     -H "Content-Type: application/json" \
     -d "{ \"hello\": \"world\"}"

Next Steps

That’s it, you have successfully created a secure AWS lambda function, that receives and verifies incoming webhooks from qstash.

Learn more about publishing a message to qstash here

QStash SDK

Our Typescript Sdk will handle verification automatically if you want to use it.

import { Receiver } from "@upstash/qstash";

const r = new Receiver({
  currentSigningKey: process.env["QSTASH_CURRENT_SIGNING_KEY"],
  nextSigningKey: process.env["QSTASH_NEXT_SIGNING_KEY"],
});

await r.verify({
  signature: event.headers["upstash-signature"],
  body: event.body,
  url: `https://${event.requestContext.domainName}`,
});