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.Source:https://dev.to/sascha-ms/receiving-webhooks-with-lambda-signatures-duplicates-sqs-44hgRelated posts

AI chip smugglers exploit trade control loophole to bring banned HBM from South Korea into China — expensive memory chips packaged with cheap, unsanctioned processors to become legal exports
SimPy: Simulating Real-World Processes With Python
Quiz: Converting Documents to Markdown With MarkItDown
Quiz: How to Use Claude Code Skills for Better Python Docstrings

