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:
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:
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.
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.
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);
}
};
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.
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! }));
}
}
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.
GitHub's webhook best practices apply to any of these, including answering within ten seconds and processing asynchronously.
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.
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.
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.
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.