API Management
Introduction
Section titled “Introduction”Azure API Management (APIM) is a managed service for publishing, securing, and analyzing APIs at scale. It acts as a gateway between clients and backend services, providing features such as rate limiting, policy enforcement, authentication, and developer portal integration.
APIM is commonly used to expose internal services as managed APIs, implement API versioning, and monitor API usage across organizations. For more information, see Azure API Management overview.
LocalStack for Azure provides a local environment for building and testing applications that make use of Azure API Management, including the gateway: a request to an instance’s hostname is matched to an operation, authorised against its subscription key, run through the API’s policies, and forwarded to its backend. The supported APIs are available on our API Coverage section, which provides information on the extent of API Management’s integration with LocalStack.
Getting started
Section titled “Getting started”This guide walks you through creating an API Management service, importing an API from an OpenAPI document, attaching a policy, and calling that API through the gateway. It assumes basic knowledge of the Azure CLI and our lstk az proxy.
Launch LocalStack using your preferred method. For more information, see Introduction to LocalStack for Azure. Once the container is running, enable Azure CLI interception by running:
lstk az start-interceptionThis command points the az CLI away from the public Azure management REST API and toward the LocalStack for Azure emulator API.
To revert this configuration, run:
lstk az stop-interceptionThis reconfigures the az CLI to send commands to the official Azure management REST API.
Create a resource group
Section titled “Create a resource group”Create a resource group for your API Management resources:
az group create \ --name rg-apim-demo \ --location westeurope{ "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-apim-demo", "location": "westeurope", "name": "rg-apim-demo", "properties": { "provisioningState": "Succeeded" }, ...}Check service name availability
Section titled “Check service name availability”An API Management name is globally unique, so check it before creating:
az apim check-name --name apimdoc86{ "message": "", "nameAvailable": true, "reason": "Valid"}Create an API Management service instance
Section titled “Create an API Management service instance”az apim create \ --name apimdoc86 \ --resource-group rg-apim-demo \ --location westeurope \ --sku-name Consumption \ --publisher-name "LocalStack" \ --publisher-email "dev@localstack.cloud"{ "gatewayUrl": "https://apimdoc86.azure-api.net", "hostnameConfigurations": [ { "certificateSource": "BuiltIn", "defaultSslBinding": true, "hostName": "apimdoc86.azure-api.net", "type": "Proxy" } ], "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-apim-demo/providers/Microsoft.ApiManagement/service/apimdoc86", "location": "West Europe", "name": "apimdoc86", "platformVersion": "mtv1", "provisioningState": "Succeeded", "publisherEmail": "dev@localstack.cloud", "publisherName": "LocalStack", "sku": { "capacity": 0, "name": "Consumption" }, ...}A new instance ships with the content Azure seeds: the built-in groups, the demonstration echo-api, the notification templates, and an all-access subscription named master.
Import an API from an OpenAPI document
Section titled “Import an API from an OpenAPI document”cat > orders.json <<'JSON'{ "openapi": "3.0.1", "info": { "title": "Orders", "version": "1.0" }, "servers": [{ "url": "https://httpbin.org" }], "paths": { "/get": { "get": { "operationId": "getItems", "summary": "Get items", "responses": { "200": { "description": "ok" } } } } }}JSON
az apim api import \ --resource-group rg-apim-demo \ --service-name apimdoc86 \ --api-id orders-api \ --path orders \ --specification-format OpenApiJson \ --specification-path orders.jsonThe operations the document described are now addressable. The operation takes its name from
operationId, case included:
az apim api operation list \ --resource-group rg-apim-demo \ --service-name apimdoc86 \ --api-id orders-api \ --output tableDescription DisplayName Method Name ResourceGroup UrlTemplate------------- ------------- -------- -------- --------------- -------------Get items Get items GET getItems rg-apim-demo /getAttach a policy
Section titled “Attach a policy”Policies are validated when they are saved, so a mistake is reported to whoever wrote it rather than at the first request:
az rest --method put \ --url "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-apim-demo/providers/Microsoft.ApiManagement/service/apimdoc86/apis/orders-api/policies/policy?api-version=2024-05-01" \ --body '{ "properties": { "format": "xml", "value": "<policies><inbound><base /><set-header name=\"X-Added-By\" exists-action=\"override\"><value>@(context.Api.Name)</value></set-header><rate-limit calls=\"5\" renewal-period=\"60\" /></inbound><backend><base /></backend><outbound><base /></outbound></policies>" } }'The response is the stored document, indented the way the service stores it:
{ "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-apim-demo/providers/Microsoft.ApiManagement/service/apimdoc86/apis/orders-api/policies/policy", "name": "policy", "properties": { "format": "xml", "value": "<policies>\r\n\t<inbound>\r\n\t\t<base />\r\n\t\t<set-header name=\"X-Added-By\" exists-action=\"override\">\r\n\t\t\t<value>@(context.Api.Name)</value>\r\n\t\t</set-header>\r\n\t\t<rate-limit calls=\"5\" renewal-period=\"60\" />\r\n\t</inbound>\r\n\t<backend>\r\n\t\t<base />\r\n\t</backend>\r\n\t<outbound>\r\n\t\t<base />\r\n\t</outbound>\r\n</policies>" }, "type": "Microsoft.ApiManagement/service/apis/policies"}A document naming an element that does not exist is refused, and the policy already in place is left untouched:
az rest --method put \ --url "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-apim-demo/providers/Microsoft.ApiManagement/service/apimdoc86/apis/orders-api/policies/policy?api-version=2024-05-01" \ --body '{ "properties": { "format": "xml", "value": "<policies><inbound><base /><teleport to=\"mars\" /></inbound><backend><base /></backend><outbound><base /></outbound></policies>" } }'ERROR: BAD REQUEST({"error": {"code": "ValidationError", "message": "<teleport> is not a known policy.", "details": [], "additionalInfo": []}})Call the API through the gateway
Section titled “Call the API through the gateway”The instance answers on its own gateway hostname. With LocalStack’s DNS interception running, the gatewayUrl above works as written; without it, use the LocalStack-local alias:
curl -s "http://apimdoc86.apim.azure.localhost.localstack.cloud:4566/orders/get"An imported API requires a subscription, so a keyless call is refused the way Azure refuses it, including the WWW-Authenticate challenge:
{"statusCode": 401, "message": "Access denied due to missing subscription key. Make sure to include subscription key when making requests to an API."}Take a key from the built-in all-access subscription:
KEY=$(az rest --method post \ --url "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-apim-demo/providers/Microsoft.ApiManagement/service/apimdoc86/subscriptions/master/listSecrets?api-version=2024-05-01" \ --query primaryKey --output tsv)
curl -s -H "Ocp-Apim-Subscription-Key: $KEY" \ "http://apimdoc86.apim.azure.localhost.localstack.cloud:4566/orders/get"The request is matched to the getItems operation, the policy adds its header, and the call is forwarded to the API’s backend:
{ "args": {}, "headers": { "Host": "httpbin.org", "Ocp-Apim-Subscription-Key": "b774c0f6d4a0b5a207f4f87f2d91318b", "X-Added-By": "Orders", ... }, "url": "https://httpbin.org/get"}X-Added-By carries Orders rather than orders-api because @(context.Api.Name) returns the API’s display name, which the import took from the document’s info.title; orders-api is the id, which @(context.Api.Id) returns.
The key may also be passed as the subscription-key query parameter:
curl -s "http://apimdoc86.apim.azure.localhost.localstack.cloud:4566/orders/get?subscription-key=$KEY"A request that matches no operation gets Azure’s own body, whatever key it carries:
curl -s -H "Ocp-Apim-Subscription-Key: $KEY" \ "http://apimdoc86.apim.azure.localhost.localstack.cloud:4566/orders/nothing-here"{"statusCode": 404, "message": "Resource not found"}Delete and purge
Section titled “Delete and purge”Deleting an instance soft-deletes it. Purging is what frees the globally unique name:
az apim delete --resource-group rg-apim-demo --name apimdoc86 --yesaz apim deletedservice purge --location westeurope --service-name apimdoc86Features
Section titled “Features”Control plane
Section titled “Control plane”- The management surface of the 2024-05-01 API: the service itself, APIs, operations, schemas, revisions, releases and version sets, products, subscriptions, users and groups, named values, backends, certificates, loggers, diagnostics, notifications, portal settings and tenant access, each with its workspace mirror, plus the standalone gateway resource.
- Service lifecycle: create, read, update, delete, soft delete, restore, purge, and name availability across the global name space.
- APIs: create, import (OpenAPI 3, Swagger 2, WADL and WSDL), export, revisions, releases, version sets and schemas.
- Operations, products, subscriptions, users and groups, including the built-in content a new instance ships with.
- Named values, with secret values reachable only through
listValue, and Key Vault references. - Backends, single and pooled, with credentials, TLS settings and circuit-breaker rules.
- Self-hosted gateways: registration, keys, tokens, attached APIs and hostname configurations.
- Security: certificates parsed from the uploaded PKCS#12 bundle, identity providers, authorization servers and OpenID Connect providers, each hiding its secret behind
listSecrets. - Observability: loggers (whose credential is replaced by a named-value reference, as Azure does), diagnostics, notifications, email templates and the report views.
- Portal settings and tenant access, which is what Terraform reads on every plan.
- ETags and
If-Matchon every entity that has them. - Long-running operations answer the status codes and poll headers their operation declares.
Gateway
Section titled “Gateway”- Request matching: API path, revision, version and URL template, including template parameters.
- Subscription keys: Microsoft’s documented authorisation algorithm, including open products, the all-access subscription and per-API key parameter names.
- Policies are parsed, validated at write time, inherited across scopes through
<base />, and executed:set-header,set-query-parameter,rewrite-uri,set-backend-service,set-body,return-response,mock-response,choose,retry,include-fragment,cors,ip-filter,check-header,validate-jwt,rate-limit,quota,cache-lookup/cache-store,forward-requestand more. - CORS preflight: an
OPTIONSrequest carrying anOriginis answered by the gateway itself, evaluating only thecorspolicy, as Azure does. - Policy expressions: the
@(...)and@{...}forms over the documentedcontextmembers. - Rate limits and quotas answer 429 and 403 with Azure’s own messages and
Retry-After. - Response caching, keyed by the
vary-bydimensions thecache-lookuppolicy declares. A response to a request carrying anAuthorizationheader is not cached unlessallow-private-response-cachingis set, matching Azure’s default. - Circuit breakers count only the responses a rule’s
statusCodeRangescover, so a breaker configured for 429 opens on 429. - Named-value substitution (
{{name}}) inside policy documents. - The health probe at
/status-0123456789abcdef, on every tier that has one.
Limitations
Section titled “Limitations”-
No developer portal. The portal’s settings and content are stored and served, but the portal itself, its sign-in flows and its OAuth consent screens are not emulated. The developer portal, direct management, SCM and self-hosted-gateway-configuration hostnames are all claimed and answer a 501 that names the endpoint, rather than a generic 404.
-
No health probe on the Consumption tier.
/status-0123456789abcdefanswers 404 there and 200 on the other tiers, which is how the real service behaves. -
Backups and API exports are not written to a storage account. A backup is kept in memory and can be restored; an export link is served by the emulator and expires after five minutes, as Azure’s SAS does.
-
OData, gRPC and GraphQL definitions are stored, not parsed into operations. The import succeeds, the API exists, and a warning in the logs says no operations were derived.
-
Outbound calls from a request handler are opt-in. The
-linkimport formats, thesend-requestpolicy, OpenID discovery and Key Vault reads are gated behindAPIM_OUTBOUND_FETCH=1. With the flag off they are refused with a message naming the flag rather than failing quietly. The gateway’s own forwarding to a backend is not affected. -
Backend waits are capped at 60 seconds. Azure’s
forward-requesttimeoutdefaults to 300 seconds; the emulator capsforward-requestandsend-requestat 60 so a slow backend cannot pin a worker. Raise the cap withAPIM_FORWARD_TIMEOUT_CAP_SECONDS. -
A backend that points at the gateway itself is refused. Such a request re-enters the gateway from inside the worker serving it and deadlocks, so it answers
BackendConnectionFailure, which is also what Azure answers for a backend it cannot reach. SetAPIM_ALLOW_SELF_FORWARD=1to allow it. -
Reports are computed from the requests the emulator’s own gateway served, so they describe local traffic only.
-
SKU differences are modelled, not enforced end to end. Tier-dependent endpoints, capacity ranges, regional availability and per-tier features are reproduced; performance and scale are not.
-
Certificates backed by Key Vault report a synthesised refresh status, because the emulator does not read a vault from a control-plane handler.
-
Circuit-breaker
percentageanderrorReasonsare not evaluated. A rule using either counts every in-range response, so it opens sooner than Azure’s would. -
az apim api exportfails to find the link. The emulator returns the body the REST specification documents ({"id", "format", "value": {"link"}}), which the Python SDK maps correctly. The CLI command looks for the link underadditional_properties.properties.value.link, a place that body never populates, so it exits withFailed to export API: link not found in responseand writes no file. Read the link from the SDK, or ask for it directly and follow it:Terminal window LINK=$(az rest --method get \--url "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-apim-demo/providers/Microsoft.ApiManagement/service/apimdoc86/apis/orders-api?api-version=2024-05-01&format=openapi%2Bjson-link&export=true" \--query "properties.value.link" --output tsv)curl -s "$LINK"
Samples
Section titled “Samples”- API Management and Function App: an Azure Function published through an API Management gateway, with subscription keys, a product, a secret named value and CORS, rate-limit and header policies, deployable with the Azure CLI, Terraform and Bicep.
Explore more end-to-end examples in the LocalStack for Azure Samples repository.
API Coverage
Section titled “API Coverage”The table below is regenerated from the operations a released emulator image reports, so it lags a service that was re-implemented recently.
663 of 679 operations implemented
| Operation ▲ | Implemented ▼ |
|---|