The situation
A platform team maintains three Lambda-fronted services, all of them currently sitting behind API Gateway REST APIs because that was the team’s historical default. The platform lead wants to ask the honest question: do all three still need API Gateway, or is at least one of them a Lambda Function URL candidate?
- A GitHub webhook receiver. GitHub POSTs to our URL on every push; a Lambda validates the HMAC signature and enqueues the event to SQS. One URL, one Lambda, one HTTP verb. No routing, no per-route auth, no custom domain needed. GitHub accepts any HTTPS URL you give it.
- A tiny admin API. Three routes:
GET /status,POST /rebuild,DELETE /cache. Internal staff use it via a small CLI. Cognito authentication, moderate throttling. Three paths, three methods, one shared Lambda behind them. - A customer-facing API. Six routes, three of them public and rate-limited, three of them Cognito-authenticated. Two of the routes have their own dedicated Lambdas; the other four share one. Custom domain (
api.acme.com). Access logs in CloudWatch, CORS, per-route throttles.
Three different service shapes, two very similar AWS surfaces in front of Lambda, and a real question about which fits where.
What actually matters
Before pattern-matching to a service, it’s worth saying what a good front door to a Lambda actually does for us, and what the price of each property is.
The first thing we want is a shape that matches the service, not a box the service has to squeeze into. A webhook receiver is one URL delivering one payload to one function; routing is a non-feature. A six-route customer API with mixed authorisation is the opposite, it is a routing problem, and the routing has to live somewhere. If routing lives in the front door we write one API surface configuration and keep the Lambdas single-purpose; if routing lives in the Lambda we write a little web framework inside a function and carry that complexity forever. Either can be made to work, but only one of them reads like the domain.
The second is the blast radius of a misconfiguration. A public Function URL with AuthType: NONE is a public endpoint, and the only thing stopping abuse is whatever the function does on its first line. An API Gateway with a JWT authoriser can reject a bad token before Lambda is ever invoked. Both can be secured; the question is where the mistake surface sits and how loud it is when the mistake happens. For a webhook protected by an HMAC signature, the Function URL’s lack of authoriser is actually fine, the authoriser would have to trust the same signature anyway. For a customer-facing API, pushing auth down into the function means every route’s handler gets a chance to forget the check.
The third is cost shape, which is less about “cheaper” and more about “cheaper when”. Some front doors add zero per-request cost; others add a small per-request fee that’s a rounding error at low volume and a real line item at high volume. Average cost isn’t the deciding factor, it’s which cost shape we’d rather own as traffic grows by an order of magnitude.
The fourth is recovery and migration cost if we pick wrong. When two of the candidate front doors deliver the same event shape to the function behind them, code written against one can be served by the other with no rewrite, the migration is a config change and a DNS cutover, not a re-implementation. That property is worth a lot: it means we can pick the smaller option today and not be punished if we grow into the bigger one.
The fifth is observability and the stuff the platform team gets for free. A managed API surface gives access logs with request IDs, integration latencies, per-route metrics, throttle counters, and a stage we can version. A bare-URL front door gives the function’s own metrics and logs. For a webhook receiver, function logs are enough; any request that reaches the function shows up there. For a customer-facing service where we want to answer “is POST /orders slower this week?”, per-route metrics are the point, and that comes from the gateway, not from the function.
And sixth, the softer one: the feature ceiling. Custom domains, WAF integration, API keys and usage plans, request transforms, mutual TLS, these live at different altitudes across the candidate front doors. Picking one that’s one rung too low means the day we need mutual TLS we’re migrating; picking one too high means paying for features we’ll never turn on. The shape of the service usually tells us which rung is honest.
What we’ll filter on
Filtering the exploration into properties we can score each front door against:
- Routing, one URL to one function, or many routes to many integrations?
- Supported authorisers. NONE / IAM only, or JWT / Cognito / Lambda authoriser as well?
- Per-request cost, zero, sub-dollar per million, or REST-API rates?
- Custom domain, built in, or needs CloudFront in front?
- Observability, function-level metrics only, or per-route gateway metrics and access logs?
- Feature ceiling, response streaming, WAF, mutual TLS, API keys and usage plans.
The Lambda-frontdoor landscape
-
Lambda Function URL. A dedicated HTTPS endpoint baked into the Lambda service itself. Configure
FunctionUrlConfigon a function and AWS hands backhttps://<url-id>.lambda-url.<region>.on.aws/; requests to that URL invoke the function directly, with no API Gateway in the path. Auth types areNONE(public) orAWS_IAM(caller signs with SigV4). CORS is configurable, throttling is inherited from the function’s reserved concurrency, and response streaming is supported viaInvokeMode: RESPONSE_STREAM. What’s absent is everything a managed API surface does: no routes, no per-route authorisers, no API keys, no native custom domain, no WAF integration (WAF attaches to CloudFront, so if we need it we put CloudFront in front of the URL). Pricing: the Lambda invocation and nothing else. -
API Gateway HTTP API. The simpler, cheaper sibling of REST API, built later, deliberately trimmed. Routes, integrations, authorisers (
AWS_IAM, JWT, or a Lambda authoriser), CORS, stages, custom domains as a first-class feature, throttling per route or per stage, access logs to CloudWatch. Missing (compared to REST API): request/response transforms, API keys plus usage plans, direct WAF integration (still CloudFront for that), private endpoints via VPC Link v1. Pricing: around a dollar per million requests, plus the Lambda invocation. The right default for a multi-route service that doesn’t need the full REST API feature set. -
API Gateway REST API. The older, richer sibling. Everything HTTP API has, plus: request/response mapping templates (VTL), API keys with usage plans, direct WAF integration, private REST APIs via interface VPC endpoints, edge-optimised endpoints via bundled CloudFront, mutual TLS on custom domains, and the Lambda-proxy integration alongside the richer non-proxy integration. Pricing is roughly three to four times HTTP API per request. Worth keeping on the list for the features HTTP API deliberately dropped, but it’s overkill for anything that just needs routes and JWT auth.
-
Application Load Balancer with Lambda target. Not usually the first thing that comes to mind, but a real option: ALB supports Lambda as a target group type, so an existing ALB in front of a VPC workload can gain a Lambda-backed route without adding API Gateway. The trade is that ALB bills an hourly fee regardless of traffic, it delivers its own ELB event shape (not API Gateway v2), and authorisation options are ALB-specific (OIDC or Cognito user pool on the listener rule). Good when an ALB is already there and we want to slot a Lambda behind an existing host; wasteful as a dedicated front door for one Lambda.
Side by side
| Option | Routing | Auth types | Per-request cost | Custom domain | Observability | Feature ceiling |
|---|---|---|---|---|---|---|
| Function URL | One URL / one function | NONE, IAM | $0 | via CloudFront | Lambda metrics only | Response streaming ✓, WAF via CF, no custom domain, no API keys |
| HTTP API | Many routes | NONE, IAM, JWT, Lambda authoriser | ~$1 / million | Built in | Per-route metrics, access logs | No API keys, WAF via CF, no mTLS, no streaming |
| REST API | Many routes | NONE, IAM, Cognito, Lambda authoriser | ~$3.50 / million | Built in | Per-route metrics, access logs | API keys + usage plans, WAF direct, mTLS, request transforms |
| ALB + Lambda target | Listener rules | OIDC, Cognito (listener) | Hourly ALB fee + LCUs | Built in | ALB access logs + Lambda metrics | ELB event shape only, good when ALB already exists |
Reading the table by service rather than by option:
- GitHub webhook receiver, one URL, one function, HMAC-in-body auth, probably millions of deliveries a month. Function URL fits cleanly; HTTP API would add a per-request bill for zero additional feature.
- Tiny admin API, three routes, Cognito, fifty thousand requests a month. HTTP API with a JWT authoriser: routing and auth that the team doesn’t have to write, at a monthly cost that rounds to zero.
- Customer-facing API, six routes, mixed auth, custom domain, real traffic, need for per-route metrics. HTTP API. REST API only if API keys, usage plans, or mTLS land on the roadmap.
Matching service shapes to front doors
The picks in depth
Webhook receiver -> Lambda Function URL. A Function URL on the webhook Lambda with AuthType: NONE gets us an HTTPS endpoint at https://<id>.lambda-url.<region>.on.aws/, which is exactly the string GitHub needs in its webhook configuration. Security lives inside the function: read the X-Hub-Signature-256 header, recompute the HMAC over the raw body with the shared secret, reject on mismatch. Throttling lives on the function itself via reserved concurrency, which is coarser than per-route throttling but appropriate for a single-URL service. The cost story is the main reason to pick this over HTTP API: at thirty million deliveries a month, HTTP API’s per-request fee would be roughly thirty dollars for nothing we actually use, because we aren’t routing and we aren’t using its authorisers. The one gotcha worth calling out is that the function runs on the public URL directly, so if the HMAC check is wrong the function still ran; pairing reserved concurrency with a small DLQ and alarming on signature-rejection count keeps the blast radius contained. Response streaming is available here if we ever need it; HTTP API does not offer it.
WebhookFnUrl:
Type: AWS::Lambda::Url
Properties:
TargetFunctionArn: !GetAtt WebhookFn.Arn
AuthType: NONE
Cors:
AllowOrigins: ["https://github.com"]
AllowMethods: ["POST"]
AllowHeaders: ["x-hub-signature-256", "x-github-event"]
WebhookFnUrlPublicAccess:
Type: AWS::Lambda::Permission
Properties:
FunctionName: !Ref WebhookFn
Action: lambda:InvokeFunctionUrl
Principal: "*"
FunctionUrlAuthType: NONE
Tiny admin API -> API Gateway HTTP API. A single function URL plus internal dispatch could cover three routes and a Cognito JWT check, but we’d be writing routing and JWT validation inside the Lambda for no good reason: HTTP API gives us both for free, and at fifty thousand requests a month the per-request fee amounts to about five cents. The cleanest shape is an HTTP API with three routes (GET /status, POST /rebuild, DELETE /cache) all pointing at the same integration, plus a JWT authoriser wired to the Cognito user pool. Access logs go to CloudWatch so audit questions (“who ran POST /rebuild yesterday?”) have answers. Throttling sits on the stage, which for an internal CLI is plenty. The value of HTTP API here is less about features and more about not writing boilerplate: no web framework inside the function, no JWKS cache to manage, no route-matching table to keep in sync with the IaC.
Customer-facing API -> API Gateway HTTP API. Six routes, three Lambdas, three public and three JWT-authenticated, custom domain, per-route metrics, this is what HTTP API was built for. The custom domain comes in natively, with an ACM certificate attached and a single base-path mapping to the stage; no CloudFront-in-front to maintain. The JWT authoriser validates Cognito tokens against the user pool’s JWKS without code. IAM auth on the admin sub-routes uses SigV4 from the internal caller. Per-route metrics let us answer “is POST /orders p99 slower this week?” from CloudWatch directly, which is impossible if the only thing in the picture is Lambda metrics. The only reason to reach past HTTP API to REST API here would be a concrete need for API keys plus usage plans, native WAF integration, mutual TLS on the custom domain, or request/response mapping templates; if and when any of those shows up on the roadmap, the event shape stays the same and the migration is a redeploy, not a rewrite.
HttpApi:
Type: AWS::ApiGatewayV2::Api
Properties:
Name: customer-api
ProtocolType: HTTP
CorsConfiguration:
AllowOrigins: ["https://acme.com"]
AllowMethods: [GET, POST]
JwtAuthorizer:
Type: AWS::ApiGatewayV2::Authorizer
Properties:
ApiId: !Ref HttpApi
AuthorizerType: JWT
IdentitySource: ["$request.header.Authorization"]
JwtConfiguration:
Audience: ["<cognito-app-client-id>"]
Issuer: "https://cognito-idp.eu-west-1.amazonaws.com/<pool-id>"
OrdersRoute:
Type: AWS::ApiGatewayV2::Route
Properties:
ApiId: !Ref HttpApi
RouteKey: "POST /orders"
AuthorizationType: JWT
AuthorizerId: !Ref JwtAuthorizer
Target: !Sub "integrations/${OrdersIntegration}"
A worked example: one month of bill shape
Steady state, same three services, same month:
GitHub webhook receiver (Function URL)
30,000,000 invocations × Lambda (128MB, 100ms) ≈ $ 6
Function URL per-request fee $ 0
subtotal $ 6
Tiny admin API (HTTP API)
50,000 requests × $1.00 / million ≈ $ 0.05
50,000 Lambda invocations (512MB, 200ms) ≈ $ 0.10
subtotal $ 0.15
Customer-facing API (HTTP API)
5,000,000 requests × $1.00 / million ≈ $ 5
5,000,000 Lambda invocations (mixed) ≈ $ 12
custom domain + ACM cert $ 0
subtotal $ 17
Total with the pick: $ 23.15
Comparable "everything on HTTP API" bill:
+ $30 for 30M webhook requests → $ 53.15
Comparable "everything on REST API" bill:
+ ~$3 admin + $17 customer + $105 webhook → $125
The numbers are illustrative; the shape is the point. Most of the savings come from one decision, not paying per-request for the webhook, and almost none come from the low-volume admin API. That tells us where to spend our attention: the right front door for the high-traffic service matters a lot; the right front door for the internal tool matters almost not at all.
What’s worth remembering
- The front door follows the service shape. One-URL-one-function is Function URL; many-routes-many-integrations is HTTP API; many-routes-plus-API-keys-or-mTLS is REST API.
- Function URL auth is NONE or IAM only. Anything with Cognito, JWT, or a custom authoriser needs HTTP API (or REST API).
- Function URL has no per-request fee. At millions of requests a month that starts to matter; at tens of thousands it doesn’t.
- HTTP API is the default managed API surface. It’s cheaper and simpler than REST API, and it covers most multi-route services without needing to step up.
- REST API still exists, and it’s where the ceiling features live. API keys with usage plans, direct WAF integration, mutual TLS, request/response mapping templates.
- The event shape is shared between Function URL and HTTP API. API Gateway v2 payload format; Lambda code doesn’t change if the front door does. Migration is config, not rewrite.
- Response streaming is a Function URL feature. HTTP API doesn’t stream; if the workload emits progressive output, that decides it.
- Custom domains are built into HTTP API and REST API. Function URL needs CloudFront in front to map a custom domain, which usually means HTTP API is less hassle the moment a hostname is in the picture.
- WAF integrates with neither Function URL nor HTTP API directly. REST API integrates directly; for the other two, attach WAF to a CloudFront distribution in front.
- When in doubt, reach for HTTP API. The floor is cheap, the feature set is honest, and stepping either way, down to Function URL or up to REST API, is a configuration change, not a rebuild.
Function URLs for webhooks and one-function tools; HTTP APIs for anything with routes, per-route auth, or a custom domain; REST API only when the feature ceiling of HTTP API isn’t tall enough. Same event shape across the first two, different front doors. The work isn’t picking a favourite, it’s reading the service shape and matching the front door to the routes, the authorisers, and the cost shape it actually needs.