Serverless Stripe Webhooks on AWS with Lambda Function URLs

Hoang Dinh
ITNEXT
Published in
6 min readApr 10, 2023

--

Photo by Glenn Carstens-Peters on Unsplash

Serverless Typescript

We will start with aws-nodejs-typescript template. And we need to customize the default template to support local development and debugging.

Clone Typescript Template

Let’s start with the latest serverless version

npm install -g serverless
sls create --template aws-nodejs-typescript --path stripe-webhooks
cd stripe-webhooks
npm ci

stripe-webhooks is project directory.

Open the project with your favorite IDE.

serverless-offline

We will install serverless-offline and make sure that we can trigger a function via an HTTP method

npm install serverless-offline -D

then update plugins path of serverless.ts to add the new plugin to the plugin list:

  plugins: [
'serverless-esbuild',
'serverless-offline',
],

You can custom serverless offline command option by adding serverless-offline property to serverless.ts ‘s custom :

custom: {
esbuild: {
bundle: true,
minify: false,
sourcemap: true,
exclude: [
'aws-sdk',
'@aws-lambda-powertools/logger',
],
target: 'node14',
define: { 'require.resolve': undefined },
platform: 'node',
concurrency: 10,
watch: {
pattern: ['src/**/*.ts'],
ignore: ['.build', 'dist', 'node_modules', '.serverless'],
},
} as EsbuildOptions,
'serverless-offline': {
noPrependStageInUrl: true,
reloadHandler: true,
},
},

watch watch options for serverless-offline , we want to rebuild the project when we save any ts file.

reloadHandle Reloads handler with each request. We need to enable this option to make watch option make sense.

You can add a script to package.json to create a shortcut to start offline mode:

"scripts": {
"start": "sls offline"
},

From now, we can start local server with the start command

npm start

Let’s verify our setup by calling POST /hello with a json object as a request body:

curl -X POST --location "http://localhost:3000/hello" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"Foo\"
}"

aws-lambda-powertools/logger

Powertools is a developer toolkit to implement Serverless best practices and increase developer velocity.

In our project, we will integrate logger util. You can read more about logger at this official link.

In general, we have to install `@aws-lambda-powertools/logger` package, config logging, and inject the logger to middy middleware.

npm install @aws-lambda-powertools/logger -D

We install the package as a dev dependency because AWS provides Lambda Powertools Lambda Layer and you can inject the layer into your project by using their Layer ARN: https://awslabs.github.io/aws-lambda-powertools-typescript/latest/#lambda-layer.

This means we will update serverless.ts to add a layer to our functions and exclude the new package.

Add layers to provider block (my aws region is ap-northest-1):

    layers: [
'arn:aws:lambda:ap-northeast-1:094274105915:layer:AWSLambdaPowertoolsTypeScript:10',
],

and add `@aws-lambda-powertools/logger` to custom.esbuild.exclude :

      exclude: [
'aws-sdk',
'@aws-lambda-powertools/logger',
],

Now, let’s config the logger, in this story, I will config the logger by setting environment variables. Add these env to provider.environment of serverless.ts

      LOG_LEVEL: 'INFO',
POWERTOOLS_SERVICE_NAME: 'stripe-webhooks',
POWERTOOLS_LOGGER_LOG_EVENT: 'true',

NOTE: `POWERTOOLS_LOGGER_LOG_EVENT` will log all incoming events, just consider turning this option off when your event may include sensitive data.

The last step is injecting the logger into middy. I will create logger.ts at libs directory.

import { Logger } from '@aws-lambda-powertools/logger';

export default new Logger();

and update libs/lambda.ts :

import middy from '@middy/core';
import middyJsonBodyParser from '@middy/http-json-body-parser';
import { injectLambdaContext } from '@aws-lambda-powertools/logger';
import logger from '@libs/logger';

export const middyfy = (handler) => {
return middy(handler).use([
injectLambdaContext(logger),
middyJsonBodyParser(),
]);
};

Now, when you call the hello api, you can see a log will be printed out that includes your request information.

Stripe webhooks with Lambda Function URL

Webhooks lambda function

Make sure that you have enough knowledge about webhook, nodejs, or Stripe Webhooks.

In this part, we will create a lambda function to handle Stripe webhook event. Our lambda function is a lambda function URL, which means we can trigger the function via a url without AWS API Gateway.

To work with Stripe webhooks we need Stripe's Secret key and Webhook signing secret.

You can get the secret key on your Stripe developer page. And the signing, you can get it when you run stripe listen command.

Make sure that you already install Stripe CLI.

Let’s build a simple handle function to handle Stripe webhook events, we can follow this official document

First, we need to install Stripe api node package:

npm install stripe

add a new handle function at src/functions/webhooks/handler.ts

import { middyfy } from '@libs/lambda';
import Stripe from 'stripe';
import * as process from 'process';
import { APIGatewayProxyHandler } from 'aws-lambda';
import logger from '@libs/logger';

const webhooks: APIGatewayProxyHandler = async (event) => {
try {
const endpointSecret = process.env.STRIPE_WEBHOOK_SIGNING_SECRET;
const signature = event.headers['Stripe-Signature'];

const stripe = new Stripe(
process.env.STRIPE_SECRET_KEY,
{
apiVersion: '2022-11-15',
},
);

const stripeEvent = stripe.webhooks.constructEvent(
event.body,
signature,
endpointSecret,
);

if (stripeEvent.type === 'payment_intent.succeeded') {
const paymentIntent = stripeEvent.data.object as Stripe.PaymentIntent;
logger.info(`PaymentIntent for ${paymentIntent.amount} was successful!`);
}

return {
body: '',
statusCode: 200,
};
} catch (error) {
logger.error(`⚠️ Webhook signature verification failed: ${error.message}`);
return {
body: '',
statusCode: 400,
};
}
};

