OpenAPI Schema Import in APIM v2: Who Fetches the Schema, and Why Your Internal URL Will Fail
A pipeline kept failing on an APIM API import. The error was a bare 400 ValidationError with almost no useful payload. The Terraform looked fine. The OpenAPI schema URL resolved from my laptop. The only clue was that this had never failed on the classic APIM it was being migrated from.
I spent longer on this than I should have. The fix, when I found it, was one line in a tfvars file. The reason it took so long is that the mental model I brought from classic APIM was wrong, and nothing in the error message hinted at what the real problem was.
The setup
Standard use case. A Terraform module wraps Microsoft.ApiManagement/service/apis and takes these inputs per API:
apim_api_configs = {
TransportDatasets = {
uri_path = "transport/datasets"
display_name = "Transport Datasets"
revision = "1"
api_schema_url = "https://tic.contoso.com/api/openapi.json"
content_format = "openapi+json-link"
}
}
The api_schema_url points at an internal backend that hosts its OpenAPI spec at a well-known path. content_format = "openapi+json-link" tells APIM the schema lives at a URL it should fetch. This exact pattern had been running against classic APIM for years.
The same config against StandardV2 returned:
ERROR: (ValidationError) One or more fields contain incorrect values
The pipeline output was effectively one line plus a correlation ID. It did not name the failing field, reference a URL, or carry a stack trace.
What I tried that was wrong
The first round of debugging was all on the APIM side. Was the API name already taken? No, it was a greenfield instance. Was the revision number valid? I tried "1", "2", and "3" (I started with "2" because it had been the working value on classic). Only "1" worked, which was misleading on its own. Greenfield APIs must start at revision 1; the revision field does not accept arbitrary integers. That lesson is real, but it was a red herring for the actual failure.
Once revision was fixed, the same 400 came back. Different day, same one-line error.
I assumed this was an OpenAPI spec validation problem. I fetched the schema from my laptop, it parsed cleanly with openapi-spec-validator, no complaints. I ran it through Swagger UI, rendered fine. I diff’d it against a known-good schema from another API; nothing structurally different.
I also briefly chased a BOM problem. The OpenAPI file had been pulled from SharePoint and had a ZWSP (e2 80 8b) prefix, three invisible bytes before the opening {. That was a real bug on our side and tail -c +4 swagger.json > swagger.clean.json fixed it for local parsing. But that was not the APIM import failure either.
The realisation
The thing that finally unlocked it was a detail in the APIM REST API documentation I had read a hundred times and never processed. When you create an API with contentFormat: "openapi+json-link", you are telling APIM: “here is a URL; go fetch the spec from it.” The question I had never asked is: who goes and fetches it.
The answer is the Azure Resource Manager control plane. Not the APIM gateway. Not anything inside my VNet. The az apim api import call (or the Terraform resource, or the ARM template, or any other way of creating the API) runs against management.azure.com. When that operation needs to resolve api_schema_url, the fetch happens from Microsoft’s control plane, which lives on the public internet and has no route to my internal DNS or my private backends.
https://tic.contoso.com/api/openapi.json resolves fine from my laptop. It does not resolve at all from Azure’s management plane. The hostname is on internal DNS only. Azure’s control plane sees an unresolvable hostname and bounces back a 400 ValidationError because, from its perspective, the URL is invalid.
Strictly speaking, this is not a classic-vs-v2 distinction. The control plane did the same fetch on classic too. The difference is that on classic, internal-mode APIM instances sometimes had exposure patterns or hybrid connectivity that made internal URLs reachable from the control plane in ways that masked this. On StandardV2 with a clean VNet integration setup, the control plane has no route at all, and the failure becomes unavoidable.
The fix: inline, not by reference
Two options solve this.
Expose the schema at a public URL. If the backend API documentation is not sensitive (and many of them aren’t, they are just OpenAPI definitions), host the spec behind a public hostname. Azure’s control plane resolves it, fetches it, imports it, done. This is the cleanest option when organizational policy allows it.
Inline the schema instead of referencing a URL. Change content_format from "openapi+json-link" to "openapi+json" and provide the schema contents as a local file the Terraform module can read at plan time. The module embeds the JSON as the value property of the API resource, and the control plane never has to fetch anything.
We went with the second option:
apim_api_configs = {
TransportDatasets = {
uri_path = "transport/datasets"
display_name = "Transport Datasets"
revision = "1"
api_schema_url = "swagger/Transport_Datasets.json" # local file path
content_format = "openapi+json" # inline, not link
}
}
The module internally reads the file and passes the contents to the API resource body. The api_schema_url field name is misleading here (with openapi+json format it becomes a file path rather than a URL), but that is a module naming issue, not an APIM one.
Two related gotchas that were not the main bug
While debugging this I hit two other things that are worth knowing because they can disguise themselves as the same 400.
Revision 2 on a greenfield API
Our classic APIM had been on revision 2 for a while, and the Terraform config had been carrying revision = "2" forward. On a fresh v2 instance with no existing API of that name, revision 2 does not exist to create; the first revision of a new API has to be 1. The error message is the same unhelpful 400 ValidationError. Fix: always set revision = "1" on first apply, increment only when cloning an existing revision.
SharePoint BOMs
If your OpenAPI spec was exported via SharePoint Online or passed through any Microsoft document tool, check for a UTF-8 BOM (ef bb bf) or worse, a zero-width space (e2 80 8b). The first few bytes of swagger.json should be 7b 0a ({\n) or similar; if you see anything else before the {, strip it with tail -c +4 swagger.json > swagger.clean.json (or the appropriate byte count for the prefix you have). APIM’s JSON parser will not tell you “your file starts with a ZWSP”. It will tell you “invalid JSON” or, in some paths, ValidationError. Confirm with xxd swagger.json | head -1 before blaming the schema.
The control plane / data plane mental model
The broader lesson here is worth internalising once so you never debug this again.
Azure operations split cleanly into two planes. The control plane is management.azure.com. It runs terraform apply, az commands, ARM template deployments, portal-driven resource changes. It lives on public Microsoft infrastructure. When a control plane operation needs to fetch something by URL (an OpenAPI spec, a certificate from Key Vault via HTTPS, a linked ARM template), the fetch runs from the control plane, using public internet DNS and routing.
The data plane is the runtime of the service you provisioned. For APIM, the data plane is the gateway itself, which resolves hostnames through whatever DNS your APIM instance is configured to use: typically Azure-provided DNS augmented by any private DNS zones linked to your VNet if you have VNet integration.
Anything with “URL” in its config field should prompt the question: which plane does the fetch? If the answer is control plane, the URL must be publicly resolvable. If the answer is data plane, private DNS is fine. APIM’s api_schema_url is control plane. The gateway’s runtime calls to backend services are data plane. Get this wrong and you will debug the right symptom in the wrong place.
The same confusion appears elsewhere: Key Vault references in ARM templates (control plane), Application Gateway backend health probes (data plane, so private DNS works), Azure Function deployment URLs (control plane), container registry image pulls during deployment (depends on the service, and this one catches people too).
Terraform shape that works
For the benefit of anyone searching for a concrete working example:
variable "apim_api_configs" {
type = map(object({
uri_path = string
display_name = string
revision = string
api_schema_url = string # local path when content_format == openapi+json
content_format = string # "openapi+json" for inline, "openapi+json-link" for URL
}))
}
resource "azurerm_api_management_api" "this" {
for_each = var.apim_api_configs
name = each.key
resource_group_name = var.resource_group_name
api_management_name = var.apim_name
revision = each.value.revision
display_name = each.value.display_name
path = each.value.uri_path
protocols = ["https"]
import {
content_format = each.value.content_format
content_value = each.value.content_format == "openapi+json" ? (
file("${path.module}/../${each.value.api_schema_url}")
) : each.value.api_schema_url
}
}
The ternary on content_value handles both modes. If the format is inline, read the file; if the format is link, pass the URL through as-is. That lets a single module handle both patterns, which is useful when some of your APIs have public OpenAPI specs and others are internal-only.
The five-minute version
If you landed here from a search: APIM v2 is rejecting your schema URL because Microsoft’s management plane, not your APIM instance, is the one that has to fetch it, and management plane cannot resolve your internal DNS. Switch content_format to openapi+json and embed the schema contents as a local file instead of a URL reference. The 400 ValidationError will go away and you can stop looking at your spec for imaginary bugs.
Azure docs: APIM API import REST reference · Control plane vs data plane · Terraform azurerm_api_management_api