Skip to content
Get Started for Free

API Management

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.

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:

Terminal window
lstk az start-interception

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

Terminal window
lstk az stop-interception

This reconfigures the az CLI to send commands to the official Azure management REST API.

Create a resource group for your API Management resources:

Terminal window
az group create \
--name rg-apim-demo \
--location westeurope
Output
{
"id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-apim-demo",
"location": "westeurope",
"name": "rg-apim-demo",
"properties": {
"provisioningState": "Succeeded"
},
...
}

An API Management name is globally unique, so check it before creating:

Terminal window
az apim check-name --name apimdoc86
Output
{
"message": "",
"nameAvailable": true,
"reason": "Valid"
}
Terminal window
az apim create \
--name apimdoc86 \
--resource-group rg-apim-demo \
--location westeurope \
--sku-name Consumption \
--publisher-name "LocalStack" \
--publisher-email "dev@localstack.cloud"
Output
{
"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.

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

The operations the document described are now addressable. The operation takes its name from operationId, case included:

Terminal window
az apim api operation list \
--resource-group rg-apim-demo \
--service-name apimdoc86 \
--api-id orders-api \
--output table
Output
Description DisplayName Method Name ResourceGroup UrlTemplate
------------- ------------- -------- -------- --------------- -------------
Get items Get items GET getItems rg-apim-demo /get

Policies are validated when they are saved, so a mistake is reported to whoever wrote it rather than at the first request:

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

Output
{
"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:

Terminal window
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>"
}
}'
Output
ERROR: BAD REQUEST({"error": {"code": "ValidationError", "message": "<teleport> is not a known policy.", "details": [], "additionalInfo": []}})

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:

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

Output
{"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:

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

Output
{
"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:

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

Terminal window
curl -s -H "Ocp-Apim-Subscription-Key: $KEY" \
"http://apimdoc86.apim.azure.localhost.localstack.cloud:4566/orders/nothing-here"
Output
{"statusCode": 404, "message": "Resource not found"}

Deleting an instance soft-deletes it. Purging is what frees the globally unique name:

Terminal window
az apim delete --resource-group rg-apim-demo --name apimdoc86 --yes
az apim deletedservice purge --location westeurope --service-name apimdoc86
  • 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-Match on every entity that has them.
  • Long-running operations answer the status codes and poll headers their operation declares.
  • 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-request and more.
  • CORS preflight: an OPTIONS request carrying an Origin is answered by the gateway itself, evaluating only the cors policy, as Azure does.
  • Policy expressions: the @(...) and @{...} forms over the documented context members.
  • Rate limits and quotas answer 429 and 403 with Azure’s own messages and Retry-After.
  • Response caching, keyed by the vary-by dimensions the cache-lookup policy declares. A response to a request carrying an Authorization header is not cached unless allow-private-response-caching is set, matching Azure’s default.
  • Circuit breakers count only the responses a rule’s statusCodeRanges cover, 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.
  • 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-0123456789abcdef answers 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 -link import formats, the send-request policy, OpenID discovery and Key Vault reads are gated behind APIM_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-request timeout defaults to 300 seconds; the emulator caps forward-request and send-request at 60 so a slow backend cannot pin a worker. Raise the cap with APIM_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. Set APIM_ALLOW_SELF_FORWARD=1 to 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 percentage and errorReasons are not evaluated. A rule using either counts every in-range response, so it opens sooner than Azure’s would.

  • az apim api export fails 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 under additional_properties.properties.value.link, a place that body never populates, so it exits with Failed to export API: link not found in response and 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"
  • 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.

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 ▼
Page 1 of 0
Was this page helpful?