Oct 27, 20266 min read

How to Host a Webhook Receiver and Worker on AWS

Receiving signed webhooks, answering fast and processing events in a separate worker on AWS with Encore, API Gateway with SQS and Lambda, or an ECS service with an SQS consumer.

Webhooks from payment providers, GitHub or Shopify arrive in bursts, are retried when your endpoint is slow, and are sometimes sent twice. Hosting them on AWS means an HTTP endpoint that verifies signatures, a queue, a worker with retries, and the IAM wiring between all three.

This guide hosts a payment webhook receiver and worker on AWS with Encore, API Gateway with SQS and Lambda, and an ECS service consuming SQS. The examples are TypeScript, and each one verifies an HMAC signature, queues the event and processes it once.

What you need to decide:

  • How the receiver reads the raw body to verify signatures.
  • Where events wait between the receiver and the worker.
  • How the worker retries and deduplicates.
  • How you test the whole flow locally.

Webhook hosting options on AWS at a glance

CapabilityEncoreAPI Gateway, SQS and LambdaECS and SQS
Receiverapi.raw endpointLambda behind API GatewayContainer behind a load balancer
QueueTopic and subscription on SNS and SQSSQS queueSQS queue
WorkerSubscription handlerLambda with an SQS triggerLong-running poller
IAMProvisioned for each serviceRoles and queue policies you writeRoles and queue policies you write
Local testingencore run delivers messagesEmulators or a dev accountEmulators or a dev account

Encore: a webhook receiver and worker from one TypeScript file

Encore

Encore combines the endpoint, topic and subscription declared in your TypeScript code with the AWS resources behind them, running the whole flow locally under encore run and provisioning SNS, SQS and the compute in your own AWS account on deploy. The receiver, queue and worker are defined in the service code, and the whole flow runs locally before it is deployed.

The signing secret and the topic are declared at the top of the service:

import { api } from "encore.dev/api"; import { secret } from "encore.dev/config"; import { Topic, Subscription } from "encore.dev/pubsub"; import log from "encore.dev/log"; import { createHmac, timingSafeEqual } from "node:crypto"; // Set per environment with `encore secret set --type prod,dev,local WebhookSigningSecret`. const signingSecret = secret("WebhookSigningSecret"); interface PaymentEvent { eventId: string; type: string; amountCents: number; } // SNS and SQS on AWS, Pub/Sub on GCP, an in-process emulator locally. export const paymentEvents = new Topic<PaymentEvent>("payment-events", { deliveryGuarantee: "at-least-once", });

The receiver is a raw endpoint, so it sees the exact bytes the provider signed. It verifies the HMAC, publishes and returns 202:

// The receiver verifies the signature, queues the event and answers fast. export const receive = api.raw( { expose: true, method: "POST", path: "/webhooks/payments" }, async (req, res) => { const chunks: Buffer[] = []; for await (const chunk of req) chunks.push(chunk as Buffer); const body = Buffer.concat(chunks); const given = Buffer.from(String(req.headers["x-signature"] ?? ""), "hex"); const expected = createHmac("sha256", signingSecret()).update(body).digest(); if (given.length !== expected.length || !timingSafeEqual(given, expected)) { res.writeHead(401).end("bad signature"); return; } const event = JSON.parse(body.toString()) as PaymentEvent; await paymentEvents.publish(event); res.writeHead(202).end(); }, );

The worker is a subscription with retries. It skips event IDs it has seen, since both providers and queues can deliver twice:

const processed = new Set<string>(); // Stand-in for a table with a unique key. // The worker runs separately from the receiver and retries on failure. const _ = new Subscription(paymentEvents, "process-payment", { handler: async (event) => { // Providers resend events, and delivery is at-least-once, so dedupe by id. if (processed.has(event.eventId)) return; processed.add(event.eventId); log.info("processed payment event", { eventId: event.eventId, type: event.type }); }, retryPolicy: { maxRetries: 10, minBackoff: "1s" }, });

Locally, the secret goes in .secrets.local.cue and encore run delivers messages in process. A signed request returns 202, a resend of the same event is skipped by the worker, and a bad signature returns 401:

