Receiving webhooks sounds like the easier half of the job: someone sends you a POST, you accept it, done. In reality, the receiving side carries just as much responsibility as the sending side. The URL is public, anyone can call it, and well-behaved senders deliberately send the same message several times as soon as you answer too slowly once.
The previous part of this series covered the sending side with retries via SQS. There I only sketched the receiver, with an in-memory set instead of a real duplicate check. Today I build the full counterpart: verify the signature, detect duplicates with a DynamoDB condition, reply with 202 right away and do the actual work through SQS. This time with AWS CDK.
In short
A webhook receiver first verifies signature and timestamp against the raw body, remembers the message ID with a conditional DynamoDB write and puts the work on an SQS queue before it answers with 202. The most common trap is the order of steps: if you store the ID but don’t protect the enqueue step, the sender’s next retry gets thrown away as a duplicate, and that is exactly the event you were still missing.
What you will learn in this article
- How to safely verify an HMAC signature in the Standard Webhooks format
- How a conditional DynamoDB write reliably detects duplicate webhooks
- Why the receiver answers 202 right away and hands the work to SQS
- How to set it all up with AWS CDK, a function URL and Parameter Store
- Which mistakes cost you events when receiving webhooks, and how to avoid them
What can go wrong when receiving webhooks
Before we write code, it pays to look at the risks. A webhook is an HTTP call that a third-party system sends to your URL when something happens, for example “payment received”. You have no control over when it arrives, how often it arrives or who actually sent it.
The first risk is forged requests. Your webhook URL shows up in configuration, logs and sometimes in tickets. Anyone who knows it can send you arbitrary JSON documents. Without a check, you then mark a bike rental as paid although no money ever moved.
Next come replay attacks. Someone captures a real, correctly signed message and sends it again a week later. The signature is valid, the content too; only the timing is wrong.
The third risk is duplicates for perfectly honest reasons. Good senders work “at least once”: if they don’t get a fast 2xx response, they send the same message again. That also happens when you processed it long ago and only the response got lost on the way back.
Finally, there are slow responses. The Standard Webhooks specification recommends a sender timeout between 15 and 30 seconds. If your handler first creates an invoice, sends an email and asks three other systems, you will hit that limit quickly and produce the very duplicates you then have to catch.
The architecture: receiving webhooks in three steps
My receiver consists of two Lambda functions, one DynamoDB table and one SQS queue. The example is a fictional bike rental called “Radstation” that gets payment events from its payment provider. The flow looks like this:
-
Verify: The
WebhookReceiverfunction sits behind a Lambda function URL. It checks signature and timestamp against the raw body. If anything is off, it answers with 401. -
Remember: Next, it writes the
webhook-idto theWebhookInboxtable with the condition “must not exist yet”. If the condition fails, the message was already there, and the answer is a friendly 200. -
Enqueue: New messages go to the
PaymentEventsqueue, and the sender gets 202 right away. TheWebhookWorkerfunction picks them up there and runs the business logic without time pressure.
🎬 Flow when receiving webhooks with Lambda – this animated diagram only runs on my site: watch it on sascha-ms.de
You may know this pattern from the asynchronous API with the job pattern. The difference: here it isn’t your own client that sets the pace but a third-party system that you can neither change nor ask for patience.
Why a function URL and not API Gateway?
A Lambda function URL is an HTTPS endpoint attached directly to the function, with no extra service in front. For a single webhook endpoint that is enough, because authentication happens through the signature in code anyway. That’s why I set the auth type to NONE. AWS CDK then automatically creates the required resource-based policy with lambda:InvokeFunctionUrl and lambda:InvokeFunction, restricted to calls through the URL.
API Gateway pays off as soon as you need per-client throttling, a custom domain with several routes or AWS WAF in front. Function URLs have no built-in throttling. If you are unsure which entry point fits, my comparison REST API or HTTP API will help.
Step by step: receiving webhooks with Lambda and CDK
Prerequisites: Node.js 22 or newer, an AWS account with a configured AWS CLI and cdk bootstrap run once in your target region. Basic TypeScript knowledge is enough; I explain the rest along the way.
Step 1: Create the project
I start with an empty folder and install only what is really needed. The AWS SDK v3 already ships with the Lambda Node.js runtime, so esbuild leaves it out of the bundle automatically.
mkdir webhook-receiver-lambda && cd webhook-receiver-lambda
npm init -y
npm install aws-cdk-lib constructs @aws-sdk/client-dynamodb @aws-sdk/client-sqs @aws-sdk/client-ssm
npm install -D aws-cdk typescript tsx esbuild @types/node @types/aws-lambda
mkdir bin lib src test scripts
The package.json gets "type": "module" and a few scripts. The tsconfig.json uses NodeNext so that imports ending in .ts work. You will find both files in full in the example repository.
{
"app": "npx tsx bin/app.ts"
}
Step 2: Verify signature and timestamp
I follow the format of the open Standard Webhooks specification because many providers already use it. The sender sends three headers: webhook-id, webhook-timestamp (Unix seconds) and webhook-signature. The signed text is <id>.<timestamp>.<body>, using HMAC-SHA256. An HMAC is a check value derived from a shared secret and the content; without the secret, nobody can produce it.
// src/signature.ts
import { createHmac, timingSafeEqual } from 'node:crypto';
// How far the timestamp may drift from our own clock (5 minutes)
export const TOLERANCE_SECONDS = 5 * 60;
export type VerifyInput = {
secret: string; // format "whsec_<base64>" as in the Standard Webhooks spec
id: string | undefined;
timestamp: string | undefined;
signatureHeader: string | undefined;
rawBody: string;
nowSeconds?: number; // can be overridden in tests
};
export type VerifyResult =
| { ok: true }
| { ok: false; reason: 'missing-headers' | 'bad-timestamp' | 'too-old' | 'bad-signature' };
function keyFromSecret(secret: string): Buffer {
const base64 = secret.startsWith('whsec_') ? secret.slice('whsec_'.length) : secret;
return Buffer.from(base64, 'base64');
}
// Computes the signature for "<id>.<timestamp>.<rawBody>" (HMAC-SHA256, Base64)
export function computeSignature(secret: string, id: string, timestamp: string, rawBody: string): string {
return createHmac('sha256', keyFromSecret(secret))
.update(`${id}.${timestamp}.${rawBody}`)
.digest('base64');
}
export function verifyWebhook(input: VerifyInput): VerifyResult {
const { secret, id, timestamp, signatureHeader, rawBody } = input;
if (!id || !timestamp || !signatureHeader) return { ok: false, reason: 'missing-headers' };
if (!/^\d{1,12}$/.test(timestamp)) return { ok: false, reason: 'bad-timestamp' };
const now = input.nowSeconds ?? Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) return { ok: false, reason: 'too-old' };
const expected = Buffer.from(computeSignature(secret, id, timestamp, rawBody), 'base64');
// The header can carry several signatures ("v1,abc v1,def"), e.g. during a key rotation
for (const entry of signatureHeader.split(' ')) {
const [version, value] = entry.split(',', 2);
if (version !== 'v1' || !value) continue;
const candidate = Buffer.from(value, 'base64');
// timingSafeEqual requires buffers of equal length
if (candidate.length === expected.length && timingSafeEqual(candidate, expected)) {
return { ok: true };
}
}
return { ok: false, reason: 'bad-signature' };
}
Three details make the difference here. For one, timingSafeEqual compares the signatures in constant time. A normal string comparison stops at the first wrong character, and a patient attacker can use those tiny timing differences to guess the signature piece by piece. In addition, the loop accepts several signatures in the header. The sender needs this when rotating its secret and signing with the old and new key in parallel for a while.
The third detail is the five-minute tolerance. The specification requires a timestamp check but doesn’t prescribe a value. Five minutes is a common compromise between slightly skewed clocks and protection against replay. Because the timestamp is part of the signed text, nobody can tamper with it afterwards.
Step 3: Store the secret in Parameter Store
The shared secret belongs neither in code nor as plain text in an environment variable. I store it as a SecureString in SSM Parameter Store. According to the specification, 24 to 64 random bytes with the prefix whsec_ are typical. You then hand the value to the sender, usually through its dashboard.
# 32 random bytes, Base64-encoded, prefixed with whsec_
SECRET="whsec_$(openssl rand -base64 32)"
aws ssm put-parameter \
--name /radstation/webhooks/payment-secret \
--type SecureString \
--value "$SECRET"
echo "$SECRET" # hand it to the sender once, then never print it again
The function reads the secret on the first call and keeps it in memory for five minutes. That way, not every webhook costs an extra call, and a rotated secret still arrives promptly.
// src/secret.ts
import { GetParameterCommand, SSMClient } from '@aws-sdk/client-ssm';
const ssm = new SSMClient({});
let cached: { value: string; loadedAt: number } | undefined;
const MAX_AGE_MS = 5 * 60 * 1000; // reload after 5 minutes so a rotation takes effect
// Reads the webhook secret from Parameter Store and caches it briefly
export async function getWebhookSecret(): Promise<string> {
if (cached && Date.now() - cached.loadedAt < MAX_AGE_MS) return cached.value;
const result = await ssm.send(
new GetParameterCommand({ Name: process.env.SECRET_PARAMETER_NAME, WithDecryption: true }),
);
const value = result.Parameter?.Value;
if (!value) throw new Error('Webhook secret missing in Parameter Store');
cached = { value, loadedAt: Date.now() };
return value;
}
Step 4: The receiver with duplicate detection
Now for the core. The order inside the handler is deliberate: first secure the raw body, then verify, then remember, then enqueue.
// src/receiver.ts
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';
import {
ConditionalCheckFailedException,
DeleteItemCommand,
DynamoDBClient,
PutItemCommand,
} from '@aws-sdk/client-dynamodb';
import { SendMessageCommand, SQSClient } from '@aws-sdk/client-sqs';
import { verifyWebhook } from './signature.ts';
import { getWebhookSecret } from './secret.ts';
const dynamo = new DynamoDBClient({});
const sqs = new SQSClient({});
const TABLE = process.env.INBOX_TABLE!;
const QUEUE_URL = process.env.QUEUE_URL!;
const KEEP_DAYS = 7; // how long we remember a webhook-id
const reply = (statusCode: number, message: string): APIGatewayProxyResultV2 => ({
statusCode,
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ message }),
});
export const handler = async (event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> => {
if (event.requestContext.http.method !== 'POST') return reply(405, 'method not allowed');
// 1. Take the raw body exactly as it arrived – do not parse and re-serialize
const rawBody = event.isBase64Encoded
? Buffer.from(event.body ?? '', 'base64').toString('utf8')
: (event.body ?? '');
// 2. Verify signature and timestamp (function URLs deliver lower-case header names)
const id = event.headers['webhook-id'];
const check = verifyWebhook({
secret: await getWebhookSecret(),
id,
timestamp: event.headers['webhook-timestamp'],
signatureHeader: event.headers['webhook-signature'],
rawBody,
});
if (!check.ok) {
console.warn(JSON.stringify({ level: 'warn', msg: 'webhook rejected', reason: check.reason }));
return reply(401, 'invalid signature');
}
// 3. Detect duplicates: each id may enter the table only once
const expiresAt = Math.floor(Date.now() / 1000) + KEEP_DAYS * 24 * 60 * 60;
try {
await dynamo.send(
new PutItemCommand({
TableName: TABLE,
Item: {
webhookId: { S: id! },
status: { S: 'RECEIVED' },
receivedAt: { S: new Date().toISOString() },
expiresAt: { N: String(expiresAt) },
},
ConditionExpression: 'attribute_not_exists(webhookId)',
}),
);
} catch (error) {
if (error instanceof ConditionalCheckFailedException) {
// Already known: acknowledge, but do not enqueue again
return reply(200, 'duplicate');
}
throw error;
}
// 4. Put the work on the queue and reply right away
try {
await sqs.send(
new SendMessageCommand({
QueueUrl: QUEUE_URL,
MessageBody: rawBody,
MessageAttributes: { webhookId: { DataType: 'String', StringValue: id! } },
}),
);
} catch (error) {
// Roll back the entry, otherwise the sender's next retry would look like a duplicate
await dynamo.send(new DeleteItemCommand({ TableName: TABLE, Key: { webhookId: { S: id! } } }));
console.error(JSON.stringify({ level: 'error', msg: 'enqueue failed', webhookId: id, error: String(error) }));
return reply(503, 'try again later');
}
return reply(202, 'accepted');
};
The duplicate check lives in ConditionExpression: 'attribute_not_exists(webhookId)'. DynamoDB evaluates the condition and performs the write atomically. If two identical webhooks arrive at the same moment, exactly one wins, and the other gets a ConditionalCheckFailedException. A read followed by a write would leave a gap that both calls could slip through.
Pay attention to the catch block around the enqueue step. Suppose the ID is already in the table but SQS is briefly unreachable. Without the DeleteItem, the sender’s next attempt would count as a duplicate and get a 200. The event would be silently lost. That’s why I roll back the entry and answer with 503, so the sender tries again later.
For a duplicate, I answer with 200 instead of an error code. From the sender’s point of view, the message did arrive. A 409 counts as a failure for many providers and triggers further attempts, in the worst case even the deactivation of your endpoint.
Every check has its own response: 401 for forgeries, 200 for duplicates, 503 for outages and 202 for new webhooks.
Step 5: The worker processes the queue
With standard queues, SQS guarantees “at least once” delivery. So a message can arrive twice behind the queue as well. The worker therefore first checks whether the ID is already marked as done, and it sets the status with a condition at the end. The business logic itself should still be idempotent, meaning it produces the same result when it runs twice. I covered the details in Lambda idempotency with TypeScript.
// src/worker.ts
import type { SQSBatchResponse, SQSEvent, SQSRecord } from 'aws-lambda';
import {
ConditionalCheckFailedException,
DynamoDBClient,
GetItemCommand,
UpdateItemCommand,
} from '@aws-sdk/client-dynamodb';
const dynamo = new DynamoDBClient({});
const TABLE = process.env.INBOX_TABLE!;
type PaymentEvent = {
type: 'payment.succeeded' | 'payment.failed' | string;
data: { rentalId: string; amountCents: number };
};
// The actual business logic – in this example: mark a bike rental as paid
async function handlePaymentEvent(event: PaymentEvent): Promise<void> {
if (event.type === 'payment.succeeded') {
console.log(JSON.stringify({ msg: 'rental paid', rentalId: event.data.rentalId, amountCents: event.data.amountCents }));
} else {
console.log(JSON.stringify({ msg: 'ignored event type', type: event.type }));
}
}
async function processRecord(record: SQSRecord): Promise<void> {
const webhookId = record.messageAttributes.webhookId?.stringValue;
if (!webhookId) throw new Error('webhookId missing');
// SQS delivers at least once: skip messages that are already done
const current = await dynamo.send(
new GetItemCommand({ TableName: TABLE, Key: { webhookId: { S: webhookId } }, ConsistentRead: true }),
);
if (current.Item?.status?.S === 'PROCESSED') return;
// The business logic must still be idempotent (setting "paid" twice does no harm)
await handlePaymentEvent(JSON.parse(record.body) as PaymentEvent);
// Mark as done; if the condition fails, another invocation was faster
try {
await dynamo.send(
new UpdateItemCommand({
TableName: TABLE,
Key: { webhookId: { S: webhookId } },
UpdateExpression: 'SET #s = :done, processedAt = :now',
ConditionExpression: '#s <> :done',
ExpressionAttributeNames: { '#s': 'status' },
ExpressionAttributeValues: { ':done': { S: 'PROCESSED' }, ':now': { S: new Date().toISOString() } },
}),
);
} catch (error) {
if (!(error instanceof ConditionalCheckFailedException)) throw error;
}
}
export const handler = async (event: SQSEvent): Promise<SQSBatchResponse> => {
const batchItemFailures: SQSBatchResponse['batchItemFailures'] = [];
for (const record of event.Records) {
try {
await processRecord(record);
} catch (error) {
console.error(JSON.stringify({ msg: 'processing failed', messageId: record.messageId, error: String(error) }));
batchItemFailures.push({ itemIdentifier: record.messageId });
}
}
return { batchItemFailures };
};
With batchItemFailures, the worker reports only the failed messages back to SQS. The successful ones from the same batch are deleted and not processed again. After five failed attempts, a message moves to the dead-letter queue, a holding area for messages that cannot be processed.
Step 6: Infrastructure with AWS CDK
The stack describes the table, the queues, both functions and the function URL. The table gets a time to live (TTL): DynamoDB deletes items automatically once the time in expiresAt has passed, according to the docs within a few days after that. For duplicate detection this delay is harmless; the ID simply stays known a little longer. Seven days cover the retry schedules of most providers; the example schedule in the specification ends after a little more than three days.
// lib/webhook-inbox-stack.ts
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { CfnOutput, Duration, RemovalPolicy, Stack, type StackProps } from 'aws-cdk-lib';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { SqsEventSource } from 'aws-cdk-lib/aws-lambda-event-sources';
import { NodejsFunction, OutputFormat } from 'aws-cdk-lib/aws-lambda-nodejs';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as sqs from 'aws-cdk-lib/aws-sqs';
import type { Construct } from 'constructs';
const here = path.dirname(fileURLToPath(import.meta.url));
const SECRET_PARAMETER = '/radstation/webhooks/payment-secret';
export class WebhookInboxStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// Remembers every webhook-id for 7 days (TTL cleans up automatically)
const inbox = new dynamodb.TableV2(this, 'WebhookInbox', {
partitionKey: { name: 'webhookId', type: dynamodb.AttributeType.STRING },
billing: dynamodb.Billing.onDemand(),
timeToLiveAttribute: 'expiresAt',
removalPolicy: RemovalPolicy.DESTROY, // demo only
});
const deadLetters = new sqs.Queue(this, 'PaymentEventsDlq', {
retentionPeriod: Duration.days(14),
});
const queue = new sqs.Queue(this, 'PaymentEvents', {
visibilityTimeout: Duration.seconds(60), // at least 6 times the worker timeout
deadLetterQueue: { queue: deadLetters, maxReceiveCount: 5 },
});
const common = {
runtime: lambda.Runtime.NODEJS_24_X,
architecture: lambda.Architecture.ARM_64,
memorySize: 256,
bundling: { format: OutputFormat.ESM, minify: true, sourceMap: true },
environment: {
INBOX_TABLE: inbox.tableName,
NODE_OPTIONS: '--enable-source-maps',
},
};
const receiver = new NodejsFunction(this, 'WebhookReceiver', {
...common,
entry: path.join(here, '../src/receiver.ts'),
timeout: Duration.seconds(5), // senders don't wait forever, neither do we
environment: {
...common.environment,
QUEUE_URL: queue.queueUrl,
SECRET_PARAMETER_NAME: SECRET_PARAMETER,
},
logGroup: new logs.LogGroup(this, 'ReceiverLogs', {
retention: logs.RetentionDays.ONE_MONTH,
removalPolicy: RemovalPolicy.DESTROY,
}),
});
const worker = new NodejsFunction(this, 'WebhookWorker', {
...common,
entry: path.join(here, '../src/worker.ts'),
timeout: Duration.seconds(10),
logGroup: new logs.LogGroup(this, 'WorkerLogs', {
retention: logs.RetentionDays.ONE_MONTH,
removalPolicy: RemovalPolicy.DESTROY,
}),
});
// Permissions: as few as possible
inbox.grant(receiver, 'dynamodb:PutItem', 'dynamodb:DeleteItem');
inbox.grant(worker, 'dynamodb:GetItem', 'dynamodb:UpdateItem');
queue.grantSendMessages(receiver);
receiver.addToRolePolicy(
new iam.PolicyStatement({
actions: ['ssm:GetParameter'],
resources: [this.formatArn({ service: 'ssm', resource: 'parameter', resourceName: SECRET_PARAMETER.slice(1) })],
}),
);
worker.addEventSource(
new SqsEventSource(queue, { batchSize: 10, reportBatchItemFailures: true }),
);
// Public HTTPS endpoint – the signature check in code is the authentication
const url = receiver.addFunctionUrl({ authType: lambda.FunctionUrlAuthType.NONE });
new CfnOutput(this, 'WebhookUrl', { value: url.url });
new CfnOutput(this, 'DeadLetterQueueUrl', { value: deadLetters.queueUrl });
}
}
// bin/app.ts
import { App } from 'aws-cdk-lib';
import { WebhookInboxStack } from '../lib/webhook-inbox-stack.ts';
const app = new App();
new WebhookInboxStack(app, 'WebhookInboxStack', {
env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION ?? 'eu-central-1' },
});
The permissions are deliberately tight. The receiver may only write and delete, the worker may only read and update. The receiver’s timeout is five seconds because it no longer does anything slow. If something hangs anyway, the sender should rather get an error and retry than run into its own timeout.
Step 7: Test and deploy
Before anything goes to the cloud, the unit tests for the signature check run. They need no AWS credentials.
// test/signature.test.ts
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { computeSignature, verifyWebhook } from '../src/signature.ts';
const secret = 'whsec_' + Buffer.from('a-secret-with-enough-characters-123').toString('base64');
const id = 'msg_2Kx7';
const timestamp = '1793404800';
const rawBody = '{"type":"payment.succeeded","data":{"rentalId":"R-1042","amountCents":1890}}';
const now = 1793404800;
test('accepts a valid signature', () => {
const signatureHeader = `v1,${computeSignature(secret, id, timestamp, rawBody)}`;
assert.deepEqual(verifyWebhook({ secret, id, timestamp, signatureHeader, rawBody, nowSeconds: now }), { ok: true });
});
test('accepts the second signature during a rotation', () => {
const signatureHeader = `v1,AAAA v1,${computeSignature(secret, id, timestamp, rawBody)}`;
assert.equal(verifyWebhook({ secret, id, timestamp, signatureHeader, rawBody, nowSeconds: now }).ok, true);
});
test('rejects a tampered body', () => {
const signatureHeader = `v1,${computeSignature(secret, id, timestamp, rawBody)}`;
const tampered = rawBody.replace('1890', '1');
assert.deepEqual(verifyWebhook({ secret, id, timestamp, signatureHeader, rawBody: tampered, nowSeconds: now }), {
ok: false,
reason: 'bad-signature',
});
});
test('rejects a timestamp that is too old', () => {
const signatureHeader = `v1,${computeSignature(secret, id, timestamp, rawBody)}`;
const result = verifyWebhook({ secret, id, timestamp, signatureHeader, rawBody, nowSeconds: now + 600 });
assert.deepEqual(result, { ok: false, reason: 'too-old' });
});
test('rejects missing headers', () => {
const result = verifyWebhook({ secret, id: undefined, timestamp, signatureHeader: 'v1,x', rawBody, nowSeconds: now });
assert.deepEqual(result, { ok: false, reason: 'missing-headers' });
});
npm test
npx tsc --noEmit
npx cdk deploy
# Output (shortened):
# WebhookInboxStack.WebhookUrl = https://abc123.lambda-url.eu-central-1.on.aws/
For a test against the real URL, I write a small script that signs like a sender. If you call it twice with the same ID, you can see the difference between a new webhook and a duplicate.
// scripts/send-test-webhook.ts
import { randomUUID } from 'node:crypto';
import { computeSignature } from '../src/signature.ts';
// Usage: WEBHOOK_URL=... WEBHOOK_SECRET=whsec_... npx tsx scripts/send-test-webhook.ts [id]
const url = process.env.WEBHOOK_URL;
const secret = process.env.WEBHOOK_SECRET;
if (!url || !secret) throw new Error('set WEBHOOK_URL and WEBHOOK_SECRET');
const id = process.argv[2] ?? `msg_${randomUUID()}`;
const timestamp = String(Math.floor(Date.now() / 1000));
const body = JSON.stringify({ type: 'payment.succeeded', data: { rentalId: 'R-1042', amountCents: 1890 } });
const response = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
'webhook-id': id,
'webhook-timestamp': timestamp,
'webhook-signature': `v1,${computeSignature(secret, id, timestamp, body)}`,
},
body,
});
console.log(id, response.status, await response.text());
export WEBHOOK_URL="https://abc123.lambda-url.eu-central-1.on.aws/"
export WEBHOOK_SECRET="whsec_..." # same value as in Parameter Store
npx tsx scripts/send-test-webhook.ts msg_test_001
npx tsx scripts/send-test-webhook.ts msg_test_001
# msg_test_001 202 {"message":"accepted"}
# msg_test_001 200 {"message":"duplicate"}
Expected result: the first call returns 202 {"message":"accepted"}, the second 200 {"message":"duplicate"}. The worker logs show exactly one rental paid entry. If you change a single character of the body after signing, you get 401.
Common mistakes when receiving webhooks
Parsing and re-serializing JSON. If you verify JSON.stringify(JSON.parse(body)), you check different bytes than the sender signed. Whitespace, key order or Unicode escapes are enough to break the match. Always verify the raw body and watch out for isBase64Encoded.
Body parser before verification. The same problem in a different outfit: frameworks such as Express like to parse the body automatically. For the webhook route you need the raw text, otherwise you will spend hours hunting a signature bug that doesn’t exist.
Reading headers with the wrong casing. Function URLs and HTTP APIs deliver lower-case header names. event.headers['Webhook-Id'] is always undefined there.
Business logic inside the request. Every second in the handler increases the risk of a sender timeout and with it the number of duplicates. Anything that takes longer than a few milliseconds belongs behind the queue.
Error codes for duplicates. A 4xx for a known webhook looks like a failure to the sender. Depending on the provider, more attempts follow or you get a warning that your endpoint is broken.
Remembering IDs for too short a time. If the TTL deletes an ID before the sender has finished its retries, a late duplicate slips through. Check your provider’s documentation for its maximum retry window and add a few days of buffer.
Cost and security
Cost-wise, the setup stays pleasantly small. Each webhook causes two short Lambda invocations, a few DynamoDB writes and reads in on-demand mode, and SQS requests to send, receive and delete, with Lambda batching the receive and delete calls. The function URL itself costs nothing extra. Parameter Store with SecureString causes KMS calls for decryption, but thanks to the cache only rarely. You can play through what this means for your volume with my Lambda cost calculator.
Security-wise, the public URL is the weak spot. The signature check protects your data, but not your wallet: rejected calls still start the function. So set reserved concurrency on the receiver (in CDK via reservedConcurrentExecutions) to keep a flood of requests from blocking your whole account. Check beforehand that your account has enough unreserved concurrency, otherwise the deployment fails. If you need more protection, put API Gateway or CloudFront with AWS WAF in front.
Never log the full header block or the secret. In the example, only the ID, the rejection reason and the event type end up in the logs. Payment data has no business in CloudWatch.
My take
I think the receiving side is underrated. On the sending side, most teams now think about retries because failures there are visible. On the receiving side, failures often surface only when someone asks why a payment was booked twice or not at all. By then the cause is hard to find, because the sender did everything right.
The pattern in this article is deliberately simple. If you need a stronger guarantee that ID and message are always stored together, you can write the body straight into the table and forward it via DynamoDB Streams. That is the outbox pattern, and it saves you the compensation via DeleteItem. For most webhook receivers, though, I find the SQS variant easier to understand and to run.
Where I disagree: I often hear that an IP allowlist could replace the signature. I see it differently. Provider IP ranges change, run over shared cloud infrastructure and don’t protect against replay. As an additional layer, an allowlist is fine; as the only safeguard, it isn’t.
FAQ
How do you verify a webhook signature?
You compute an HMAC-SHA256 over ID, timestamp and raw body yourself, using the shared secret, and compare it in constant time with the signature in the header. You also check that the timestamp is at most a few minutes old.
Which status code should a webhook receiver return?
202 or 200 for new, valid messages, 200 for known duplicates, 401 for invalid signatures and 503 for temporary outages. Many senders treat redirects (3xx) as failures.
How long should you store webhook IDs?
At least as long as the sender keeps retrying, plus some buffer. For many providers that means a few days. A DynamoDB TTL of seven days is a good starting point.
Is a Lambda function URL enough for webhooks?
For a single endpoint with the signature check in code, yes. If you need throttling, WAF rules or many routes under a custom domain, API Gateway or CloudFront in front is the better choice.
Your next step
Pick one webhook endpoint you already run today and answer three questions: Does it verify the signature against the raw body? Does it detect duplicates even when two calls arrive at the same time? Does it respond in less than five seconds? If any answer is “no”, this article gives you the blueprint for the fix.
I’d like to know how you handle it: do you store webhook IDs in DynamoDB, in Redis, or do you rely on the idempotency of your business logic? Tell me in the comments.
Next in the series: Back to the overview “Serverless patterns in practice” · Previous part: Webhook retries: reliable delivery with SQS and Lambda · Next part: Human in the Loop for AI Agents with Step Functions
Sources
- Standard Webhooks: specification
- AWS Lambda: Control access to function URLs
- AWS Lambda: Invoking function URLs (payload format)
- Amazon DynamoDB: Condition expressions
- Amazon DynamoDB: Time to Live
- AWS Lambda: Handling errors for an SQS event source
- AWS Systems Manager Parameter Store
This post was created with AI assistance.
👉 Next step: Curious what your own Lambda functions cost? Try my Lambda cost calculator – it runs right in your browser.
Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

Top comments (0)