export const main = middyfy(webhooks);

The handle type is `APIGatewayProxyHandler` instead of `ValidatedEventAPIGatewayProxyEvent` because in this example we want to parse the request body by stripe.webhooks.constructEvent function, then the body should be a JSON string.

We get the request signature from the request header by Stripe-Signature key, not stripe-signature .

And this function requires 2 environment variables: STRIPE_SECRET_KEY , STRIPE_SIGNING_SECRET .

Now, we have to config the webhook lambda function. We will define the function as a lambda function URL.

src/functions/webhooks/index.ts

import { handlerPath } from '@libs/handler-resolver';
import type { AWS } from '@serverless/typescript';
import * as process from 'process';

export default {
handler: `${handlerPath(__dirname)}/handler.main`,
url: true,
...(
process.env.IS_OFFLINE
? {
events: [
{
http: {
method: 'post',
path: 'webhooks',
},
},
],
}
: {}
),
} as AWS['functions'][0];

Serverless-offline library does not support URL mode yet, then there is a trick to make the function can be tested locally, we define an HTTP event to trigger the function. The IS_OFFLINE environment variable should be defined at compile time.

Don’t forget to export the function in the functions index file.

export { default as webhooks } from './webhooks';

As I mentioned before, we don't need to parse the event body by ourselves, then we will reject `middyJsonBodyParser` from middy:

// src/libs/lambda.ts
import middy from '@middy/core';
import { injectLambdaContext } from '@aws-lambda-powertools/logger';
import logger from '@libs/logger';

export const middyfy = (handler) => {
return middy(handler).use([
injectLambdaContext(logger),
]);
};

Go to serverless.ts file, and update environments and functions parts:

import { webhooks } from '@functions/index';

// ...
environment: {
AWS_NODEJS_CONNECTION_REUSE_ENABLED: '1',
NODE_OPTIONS: '--enable-source-maps --stack-trace-limit=1000',
LOG_LEVEL: 'INFO',
POWERTOOLS_SERVICE_NAME: 'stripe-webhooks',
POWERTOOLS_LOGGER_LOG_EVENT: 'false',
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SIGNING_SECRET: process.env.STRIPE_WEBHOOK_SIGNING_SECRET,
},
// ...
functions: {
webhooks,
},
// ...

We have to provide some env, so I choose dotenv to control them on the local side:

npm install dotenv -D

and this is .env template:

IS_OFFLINE=true
STRIPE_SECRET_KEY=sk_live_YOUR_SECRET_KEY
STRIPE_WEBHOOK_SIGNING_SECRET=whsec_STRIPE_WEBHOOK_SIGNING_SECRET

Update package json’s start command to preload dotenv:

  "scripts": {
"start": "node -r dotenv/config node_modules/.bin/sls offline"
},

Verify the webhooks function

In a new terminal window, let's start forwarding the stripe webhooks to your local URL:

stripe listen --forward-to http://localhost:3000/webhooks

the output will be look like this (let’s keep this process):

Ready! Your webhook signing secret is '{{WEBHOOK_SIGNING_SECRET}}' (^C to quit)

let’s copy and update `WEBHOOK_SIGNING_SECRET` value to your .env file.

After updating the .env file, you can start the local server:

npm start

In another terminal, trigger Stripe’s events to test your webhook:

stripe trigger payment_intent.succeeded

and you can get some log in the server terminal:

{
"cold_start":true,
"function_arn":"offline_invokedFunctionArn_for_stripe-webhooks-dev-webhooks",
"function_memory_size":1024,
"function_name":"stripe-webhooks-dev-webhooks",
"function_request_id":"d9e38439-68b5-4475-b554-3e6d3a625eb7",
"level":"INFO",
"message":"PaymentIntent for 2000 was successful!",
"service":"stripe-webhooks","timestamp":"2023-04-10T04:11:05.650Z"
}
(λ: webhooks) RequestId: d9e38439-68b5-4475-b554-3e6d3a625eb7 Duration: 469.25 ms Billed Duration: 470 ms

It works!

Deploy the function to AWS Lambda

Just try to complete your function and you can make it go live by deploying the function to AWS.

After you have set up your AWS access and environment variables, you can deploy your webhook with this command:

npx sls deploy

The command may take a few minutes to create a Lambda function and deploy it to AWS. Once the deployment completes, you will see the public URL that was assigned to the deployed webhook:

✔ Service deployed to stack stripe-webhooks-acp (556s)

endpoint: https://xxxxxxxxxxxxxxxxxxxxxxx.lambda-url.ap-northeast-1.on.aws/
functions:
webhooks: stripe-webhooks-acp-webhooks (51 MB)

Copy the URLs shown in the endpoint line, this is the URL assigned to your webhook.

And you can follow this link to register the endpoint on the Stripe dashboard so Stripe knows where to deliver events.

Conclusion

The utilization of the Serverless framework for deploying webhooks to AWS Lambda in production is a highly convenient option, which has been made even more robust with the introduction of Lambda’s latest function URLs. I trust that you have found this tutorial to be beneficial and that you will utilize the methods that you have learned for your own webhooks.

You can find the complete source code at this GitHub link.

Thank you for reading!

--

--