~/payments
$ encore run$ SIG=$(printf %s "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | cut -d' ' -f2)$ curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:4000/webhooks/payments -H "x-signature: $SIG" -d "$BODY"202$ curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:4000/webhooks/payments -H "x-signature: 00" -d "$BODY"401

The local development dashboard shows a trace for each webhook that follows it from the receiver into the subscription. On deploy to AWS, Encore creates the SNS topic, an SQS queue for the subscription and the compute in your own account, and provisions an IAM role for the service.

For an agent, the receiver, queue and worker are three declarations in one file that it can exercise end to end locally before anything exists in AWS.

Good to know

Verify signatures before parsing, and compare with timingSafeEqual after checking lengths, as RFC 2104 HMACs are meant to be compared in constant time. Many providers also sign a timestamp to stop replays; webhooks.fyi collects how each provider does it.

Try Encore →

API Gateway, SQS and Lambda

The serverless version uses two Lambda functions: one behind API Gateway that verifies and enqueues, and one triggered by SQS:

import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs"; import type { APIGatewayProxyHandlerV2, SQSHandler } from "aws-lambda"; import { createHmac, timingSafeEqual } from "node:crypto"; const sqs = new SQSClient({}); export const receive: APIGatewayProxyHandlerV2 = async (event) => { const body = Buffer.from(event.body ?? "", event.isBase64Encoded ? "base64" : "utf8"); const given = Buffer.from(event.headers["x-signature"] ?? "", "hex"); const expected = createHmac("sha256", process.env.SIGNING_SECRET!).update(body).digest(); if (given.length !== expected.length || !timingSafeEqual(given, expected)) { return { statusCode: 401 }; } await sqs.send(new SendMessageCommand({ QueueUrl: process.env.QUEUE_URL!, MessageBody: body.toString() })); return { statusCode: 202 }; }; export const work: SQSHandler = async (event) => { for (const record of event.Records) { const payment = JSON.parse(record.body); console.log("processing", payment.eventId); } };

Good to know

You create the queue, a dead-letter queue, both functions, the API, the event source mapping and the roles: sqs:SendMessage for the receiver and receive and delete permissions for the worker. With an SQS trigger, one failing record fails the whole batch unless you report partial batch failures. The signing secret should come from Secrets Manager rather than a plain environment variable.

ECS service with an SQS consumer

For long-running work or heavy dependencies, a container on ECS can receive webhooks and a second service can poll SQS:

import { SQSClient, ReceiveMessageCommand, DeleteMessageCommand } from "@aws-sdk/client-sqs"; const sqs = new SQSClient({}); const QueueUrl = process.env.QUEUE_URL!; while (true) { const { Messages = [] } = await sqs.send( new ReceiveMessageCommand({ QueueUrl, MaxNumberOfMessages: 10, WaitTimeSeconds: 20 }), ); for (const m of Messages) { const payment = JSON.parse(m.Body!); console.log("processing", payment.eventId); await sqs.send(new DeleteMessageCommand({ QueueUrl, ReceiptHandle: m.ReceiptHandle! })); } }

Good to know

You run two services, a load balancer, the queue and its policies, and scale the worker on queue depth yourself. The visibility timeout has to exceed the longest processing time, or messages are redelivered while still in progress.

How to choose

  • Encore if you want the receiver, queue and worker defined together in TypeScript, tested locally, and deployed to SNS and SQS in your own AWS account.
  • API Gateway, SQS and Lambda for spiky, low-volume webhooks where you are comfortable writing the roles and mappings.
  • ECS with SQS for heavy or long-running processing in containers.

GitHub's webhook best practices apply to any of these, including answering within ten seconds and processing asynchronously.

Frequently asked questions

How should I host a webhook endpoint on AWS?

Verify the signature, put the event on a queue and return 2xx quickly, then process it in a separate worker with retries. With Encore, use api.raw to read the raw body and verify it, publish to new Topic<PaymentEvent>("payment-events", { deliveryGuarantee: "at-least-once" }), and handle it in new Subscription(paymentEvents, "process-payment", { handler, retryPolicy: { maxRetries: 10, minBackoff: "1s" } }). On deploy this becomes SNS and SQS in your AWS account.

Why should a webhook handler return quickly?

Providers time out slow responses and retry, which multiplies load and duplicates events. Answering after the event is queued keeps the response fast and moves slow work, like calling other APIs, to a worker that can retry on its own.

How do I handle duplicate webhook deliveries?

Providers resend events, and queues deliver at least once, so the worker records each event ID under a unique key and skips IDs it has already processed.

How do I verify a webhook signature in Node.js?

Compute an HMAC-SHA256 over the raw request body with the signing secret and compare it with the signature header using crypto.timingSafeEqual after checking the lengths match. Parsing the JSON before verifying can change the bytes.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders