Section 4 · Building Something Real
A Serverless API with Lambda, API Gateway and DynamoDB
17 min3 min readSpot the bug
Lesson at a glance
Build Lantern’s RSVP API from an Amazon API Gateway HTTP API, an AWS Lambda function with a least-privilege role, and a DynamoDB table, with duplicate protection and costs that fall to near zero when idle.
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
The front end now loads from edge locations in milliseconds. But an RSVP is not a static file: it has to be checked, stored, and counted. On the old server that was a route in the Node.js app, running all day for the few minutes a week anyone RSVPed. Now it becomes three managed pieces that cost nothing while idle and scale up when an event goes viral.
The shape of it
Two routes are enough for launch:
| Route | Does |
|---|---|
POST /events/{eventId}/rsvps |
Stores one RSVP, refuses duplicates |
GET /events/{eventId}/rsvps |
Returns the RSVP count for the event |
The function
API Gateway’s HTTP API passes Lambda a JSON event (payload format 2.0) with the path parameters, the method and the body as a string. The function returns a status code, headers and a string body:
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand, QueryCommand } from "@aws-sdk/lib-dynamodb";
// Created once per execution environment and reused across invocations.
// removeUndefinedValues: an RSVP without a "name" is stored without that attribute.
const db = DynamoDBDocumentClient.from(new DynamoDBClient({}), {
marshallOptions: { removeUndefinedValues: true },
});
const TABLE = process.env.TABLE_NAME;
const reply = (statusCode, data) => ({
statusCode,
headers: { "content-type": "application/json" },
body: JSON.stringify(data),
});
export const handler = async (event) => {
const eventId = event.pathParameters?.eventId;
if (event.requestContext.http.method === "GET") {
const res = await db.send(new QueryCommand({
TableName: TABLE,
KeyConditionExpression: "eventId = :e",
ExpressionAttributeValues: { ":e": eventId },
Select: "COUNT",
}));
return reply(200, { eventId, count: res.Count });
}
const { email, name } = JSON.parse(event.body ?? "{}");
if (typeof email !== "string" || !email.includes("@")) {
return reply(400, { error: "A valid email is required" });
}
try {
await db.send(new PutCommand({
TableName: TABLE,
Item: { eventId, email: email.toLowerCase(), name, createdAt: new Date().toISOString() },
ConditionExpression: "attribute_not_exists(email)",
}));
} catch (err) {
if (err.name === "ConditionalCheckFailedException") {
return reply(409, { error: "You have already RSVPed to this event" });
}
throw err;
}
return reply(201, { eventId, email });
};Notice what is not there: no credentials, no table ARN, no connection pool. The SDK finds the execution role’s credentials, and the table name comes from an environment variable so the same code runs against dev and production tables. The Node.js runtime includes the AWS SDK for JavaScript v3, but bundle the version you tested with your code so a runtime update can’t change it under you.
One honest limit: a single Query reads at most 1 MB, so for an event with an enormous number of RSVPs the count would need pagination, or a separate counter item updated on each write. For Lantern’s events, one call is plenty.
The permissions
The function’s execution role gets exactly what the code calls, on exactly one table, plus the AWS managed AWSLambdaBasicExecutionRole policy so it can write logs:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "RsvpTable",
"Effect": "Allow",
"Action": ["dynamodb:PutItem", "dynamodb:Query"],
"Resource": "arn:aws:dynamodb:eu-west-1:111122223333:table/lantern-rsvps"
}
]
}The other direction is the function’s resource-based policy: API Gateway may invoke it. The console adds this when you connect a route; from the CLI it looks like this, scoped to one API by its ARN:
aws lambda add-permission --function-name lantern-rsvp \
--statement-id allow-lantern-http-api \
--action lambda:InvokeFunction \
--principal apigateway.amazonaws.com \
--source-arn "arn:aws:execute-api:eu-west-1:111122223333:a1b2c3d4e5/*"The API
In the API Gateway console, create an HTTP API, add the two routes with a Lambda integration to lantern-rsvp, and use the auto-deployed $default stage. HTTP APIs are the cheaper, simpler type. Choose a REST API instead when you need API keys with usage plans, per-client throttling, request validation, response caching or AWS WAF.
Because the site is served from https://lantern.example and the API lives on another domain, configure CORS on the HTTP API to allow that origin, or add the API as a second origin in the CloudFront distribution under /api/* so the browser sees one domain. Then test:
curl -i -X POST \
"https://a1b2c3d4e5.execute-api.eu-west-1.amazonaws.com/events/evt-2026-street-food/rsvps" \
-H "content-type: application/json" \
-d '{"email":"rita@example.com","name":"Rita"}'The first call returns 201; send it again and you get 409.
What it costs, and how to cap it
Each piece bills per use: API Gateway per request, Lambda per request and GB-second, DynamoDB on-demand per read and write. A quiet week costs close to nothing, and the festival evening costs a few thousand requests’ worth. To make sure a bot can’t run up the bill or overload anything downstream, set reserved concurrency on the function (a ceiling on simultaneous copies) and a throttling limit on the API’s stage.
Next you will watch this API in production: logs, metrics and alarms, and a structured review of the whole design.
Key takeaways
- An HTTP API in API Gateway receives the request, invokes Lambda with a JSON event, and returns whatever status code and body the function returns.
- The Lambda function needs two kinds of permission; its execution role lets it call DynamoDB, and its resource-based policy lets API Gateway invoke it.
- A conditional write such as attribute_not_exists stops double-clicks and retries from creating duplicate items.
- Choose HTTP APIs for simple, low-cost APIs, and REST APIs when you need API keys, per-client throttling, request validation, caching or AWS WAF.
- Every piece bills per request, so an idle API costs close to nothing, and reserved concurrency caps how far a spike can scale.