Reliable Serverless Webhooks on AWS with HTTP API, Lambda, and DynamoDB
Webhook integrations look simple on the surface: expose an HTTP endpoint, accept JSON, start some internal processing. In practice they often fail in production because of timeouts, missing validation, weak security, or no clear ownership of configuration.
This article shows a minimal but production-ready pattern for serverless webhooks on AWS using API Gateway HTTP API, Lambda, and DynamoDB. The example is multi-tenant capable, uses a single opaque UUID per webhook, and keeps the infrastructure fully reproducible via CloudFormation.
The same structure can be used to start workflow engines, background jobs, or other internal processes from external systems like form tools, CRM systems, or automation platforms.
Architecture overview
The goal is a generic, tenant-aware webhook API with a simple, stable URL:
-
HTTP endpoint:
POST /v1/webhooks/{tenant}/{webhookId} -
Components:
- API Gateway HTTP API (v2) for routing
- Lambda
webhookEntryas the only public entry point - DynamoDB as registry for webhook configuration
- CloudFormation to provision everything as code
The key ideas:
- The external system only knows the base URL and a webhookId (a UUID).
- The internal mapping from
webhookIdto process details lives in DynamoDB. - The Lambda validates the webhook, starts the right logic, and returns a clear HTTP response.
- Tenants are separated logically via the
{tenant}path segment and by including tenant information in dynamodb.
Why naive webhook implementations break in production
Typical quick implementations look like this:
- Create an HTTP endpoint.
- Parse the body.
- Do heavy work directly in the Lambda.
- Return
200if nothing throws.
This tends to fail for several reasons:
- Timeouts: API Gateway HTTP APIs have a hard timeout (up to 30 seconds). Slow downstream systems will break your webhooks.
- No registry: The URL path is tightly coupled to internal implementation details. Changing the process means changing the endpoint.
- No token: Anyone who knows the URL can trigger the process.
- No tenant separation: Hard to know which data belongs to which customer.
- No extension point: Adding signature validation, rate limiting, or new processing steps becomes painful.
The pattern in this article solves these problems with a small, focused design.
Defining the HTTP API in CloudFormation
The HTTP API is created as part of your infrastructure stack. The example below shows a minimal CloudFormation template that:
- creates an HTTP API,
- integrates it with the
webhookEntryLambda, - defines the
POST /v1/webhooks/{tenant}/{webhookId}route, - and grants API Gateway permission to invoke the Lambda.
{
"AWSTemplateFormatVersion": "2010-09-09",
"Parameters": {
"env": { "Type": "String" },
"functionwebhookEntryArn": {
"Type": "String",
"Description": "ARN of the Lambda function that handles incoming webhooks"
}
},
"Resources": {
"WebhookHttpApi": {
"Type": "AWS::ApiGatewayV2::Api",
"Properties": {
"Name": { "Fn::Sub": "pantarey-webhooks-${env}" },
"ProtocolType": "HTTP"
}
},
"WebhookIntegration": {
"Type": "AWS::ApiGatewayV2::Integration",
"Properties": {
"ApiId": { "Ref": "WebhookHttpApi" },
"IntegrationType": "AWS_PROXY",
"IntegrationMethod": "POST",
"PayloadFormatVersion": "2.0",
"IntegrationUri": { "Ref": "functionwebhookEntryArn" }
}
},
"WebhookRoute": {
"Type": "AWS::ApiGatewayV2::Route",
"Properties": {
"ApiId": { "Ref": "WebhookHttpApi" },
"RouteKey": "POST /v1/webhooks/{tenant}/{webhookId}",
"Target": { "Fn::Sub": "integrations/${WebhookIntegration}" }
}
},
"WebhookStage": {
"Type": "AWS::ApiGatewayV2::Stage",
"Properties": {
"ApiId": { "Ref": "WebhookHttpApi" },
"StageName": "prod",
"AutoDeploy": true
}
},
"WebhookEntryLambdaPermission": {
"Type": "AWS::Lambda::Permission",
"Properties": {
"FunctionName": { "Ref": "functionwebhookEntryArn" },
"Action": "lambda:InvokeFunction",
"Principal": "apigateway.amazonaws.com",
"SourceArn": {
"Fn::Sub": "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebhookHttpApi}/*/*/v1/webhooks/*/*"
}
}
}
},
"Outputs": {
"WebhookBaseUrl": {
"Value": {
"Fn::Sub": "https://${WebhookHttpApi}.execute-api.${AWS::Region}.amazonaws.com/prod/v1/webhooks"
}
}
}
}
URL placeholders and examples
The route definition uses two placeholders:
{tenant}– a human-readable tenant identifier such asacmeordemo.{webhookId}– an opaque UUID that identifies the webhook configuration.
If the stack is deployed in eu-central-1 and the generated API ID is a1b2c3d4e5, the base URL from the stack output will look like this:
A full webhook URL for tenant acme and a UUID 1dfc1e41-2c5d-4c5c-9f60-3fe5b9adad8f is then:
https://a1b2c3d4e5.execute-api.eu-central-1.amazonaws.com/prod/v1/webhooks/acme/1dfc1e41-2c5d-4c5c-9f60-3fe5b9adad8f
This is the URL you give to external systems (form tools, automation platforms, etc.).
DynamoDB as webhook registry
Each webhook is represented by a single item in a DynamoDB table. A simple structure is:
- Partition key:
pk = TENANT#<tenant> - Sort key:
sk = WEBHOOK#<webhookId>
Attributes might include:
tenant– tenant idwebhookId– UUID used in the URLstatus–activeordisableddescription– optional human-readable description
A minimal upsert could look like this (Node.js, AWS SDK v3):
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, UpdateCommand } from "@aws-sdk/lib-dynamodb";
const client = new DynamoDBClient({});
const ddb = DynamoDBDocumentClient.from(client);
const TABLE_NAME = process.env.WEBHOOK_TABLE;
export async function upsertWebhookConfig({ tenant, webhookId, status = "active" }) {
if (!tenant || !webhookId ) {
throw new Error("Missing required values for webhook config");
}
const pk = `TENANT#${tenant}`;
const sk = `WEBHOOK#${webhookId}`;
const result = await ddb.send(
new UpdateCommand({
TableName: TABLE_NAME,
Key: { pk, sk },
UpdateExpression:
"SET #status = :status, tenant = :tenant, webhookId = :wid",
ExpressionAttributeNames: {
"#status": "status"
},
ExpressionAttributeValues: {
":status": status,
":tenant": tenant,
":wid": webhookId
},
ReturnValues: "ALL_NEW"
})
);
return result.Attributes;
}
Looking up a configuration during webhook processing is a simple GetItem:
import { GetCommand } from "@aws-sdk/lib-dynamodb";
export async function getWebhookConfig({ tenant, webhookId }) {
const pk = `TENANT#${tenant}`;
const sk = `WEBHOOK#${webhookId}`;
const result = await ddb.send(
new GetCommand({
TableName: TABLE_NAME,
Key: { pk, sk }
})
);
return result.Item ?? null;
}
Lambda webhookEntry: from HTTP to internal process
The webhookEntry Lambda is integrated via AWS_PROXY and receives events in the HTTP API v2 format. It is responsible for:
- parsing the path parameters,
- parsing and validating the JSON body,
- loading the webhook configuration from DynamoDB,
- returning an HTTP response with a clear status code.
A minimal implementation (Node.js, AWS SDK v3) looks like this:
import { getWebhookConfig } from "./dynamo";
const REGION = process.env.AWS_REGION || "eu-central-1";
export const handler = async (event) => {
console.log("Incoming event:", JSON.stringify(event));
const routeKey = event.requestContext?.routeKey;
const method = event.requestContext?.http?.method;
try {
if (routeKey !== "POST /v1/webhooks/{tenant}/{webhookId}") {
return notFound(routeKey);
}
if (method !== "POST") {
return methodNotAllowed(["POST"]);
}
return await handleWebhook(event);
} catch (err) {
console.error("Error in handler:", err);
return {
statusCode: 500,
body: JSON.stringify({
message: "Internal server error",
error: err.message || String(err)
})
};
}
};
function notFound(routeKey) {
return {
statusCode: 404,
body: JSON.stringify({
message: "Route not found",
routeKey
})
};
}
function methodNotAllowed(allowed) {
return {
statusCode: 405,
headers: { Allow: allowed.join(", ") },
body: JSON.stringify({
message: `Method not allowed. Allowed: ${allowed.join(", ")}`
})
};
}
The actual webhook logic is encapsulated in handleWebhook:
async function handleWebhook(event) {
const { pathParameters, body, headers, requestContext } = event;
const tenant = pathParameters?.tenant;
const webhookId = pathParameters?.webhookId;
if (!tenant || !webhookId) {
return {
statusCode: 400,
body: JSON.stringify({
message: "Missing required path parameters",
required: ["tenant", "webhookId"],
got: pathParameters
})
};
}
let payload = null;
if (body) {
try {
payload = typeof body === "string" ? JSON.parse(body) : body;
} catch (e) {
return {
statusCode: 400,
body: JSON.stringify({
message: "Invalid JSON body",
error: e.message
})
};
}
}
const config = await getWebhookConfig({ tenant, webhookId });
if (!config || config.status !== "active") {
console.warn("WebhookConfig not found or inactive", { tenant, webhookId });
return {
statusCode: 404,
body: JSON.stringify({
message: "Webhook not found"
})
};
}
const input = {
tenant,
webhookId,
payload: payload ?? {},
requestMeta: {
path: requestContext?.http?.path,
method: requestContext?.http?.method,
headers
}
};
// logic here... e.g. Start Pantarey Process
return {
statusCode: 202,
body: JSON.stringify({
ok: true,
message: "Process started",
})
};
}
IAM permissions for the Lambda
The execution role of webhookEntry needs permission to:
- read from the DynamoDB table, and
A minimal IAM policy statement looks like this:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"dynamodb:GetItem"
],
"Resource": "arn:aws:dynamodb:eu-central-1:123456789012:table/WebhookTable"
}
]
}
Restrict the Resource ARNs as tightly as your setup allows.
How to extend this pattern
The minimal implementation intentionally focuses on a small surface area. It is easy to extend without breaking the external contract.
Signature validation
Add support for a shared secret or signature header:
- Store a
secretvalue in the webhook config item. - Require an
X-Webhook-Signatureheader in incoming requests. - Compute an HMAC over the raw request body and compare with the header.
If the signature does not match, return 401 or 403 and do not start the process.
Rate limiting and abuse protection
There are two options:
- Use API Gateway throttling settings to limit requests globally or per account.
- Implement simple per-tenant counters in DynamoDB and reject if a threshold is exceeded.
Payload mapping
Some external systems send verbose payloads. You can:
- store a
mappingConfigJSON in DynamoDB, - apply a transformation step in the Lambda (for example using JSONata or custom code),
Async buffering
If downstream processing is heavy, or if you expect bursts, you can:
- replace direct logic calls with writes to an SQS queue and return 201 status code,
- process SQS messages in a second Lambda,
The HTTP contract and URL do not change.
Custom domains
Instead of using the default execute-api hostname, attach an ACM certificate and a custom domain name such as:
The internal implementation stays the same.
FAQ
How do I generate the webhookId?
Use a UUID v4 generated by your backend, provisioning scripts, or even a simple CLI script. Store the value together with the configuration in DynamoDB and insert it into the URL you give to the external system.
Can I use query parameters instead of path parameters?
Yes, but path parameters make it clearer which parts are identifiers ({tenant}, {webhookId}). For simple, resource-like URLs, path segments are usually easier to work with than query strings.
Can I support multiple HTTP methods?
You can define additional routes, for example:
GET /v1/webhooks/{tenant}/{webhookId}to check status,DELETE /v1/webhooks/{tenant}/{webhookId}to invalidate a webhook.
Each route can point to the same Lambda and branch based on routeKey or method.
How do I test the webhook endpoint?
Use curl or a tool like Postman/Insomnia. Example:
curl -X POST "https://a1b2c3d4e5.execute-api.eu-central-1.amazonaws.com/prod/v1/webhooks/acme/1dfc1e41-2c5d-4c5c-9f60-3fe5b9adad8f" -H "Content-Type: application/json" -d '{"event":"test","value":123}'
Check CloudWatch Logs for the Lambda execution history.
How do I rotate a webhook token?
Create a new webhook configuration item with a fresh UUID, rollout the new URL to the external system, and then disable or delete the old configuration once no more calls are expected.
Troubleshooting
I get 403 or Missing Authentication Token
Check:
- Is the HTTP method correct (
POST)? - Does the URL path exactly match the route (
/v1/webhooks/{tenant}/{webhookId})? - Is the
prodstage included in the URL?
Missing Authentication Token usually means the route or method do not match any configured route.
I get 502 or Internal server error from API Gateway
This typically happens when the Lambda:
- throws an unhandled error,
- or returns a response that does not conform to the Lambda proxy integration format.
Verify that you always return an object with statusCode and body (string). Check CloudWatch Logs for stack traces.
The Lambda logs show Invalid JSON body
The body is not valid JSON, or the Content-Type header is missing or incorrect. Many external systems send URL-encoded form data by default. Configure them to send application/json and adjust the body parsing logic if needed.
The wrong tenant appears in logs
Make sure your external URL uses the correct {tenant} segment and that you do not hard-code the tenant anywhere in the Lambda. For multi-tenant systems, including the tenant in the DynamoDB keys and execution input is essential for safe separation.
Conclusion
With a small amount of infrastructure you can build a robust, tenant-aware webhook system on AWS:
- HTTP API for lightweight, cheap HTTP entry.
- Lambda to validate requests and trigger internal processing.
- DynamoDB as the central webhook registry.
- CloudFormation to keep everything repeatable and version controlled.
This pattern scales from small prototypes to production workloads and can be adapted for many systems beyond webhooks. A similar approach is used internally at Pantarey.io to trigger serverless workflows from external tools and integrations.