EduFlow
AWS Cloud Foundations
0%

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.

  1. 1The shape of it
  2. 2The function
  3. 3The permissions
  4. 4The API
  5. 5What it costs, and how to cap it

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

The browser sends POST /events/{eventId}/rsvps to an API Gateway HTTP API. API Gateway invokes the lantern-rsvp Lambda function, which runs with an execution role and writes to the lantern-rsvps DynamoDB table. The function's logs go to CloudWatch Logs. Browser API Gateway HTTP API Lambda lantern-rsvp DynamoDB lantern-rsvps CloudWatch Logs resource policy execution role
One request through the RSVP API

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:

rsvp/index.mjs
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:

rsvp-role-policy.json
{
  "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:

Terminal
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:

Terminal
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.

Further reading

Check your understanding

3 questions

Question 1 of 3

The function works when tested in the Lambda console, but calls through API Gateway fail because API Gateway is not allowed to invoke it. What is missing?

Question 2 of 3

An organiser double-clicks Submit and two identical RSVP requests arrive within milliseconds. How does the lesson’s function avoid storing two RSVPs?

Question 3 of 3

Lantern needs API keys so that two partner websites can submit RSVPs, each with its own usage limit. Which API Gateway type fits?

Practice

Spot the bug

Review the RSVP function

This version of lantern-rsvp was opened as a pull request. It looks almost identical to the lesson’s function, but three lines would cause a security incident, a growing bill, or broken RSVPs in production. Select them.

Select the line(s) you think are wrong, then check.