AWS Builder Center

Continuing the series — last time I mentioned branching into something more dynamic: a serverless API with Lambda and API Gateway. I'll write that one.

Building a first serverless API with Lambda and API Gateway — HTTP API setup, scoped execution roles, CORS configuration, and the mismatched-header bug that broke it the first time.

Building My First Serverless API with Lambda and API Gateway

Every project in this series so far has been static — files served to a browser with no actual computation happening on the backend. This article is the first step into something dynamic: a small serverless API, built with Lambda and API Gateway, that my static site can actually call.
The concrete goal: a simple endpoint that receives a visitor's name and returns a greeting as JSON. Trivial on purpose — the point of this project isn't the logic, it's understanding how the pieces of a serverless API fit together.

Why Serverless, and Why This Pair of Services

Everything up to now has run on infrastructure I had to provision and think about — buckets, distributions, IAM roles. Lambda is different: you write a function, and AWS runs it only when it's invoked, scaling automatically and charging only for actual execution time. There's no server to patch, size, or leave running idle.
Lambda handles the actual logic. API Gateway sits in front of it and turns an HTTP request into something Lambda understands, then turns Lambda's response back into a proper HTTP response. Neither one is useful for this purpose alone — Lambda has no HTTP-facing endpoint by itself, and API Gateway needs something to route requests to.

The Pieces Involved

  1. Lambda function — the actual code that runs
  2. API Gateway (HTTP API) — exposes it over HTTPS
  3. IAM execution role — permissions the Lambda function runs with
  4. CORS configuration — so my static site's browser JavaScript is allowed to call it

Step 1: Write the Lambda Function

I used Node.js for this one, mostly because the JSON handling is minimal ceremony:
1
2
3
4
5
6
7
8
9
10
11
12
13
export const handler = async (event) => {
const body = event.body ? JSON.parse(event.body) : {};
const name = body.name?.trim() || "stranger";

return {
statusCode: 200,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "https://yourdomain.com"
},
body: JSON.stringify({ message: `Hello, ${name}! Thanks for visiting.` })
};
};
A couple of details worth calling out:
  • event.body arrives as a string, not a parsed object, when invoked through API Gateway's HTTP API — this caught me off guard the first time, since I expected a ready-to-use object.
  • The CORS header is set manually here inside the function response, in addition to configuring CORS at the API Gateway level. Belt-and-suspenders, but it meant I understood exactly where CORS was actually being enforced rather than trusting a single layer blindly.

Step 2: Create the Execution Role

Every Lambda function runs with an execution role — an IAM role that defines what AWS resources the function itself is allowed to touch, separate entirely from who's allowed to invoke the function.
For this project, the function doesn't touch any other AWS service, so the role only needs the baseline permissions to write logs to CloudWatch:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:*:*:*"
}
]
}
Lambda actually attaches a managed policy like this automatically if you create the function through the console with default settings, but writing it out explicitly made the "every Lambda function needs its own scoped permissions" idea click in a way that accepting the default didn't.

Step 3: Create the API Gateway HTTP API

API Gateway has two API types: REST API (older, more features, more complex) and HTTP API (newer, simpler, cheaper, and enough for most straightforward use cases). I used an HTTP API here — there was no feature in this project that needed the older REST API's extra capability.
Setup:
  • Route: POST /greet
  • Integration: Lambda function from Step 1
  • Stage: a single $default auto-deploying stage, which is enough for a project this size
API Gateway gives you an invoke URL once this is set up — something like https://abc123.execute-api.us-east-1.amazonaws.com/greet.

Step 4: Configure CORS

Since this API will be called from JavaScript running on my static site's domain — a different origin than the API Gateway URL — the browser enforces CORS, and API Gateway needs to explicitly allow it.
In the HTTP API's CORS settings:
  • Allowed origins: https://yourdomain.com (not * — more on why below)
  • Allowed methods: POST
  • Allowed headers: Content-Type
I deliberately scoped Allowed origins to my actual domain instead of using a wildcard. A wildcard would work and is tempting during testing, but it means literally any website could call this endpoint from a visitor's browser. For an endpoint that does nothing sensitive, the risk here is low, but it's the right habit to build before this pattern gets reused for something that matters more.

Step 5: Call It from the Static Site

On the frontend, a simple fetch call:
1
2
3
4
5
6
7
8
const response = await fetch("https://abc123.execute-api.us-east-1.amazonaws.com/greet", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: visitorName })
});

const data = await response.json();
console.log(data.message);
With both pieces deployed, this is the first point in the series where my static site is actually talking to backend logic rather than just serving files.

What Went Wrong

  • My first attempt failed with a CORS error in the browser console even though I'd configured CORS in API Gateway — because I was also manually setting Access-Control-Allow-Origin in the Lambda response to a different value than what I'd configured at the gateway level. Browsers enforce the header strictly; a mismatch between the two layers caused the rejection. Worth picking one source of truth rather than configuring CORS in two places with different values.
  • I assumed event.body would already be a parsed object and got a confusing runtime error from calling .trim() on a string before parsing it — a reminder that API Gateway's HTTP API passes the raw body through, parsing is the function's job.
  • My first execution role used the broad AWSLambdaBasicExecutionRole AWS managed policy instead of a role scoped to just this function's log group — it works, but it's broader than it needs to be, and I only noticed after writing the next, more sensitive version of this pattern later and realizing I should probably go back and tighten this one too.

What I Learned

Serverless shifts the "what can go wrong" questions. There's no server to patch or scale, but permissions, CORS, and cold-start behavior become the things that actually matter. Different problems, not fewer problems.
API Gateway and Lambda have a genuinely clean separation of concerns, and seeing it in a small project made it click faster than reading about it in the abstract: API Gateway's job is translating HTTP in and out; Lambda's job is logic. Nothing about this particular function knows or cares that it's being invoked over HTTP at all — the same handler could be triggered by an S3 event or a schedule with no code changes.
Execution roles are IAM applied to code, not people. It's the same least-privilege thinking from the IAM work earlier in this series, just scoped to a function instead of a human or a CI pipeline.

What's Next

Now that the API works, I want to add persistence — storing something, even just a visit counter, in DynamoDB, which means the Lambda function's execution role will need real permissions beyond logging for the first time in this series.
If you've built something similar, I'd be curious whether you reached for HTTP API or REST API for your first serverless project, and why.

Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article