Receiving email-webhook Requests in AWS Lambda with SST
SST makes it easy to stand up an API Gateway v2 HTTP endpoint backed by a Lambda function. Once deployed, you paste the URL into the email-webhook dashboard and every incoming email lands in your function as a typed JSON payload.
This guide uses SST v3 (Ion), API Gateway v2, and TypeScript.
Prerequisites
- An AWS account with credentials configured locally (
aws configureor environment variables) - Node.js 18+
- SST CLI installed (
npm install -g sst)
Step 1: Create a new SST project
mkdir email-handler && cd email-handler
npx create-sst@latest
Choose the default Empty template when prompted. This creates:
email-handler/
โโโ sst.config.ts
โโโ package.json
โโโ src/
Step 2: Define the API and function
Replace the contents of sst.config.ts:
/// <reference path="./.sst/platform/config.d.ts" />
export default $config({
app(input) {
return {
name: "email-handler",
// Non-production stages are torn down on `sst remove`; production resources are kept
removal: input?.stage === "production" ? "retain" : "remove",
home: "aws",
};
},
async run() {
// Stores the shared secret as an encrypted SST secret โ set it once with
// `npx sst secret set WebhookSecret <value>` and it's available at runtime
// via the Resource binding without any manual env var wiring
const secret = new sst.Secret("WebhookSecret");
// Creates an API Gateway v2 HTTP API (lower latency and cost than REST API)
const api = new sst.aws.ApiGatewayV2("EmailApi");
// Routes POST /webhook to the handler function; linking the secret
// automatically grants the function permission to read it at runtime
api.route("POST /webhook", {
handler: "src/handler.handler",
link: [secret],
});
return { url: api.url };
},
});
Step 3: Write the handler
Create src/handler.ts:
import type {
APIGatewayProxyEventV2,
APIGatewayProxyResultV2,
} from "aws-lambda";
import { Resource } from "sst";
interface Attachment {
filename: string;
content: string; // base64-encoded
}
interface EmailPayload {
from: string;
to: string;
subject: string;
message: string;
attachments: Attachment[];
}
export async function handler(
event: APIGatewayProxyEventV2
): Promise<APIGatewayProxyResultV2> {
// Verify the shared secret set in the email-webhook dashboard
if (event.headers["x-my-api-key"] !== Resource.WebhookSecret.value) {
return { statusCode: 401 };
}
// Use X-email-webhook-id as an idempotency key
const deliveryId = event.headers["x-email-webhook-id"];
const payload: EmailPayload = JSON.parse(event.body ?? "{}");
console.log(JSON.stringify({
deliveryId,
from: payload.from,
subject: payload.subject,
attachments: payload.attachments.length,
}));
// Your logic here โ store to a database, trigger a workflow, etc.
return { statusCode: 200 };
}
The handler does three things before any business logic:
- Authenticates the request using the
X-My-Api-Keyheader. - Reads the idempotency key (
X-email-webhook-id) so you can detect duplicate deliveries. - Parses the typed payload โ no
anycasts needed.
Step 4: Install types
npm install --save-dev @types/aws-lambda
Step 5: Set the secret and deploy
Set the secret value before the first deploy. Replace my-secret-token with a random string you generate (e.g. openssl rand -hex 32):
npx sst secret set WebhookSecret my-secret-token
Then deploy:
npx sst deploy --stage production
SST prints your endpoint URL when it finishes:
url: https://abc123.execute-api.us-east-1.amazonaws.com
Your webhook URL is:
https://abc123.execute-api.us-east-1.amazonaws.com/webhook
Step 6: Wire it up in the email-webhook dashboard
- Open the email-webhook dashboard and click New webhook (or edit an existing one).
- Set Webhook URL to your Lambda endpoint.
- Set HTTP Method to
POST. - Open the Custom Headers section and add:
| Header name | Header value |
|---|---|
X-My-Api-Key |
my-secret-token |
- Click Save.
Send a test email to your @email-webhook.com address. Within a few seconds your Lambda function receives the request and logs the delivery.
Handling attachments
Attachments arrive base64-encoded inside the attachments array. To upload them to S3, add an S3 bucket to your SST config and link it to the function:
// sst.config.ts (inside run())
const bucket = new sst.aws.Bucket("Attachments");
api.route("POST /webhook", {
handler: "src/handler.handler",
link: [secret, bucket],
});
return { url: api.url, bucket: bucket.name };
Then in the handler:
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
const s3 = new S3Client({});
for (const attachment of payload.attachments) {
const buffer = Buffer.from(attachment.content, "base64");
await s3.send(new PutObjectCommand({
Bucket: Resource.Attachments.name,
Key: `${deliveryId}/${attachment.filename}`,
Body: buffer,
}));
}
SST automatically grants the Lambda function the necessary S3 permissions when you use link.
Staying within Lambda timeouts
API Gateway v2 has a hard 30-second integration timeout. If you have heavy processing to do (OCR, ML inference, large file uploads), acknowledge the request immediately and hand off to a queue:
import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs";
const sqs = new SQSClient({});
export async function handler(event: APIGatewayProxyEventV2) {
// auth check ...
await sqs.send(new SendMessageCommand({
QueueUrl: Resource.EmailQueue.url,
MessageBody: event.body ?? "{}",
MessageDeduplicationId: event.headers["x-email-webhook-id"],
MessageGroupId: "email",
}));
return { statusCode: 200 };
}
Return 200 as soon as the message is enqueued. A second Lambda, subscribed to the queue, does the real work without any timeout pressure.
Next steps
- Lock down your endpoint: see Authenticating Your Endpoint for all supported header patterns.
- Debug deliveries: enable Message Logs to see HTTP status, duration, and payload metadata for each delivery.
- Decode attachments: for deeper examples including type detection and safe filename handling, see Handling Attachments.