π΅οΈ Webhook Spy: A Super-Simple Serverless Webhook Spy Service
Have you ever wanted to peek behind the curtain and see exactly what requests your webhooks are sending, but didn’t fancy wrestling with SaaS tools or spinning up a full-blown server? Well, I certainly did. That’s why I decided to build Webhook Spy β a delightfully minimal, serverless webhook request logger that you can deploy and own yourself!
Let’s embark on this journey together and see how absurdly easy (and cheap!) it is to create your own webhook spy service on AWS. Spoiler: I got it built and running in under an hour β±οΈ.
Why Webhook Spy?
As a developer, I frequently integrate with third-party services and build systems that rely on inter-service communication (like event-driven or service-oriented architectures), each sending webhook requests to my endpoints. While there are plenty of commercial request bin services out there, I found myself wanting:
- Complete control & data ownership (my requests, my rules).
- No faffing about with expiring bins or complex dashboards.
- A service that costs next to nothing (let’s face it, most of these requests are just for staging).
- Something I could spin up (and tear down) in minutes.
And thus, Webhook Spy was born! It consists of just two AWS Lambda functions and a DynamoDB table. One Lambda logs incoming requests to DynamoDB, while the other lists them in a pretty HTML page. DynamoDB TTL takes care of the clean-up, so you don’t have to lift a finger. π
How It Works
Let’s walk through the architecture and see how it all fits together.
π§© Components
- Spy Lambda β Receives incoming webhook requests and stores them in DynamoDB.
- List Lambda β Fetches and displays the logged requests for a given path.
- DynamoDB Table β Persists the request data and automatically expires old entries.
Each request is grouped by the path name, making it easy - and frictionless - to segment traffic for different use-cases (think /stripe
, /github
, or /my-webhook-endpoint
).
You own and deploy everything, so you know where your data lives.
The Code: Simple, Effective, and Yours
I promised this would be minimal, so let’s look at the core logic. I’ll share both Lambda functions and the essential Serverless Framework config.
1. Logging Requests: The Spy Lambda
This Lambda takes any incoming request, grabs all the relevant details, and drops them into DynamoDB. It also sets an expiry timestamp, so DynamoDB can auto-delete old entries. Here’s the code:
'use strict';
const { DynamoDBClient } = require('@aws-sdk/client-dynamodb');
const { DynamoDBDocumentClient, PutCommand } = require('@aws-sdk/lib-dynamodb');
const client = new DynamoDBClient();
const dynamo = DynamoDBDocumentClient.from(client);
const epoch = () => Math.floor(new Date().getTime() / 1000);
module.exports.handler = async event => {
const expireInSeconds = process.env.REQUEST_EXPIRY_AFTER_DAYS * 24 * 60 * 60;
await dynamo.send(
new PutCommand({
TableName: process.env.TABLE_NAME,
Item: {
PathName: event.rawPath,
OccurredAt: epoch(),
ExpiresAt: epoch() + expireInSeconds,
Request: JSON.stringify(event),
},
})
);
return {
statusCode: 200,
body: '{}',
headers: {
'Content-Type': 'application/json',
},
};
};
What’s going on here?
- We capture the full request event as JSON.
- Each entry is keyed by
PathName
(as the Primary Key) andOccurredAt
(timestamp, as the Sort Key). - TTL magic:
ExpiresAt
tells DynamoDB when to auto-delete the record. - Returns a simple
200 OK
β webhooks will be delighted.
2. Viewing Requests: The List Lambda
Now for the fun part: seeing what arrived! This Lambda queries DynamoDB for all requests to a certain path, then renders them in a friendly HTML page.
'use strict';
const { DynamoDBClient } = require('@aws-sdk/client-dynamodb');
const {
DynamoDBDocumentClient,
QueryCommand,
} = require('@aws-sdk/lib-dynamodb');
const client = new DynamoDBClient();
const dynamo = DynamoDBDocumentClient.from(client);
module.exports.handler = async event => {
const results = await dynamo.send(
new QueryCommand({
TableName: process.env.TABLE_NAME,
KeyConditionExpression: 'PathName = :pathName',
ExpressionAttributeValues: { ':pathName': event.rawPath },
ScanIndexForward: false,
})
);
return {
statusCode: 200,
headers: {
'Content-Type': 'text/html',
},
body: `
<html>
<head>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/open-fonts@1.1.1/fonts/inter.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@exampledev/new.css@1.1.2/new.min.css">
<title>Webhook Spy</title>
</head>
<body>
<header>
<h1>Webhook Spy</h1>
<div>Path: ${event.rawPath}</div>
</header>
${results.Items.map(result => {
const request = JSON.parse(result.Request);
try {
request.body = JSON.parse(request.body);
} catch (e) {}
return `
<div>
<time datetime="${result.OccurredAt}"></time>
<pre>${JSON.stringify(
{
method: request.requestContext.http.method,
path: request.requestContext.http.path,
query: request.rawQueryString,
protocol: request.requestContext.http.protocol,
sourceIp: request.requestContext.http.sourceIp,
headers: request.headers,
body: request.body,
},
null,
2
)}</pre>
</div>
`;
}).join('\n')}
<script>
[...document.getElementsByTagName('time')].forEach((time) => {
time.innerHTML = new Date(time.getAttribute('datetime') * 1e3).toLocaleString();
});
</script>
</body>
</html>
`,
};
};
Highlights:
- Relevant path-based results are sorted with the newest entries shown first.
- Each request is rendered in a readable format, with headers, query string, body, and more. If the request body contains JSON, we attempt to prettify it as well.
- Light CSS styling is applied for better readability.
3. The Serverless Framework Setup
You can deploy the whole service with the Serverless Framework.
Here’s the serverless.yml
that glues it all together:
service: webhook-spy
frameworkVersion: '3'
custom:
tableName: ${self:service}-${opt:stage}
requestExpiryAfterDays: 1
provider:
name: aws
runtime: nodejs20.x
iam:
role:
statements:
- Effect: Allow
Action:
- dynamodb:Query
- dynamodb:PutItem
Resource:
- 'Fn::GetAtt': [Table, Arn]
environment:
TABLE_NAME: ${self:custom.tableName}
functions:
spy:
handler: spy.handler
url: true
environment:
REQUEST_EXPIRY_AFTER_DAYS: ${self:custom.requestExpiryAfterDays}
list:
handler: list.handler
url: true
resources:
Resources:
Table:
Type: AWS::DynamoDB::Table
Properties:
TableName: ${self:custom.tableName}
AttributeDefinitions:
- AttributeName: PathName
AttributeType: S
- AttributeName: OccurredAt
AttributeType: N
KeySchema:
- AttributeName: PathName
KeyType: HASH
- AttributeName: OccurredAt
KeyType: RANGE
TimeToLiveSpecification:
AttributeName: 'ExpiresAt'
Enabled: true
BillingMode: PAY_PER_REQUEST
What I love about this:
- PAY_PER_REQUEST: No idle costs, only charged for what you use.
- Automatic cleanup: DynamoDB handles deleting old requests. No cron jobs, scripts, or post-it notes required.
- Minimal AWS resources: Just a few services (Lambda, DynamoDB), all provisioned and decommissioned via this single configuration file.
Deploying and Using Webhook Spy
Ready to give it a go? Here’s the step-by-step:
- Clone the repository.
- (Optional) Edit
custom.requestExpiryAfterDays
inserverless.yml
to set your preferred retention period. - Deploy with Serverless Framework:
npx serverless deploy --stage dev
- Pick a path name for your webhook group (e.g.
/stripe
). - Point your webhook sender to:
https://spy-lambda-function-url/{path-name}
- Visit the list endpoint to view requests:
https://list-lambda-function-url/{path-name}
And that’s it! No third-party dashboards, no hidden costs, no expiring endpoints (unless you want them to expire). Just pure, hands-on webhook spying goodness.
Reflections, Lessons, and What’s Next
I love little projects like this. They scratch an itch, teach me something new, and (let’s be honest) give me an excuse to play with AWS. Lambda URLs are a perfect fit for this use case and reduce the required AWS resources needed for tiny utilities like this.
What went well:
- The whole thing fits comfortably in my head (and in a single blog post).
- DynamoDB TTL means zero maintenance. I can forget about it and it’ll quietly clean itself up.
- The cost is almost unnoticeable, even with frequent webhook testing.
Areas to improve (and possible future work):
- Add response stubs (beyond just 200s), maybe configurable via query params.
- Authentication! Right now, this is open to anyone with the URL, which amounts to security through obscurity β not ideal.
- Better search features: filtering by date, method, etc. β could be handy for those “why did it POST at 2am?” moments.
Over to You! π
If you’ve ever needed a quick, private webhook bin for your staging or dev environment, I hope you’ll find this as handy as I do. Feel free to fork the code or adapt it for your own use cases.