Give Your Lambda an HTTP Front Door
Your Lambda works, but how should the world call it? This is a practical tour of the options: invoking directly, Lambda function URLs, and Amazon API Gateway, with a clear guide to what each one buys you. Then we build an HTTP API with SAM, test it locally, and call it from a Laravel app.
In part one we packaged a Python thumbnailer as a container image, pushed it to ECR, and invoked it directly. Direct invocation is perfect for internal tools and event-driven work, but it needs AWS credentials and request signing on every call, so it is not how a browser, a webhook, or a third party reaches your function. For that you want a plain HTTPS endpoint. Lambda gives you two front doors, and knowing which to pick (and when you need neither) is most of the battle.
You already have one way in: direct invocation
Worth saying plainly, because it is easy to forget: API Gateway is just one of many ways to trigger a Lambda. You can invoke it directly with the SDK or CLI, and in production it is far more often triggered by events: an S3 upload, an SQS message, an EventBridge schedule, a DynamoDB stream. None of those involve API Gateway at all.
Direct invocation shines when the caller is your own backend and already holds AWS credentials. From a server-side app, signing the request is automatic and there is no public surface to secure. The moment the caller is a browser or someone else's system, though, you want HTTP.
The simplest HTTP: Lambda function URLs
A function URL is a dedicated HTTPS endpoint bolted straight onto one function, with no API Gateway in the picture. You get a permanent address shaped like https://<url-id>.lambda-url.<region>.on.aws. The quickest way to add one:
aws lambda create-function-url-config \
--function-name thumbnailer \
--auth-type NONE
AuthType is either NONE (anyone with the URL can call it, good for public endpoints and webhooks) or AWS_IAM (callers must sign requests with IAM credentials). CORS is built in, and for repeatable infrastructure you would define the URL in CloudFormation rather than the CLI:
Type: AWS::Lambda::Url
Properties:
AuthType: NONE
TargetFunctionArn: !Ref Thumbnailer
Cors:
AllowOrigins: ["*"]
AllowMethods: ["POST"]
The lovely part: function URLs deliver events in the same payload format 2.0 as API Gateway HTTP APIs, so the handler code is identical whether you front it with a function URL or a full API. The limits are real, though. You get one function and one route, no custom domain, no API keys or usage plans, and no AWS WAF. Throttling exists only indirectly: you cap concurrency with reserved concurrency, and your ceiling is roughly ten requests per second for each unit of it. A function URL is the right call for a single endpoint, a webhook receiver, or an internal service. For a real API you graduate to API Gateway.
What API Gateway actually buys you
API Gateway sits in front of one or many Lambdas and adds everything an API needs around the raw function. It comes in two flavors, and the choice mostly comes down to which features you need.
HTTP API is the newer, cheaper, lower-latency option, and the right default for a straightforward Lambda proxy. REST API is the older, fuller, pricier one. Here is the split that matters in practice:
- Both HTTP and REST give you: routing across many paths, methods, and functions; stages; custom domain names with managed TLS; CORS; authorizers for IAM, Amazon Cognito, and custom Lambda logic; mutual TLS; CloudWatch metrics and access logs.
- HTTP API adds: native JWT authorizers and automatic deployments, at a noticeably lower price.
- REST API alone gives you: API keys and usage plans, per-client rate limiting, request validation, AWS WAF integration, response caching, edge-optimized and private endpoints, request body transformation, and X-Ray tracing.
So the decision guide reads like this:
- Invoke directly (SDK or CLI) when the caller is your own credentialed backend or an AWS event source.
- Function URL when you need the simplest possible public HTTPS for a single function, and you do not need keys, custom domains, or WAF.
- HTTP API for a real web API with routes, a custom domain, and JWT or Cognito auth, at the lowest cost. This covers most applications.
- REST API when you specifically need API keys and usage plans, request validation, WAF, caching, or private endpoints.
For the thumbnailer, an HTTP API is the sweet spot, so that is what we will build.
Build the HTTP API with SAM
You could click this together in the console, but the AWS Serverless Application Model (SAM) lets you declare the function, the API, and the permissions in one file, and it doubles as a local test rig. A template.yaml for our image-based function:
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Resources:
Thumbnailer:
Type: AWS::Serverless::Function
Properties:
PackageType: Image
Architectures: [arm64]
MemorySize: 512
Timeout: 30
Events:
Resize:
Type: HttpApi
Properties:
Path: /thumbnail
Method: POST
Metadata:
Dockerfile: Dockerfile
DockerContext: .
DockerTag: latest
Outputs:
ApiUrl:
Value: !Sub "https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com/thumbnail"
The HttpApi event creates an HTTP API for you and (this is the quietly useful bit) wires up the permission that lets API Gateway invoke the function. Building and deploying is two commands:
sam build
sam deploy --guided
On the first guided deploy, SAM offers to create and manage a private ECR repository for your image automatically, so you can skip the manual aws ecr dance from part one entirely. After that, sam deploy redeploys with the settings it saved.
Handle the request, and return a real image
Over HTTP, the function no longer receives a bare dictionary. Payload format 2.0 wraps the request: the JSON you posted is in event["body"], the method is at event["requestContext"]["http"]["method"], and there are rawPath and queryStringParameters fields too. The neat trick is to make the handler work for both an HTTP request and a direct invoke:
import base64
import io
import json
from urllib.request import urlopen
from PIL import Image
def load_image_bytes(params):
if "image_base64" in params:
return base64.b64decode(params["image_base64"])
if "image_url" in params:
with urlopen(params["image_url"]) as response:
return response.read()
raise ValueError("Provide image_base64 or image_url")
def handler(event, context):
over_http = "requestContext" in event or "body" in event
# An HTTP request wraps the payload in a body; a direct invoke passes it as-is.
if over_http:
body = event.get("body") or "{}"
if event.get("isBase64Encoded"):
body = base64.b64decode(body).decode()
params = json.loads(body)
else:
params = event
width = int(params.get("width", 200))
image = Image.open(io.BytesIO(load_image_bytes(params)))
image.thumbnail((width, width))
buffer = io.BytesIO()
image.save(buffer, format="PNG")
thumbnail = base64.b64encode(buffer.getvalue()).decode()
if over_http:
# Return the PNG itself so a browser renders it directly.
return {
"statusCode": 200,
"headers": {"Content-Type": "image/png"},
"isBase64Encoded": True,
"body": thumbnail,
}
return {"width": image.width, "height": image.height, "thumbnail_base64": thumbnail}
Format 2.0 gives you two ways to respond. Return any JSON-serializable value and API Gateway auto-wraps it as a 200 with a JSON content type, which is great for plain data. Or return an explicit object with statusCode, headers, body, and isBase64Encoded for full control. To send back an actual PNG that a browser can show, you need that explicit form: base64-encode the bytes, set isBase64Encoded to true, and the HTTP API decodes it to binary on the way out.
A note on permissions
With the SAM HttpApi event above, the resource-based policy that allows API Gateway to call your function is created automatically. If you ever wire it by hand, that policy is what you are adding:
aws lambda add-permission \
--function-name thumbnailer \
--statement-id apigateway-invoke \
--action lambda:InvokeFunction \
--principal apigateway.amazonaws.com \
--source-arn "arn:aws:execute-api:us-east-1:111122223333:abc123/*/POST/thumbnail"
The principal is always apigateway.amazonaws.com and the action is lambda:InvokeFunction. And because none of this cares how the function is packaged, a container-image Lambda is invoked by API Gateway exactly like a zip one.
Test the whole API locally
SAM can stand up a local API Gateway in front of your container, payload format 2.0 envelope and all:
sam local start-api
It serves on http://127.0.0.1:3000 by default. Because our handler returns the PNG with isBase64Encoded, you can save the response straight to a file and open it:
curl -s -X POST http://127.0.0.1:3000/thumbnail \
-H "Content-Type: application/json" \
-d '{"image_url": "https://httpbin.org/image/jpeg", "width": 120}' \
--output thumb.png
For a single function with no HTTP layer, sam local invoke -e event.json runs it against an event file instead. Between this and the Runtime Interface Emulator from part one, you can exercise the whole thing before anything reaches AWS.
Call it from a Laravel app
Here is the payoff for the PHP crowd, and it mirrors the two front doors exactly. Install the SDK with composer require aws/aws-sdk-php (Laravel 13 wants PHP 8.3+).
Through API Gateway it is just an HTTP request, so Laravel's HTTP client is all you need. Since the endpoint returns image bytes, you can pass them straight through to the browser:
use Illuminate\Support\Facades\Http;
$response = Http::post('https://abc123.execute-api.us-east-1.amazonaws.com/thumbnail', [
'image_url' => 'https://example.com/cat.jpg',
'width' => 120,
]);
return response($response->body(), 200)->header('Content-Type', 'image/png');
With no gateway at all, call the function directly with the AWS SDK for PHP. The handler detects the direct invoke and returns plain JSON, so you decode the base64 yourself:
use Aws\Lambda\LambdaClient;
$lambda = new LambdaClient(['region' => 'us-east-1', 'version' => 'latest']);
$result = $lambda->invoke([
'FunctionName' => 'thumbnailer',
'Payload' => json_encode(['image_url' => 'https://example.com/cat.jpg', 'width' => 120]),
]);
$data = json_decode($result->get('Payload')->getContents(), true);
$png = base64_decode($data['thumbnail_base64']);
Which one to use? From a credentialed Laravel backend, the SDK invoke is direct and needs no public endpoint. When the caller is a browser, a mobile client, or a partner, put API Gateway or a function URL in front and call that. Credentials for the SDK come from the usual AWS chain: environment variables locally, and an IAM role on the server in production, so you never hard-code keys.
You now have a function reachable over HTTP, with a clear sense of which door fits which job, tested locally and wired into a real app. One function behind one endpoint covers an enormous amount of ground. The next question is what happens when the work does not fit in one function: several steps, retries, things running in parallel. That is where Step Functions come in, and in part three we will see when they are worth it and when they are overkill.