Appearance
Wrapping an API
An Airnode is configured with the OpenAPI description of the API it wraps, in version 3.1 or 3.2. You almost certainly have one already, tooling already understands it, and there is nothing Airnode-specific to learn.
The one assumption is that the API is HTTP based. Requests go to the document's servers[0].url, and every GET and POST operation in the document becomes an operation the Airnode serves, keyed by its operationId. $ref is resolved, so a document that leans on components works as-is, including a schema that refers back to itself the way an entity holding another entity does.
Why a document and not a URL
An Airnode could take an upstream URL and forward whatever arrived at it. Asking for the description instead costs a deployer a file the provider already publishes, and it is where most of what an Airnode is for comes from.
An agent chooses a call by reading, and what it reads is the document the Airnode publishes about itself, derived from the one it was configured with. A forwarder has nothing to publish. It can say that a request may be made, not which requests exist or what they take, and a listing on AirnodeHub, a price quoted before anything is spent, and a plain English intent resolved into a concrete call all rest on the operations being enumerable.
What is declared is also the limit of what the Airnode will do, and everything it does it signs. An Airnode that forwarded anything would sign anything, including an upstream error returned as an ordinary body, which is attested like any other response. Declaring the API keeps the key behind requests whoever deployed it chose rather than behind whatever a caller can compose.
An operation is also the unit the rest of the system keys on. A request hash covers an operation name and its named parameters rather than a URL, so two callers phrasing the same request agree on what it was, and a price is per operation because there is an operation to attach one to. Being a contract, the document is also what the upstream can be held to: an API that goes down or quietly changes shape can be caught against it rather than by a consumer.
The document is the contract with the caller, and the upstream is only how that contract is met. An Airnode today forwards one operation to one endpoint, but nothing about the split requires it to. The same split is what already lets a deployment hold the upstream credential so a caller needs none of their own, and it is where an API that describes itself some other way, or processing around a call rather than a bare forward, would go.
Operations and their inputs
An operation's inputs are its parameters and, for a JSON requestBody, that body's properties. Both arrive as one flat set of named values, each keeping the type your document declared for it. An Airnode reads a parameter that declares no type as a string. But OpenAPI requires every parameter to carry a schema or a content, so write the schema out. A document that other tooling cannot read is not a contract.
Outside a JSON body, that type has to be a single value. A query, header or path parameter is written into a URL or a header as one string, and OpenAPI has several ways to spell a list out there, through style and explode. Rather than guess one, an Airnode refuses a document that declares an array or an object in those positions, and says so when it loads rather than on a call. A list belongs in the request body, where it is sent as the JSON you declared.
Only the outermost type of a value is checked. An array has to be an array, which catches a caller who sent the wrong kind of thing, but what is inside one is the upstream's own schema to enforce. Checking deeper would mean maintaining a second copy of an API the Airnode does not own.
Where a parameter takes a closed set, declare it as an enum. The Airnode publishes that set but does not enforce it. It tells a caller reading the document, and an agent resolving an intent, which values exist. The upstream still rules on the value that arrives, so a value outside the set reaches the upstream and fails there.
Declare a set only where the provider's own reference gives it. An enum that is wrong misleads every caller who reads it. Leave the values out of the description, which the schema already carries.
A set of one is a const. The type on a GeoJSON feature, the jsonrpc on a JSON-RPC envelope, a role that is assistant because nothing else answers. On a response field the check enforces it like the rest of the schema, so it is the cheapest drift signal there is. If the upstream changes how it spells that value, the run says so. A nullable field cannot take a const, because a const refuses the null the field really carries. An enum that lists the value and null says that instead.
A quantity gets the same treatment: declare it as an integer or a number with its minimum and maximum, and a value a caller can leave out as a default. None of it is enforced either, and all of it is published, so a page size capped at a thousand or a score running from zero to one reaches an agent as a bound rather than as a sentence it has to read. An identifier is not a quantity. Codes such as an IMDb id or a barcode keep their leading zeros and their letters only as strings, and a numeric id says nothing a caller can compare or count with, so declare those as strings whatever they look like.
A default is a promise about one parameter left out, so declare one only where leaving it out gives that value whatever else the caller sent. A default that moves when a sibling arrives is not one, and publishing it as though it were tells an agent the cheap call is safe when it is not. Say the condition in the description and leave the field off.
A date or a timestamp gets a format, and that is published the same way. date reaches an agent as as YYYY-MM-DD beside the parameter's name, so the spelling belongs in the schema rather than in a sentence repeating it. date-time means RFC 3339, so a zone of Z or of +02:00 both satisfy it.
Declare only a format the values really hold. Unlike the rest of a schema, a format on a response field is checked: the integration checks build their schemas from the document, so a field found to carry 2026-08-24 14:35:47 is neither a date nor a date-time, and calling it one turns a passing check into a failing one. Leaving it off is the honest answer where the values are their own shape.
A value that none of that pins down gets examples: a vendor's own id, a slug, an exchange code, a market name. It reaches an agent as for example: "US", "LSE", "XETRA" beside the parameter. That is a value to copy rather than a sentence to read. A sample belongs in the schema for the same reason a set or a bound does. One or two are enough. Put them in the schema, not in the description that used to carry them.
Give a sample only where it is a value the parameter really takes. A gloss is not one. "Such as a topic the provider classifies articles under" describes what to send but cannot be sent, so it stays as prose.
Where a parameter takes a comma separated list, give the members as separate samples rather than one joined string. Each member is a value on its own, and the description already says the comma joins them. A model reads both and joins them when the intent asks for more than one.
Where the right value depends on another parameter, name the sample beside the value it goes with. AeroDataBox picks a flight by searchBy, so searchParam reads KL1395 for number, PH-BXO for reg. Samples alone would leave a caller to guess the pairing, and a wrong pairing is a call the upstream answers with the wrong flight.
Pick a sample whose own digits rule out a reading it does not mean. A date of 2026-08-24 says which half is the month. A date of 2026-08-12 reads the same either way and settles nothing. A time of 14:35:47 does as much for the clock. The hour is above twelve, so the clock is a 24 hour one. The minute and the second are above 23, so neither can be read as the hour. A time of 00:00:00 says none of that.
Leave a real value alone. A boundary the API enforces, a date that belongs to the entity you match, and the epoch are facts rather than samples. Rewriting one to look tidy makes the document wrong.
Mark a parameter or a field deprecated where the provider retired it and still answers with it. Nothing acts on the flag and the Airnode publishes it as it stands, which is the point. It says the upstream moved on, and the Airnode still serves what a caller reads.
What the Airnode sends of its own
Two headers go out with every upstream request that no caller asked for. Accept: application/json says what would be understood if it arrived, because an API that negotiates its content type answers XML to the */* Node sends by default, and an Airnode carries JSON. A User-Agent naming Airnode, its version, where to read about it and where to write to us says who is calling, because a provider whose data access policy asks to be told has nothing to read otherwise, and more than one of them rate limits on the answer. Some ask for the address specifically, and answer a caller that supplies one with a higher limit than one that does not.
Both are defaults rather than fixtures. An upstream that wants a vendor media type instead, or wants to be addressed some other way, says so as a header parameter in the document, which is applied over them.
Narrowing by declaring less
An Airnode does not have to serve all of an API. It narrows by declaring less of it: anything the document does not mention is refused before the upstream is called. So the document is both what the Airnode forwards to and the limit of what it will forward, and there is nothing to write to exclude the rest.
Keys the upstream wants
Most APIs worth wrapping want a key, and the document already has a way to say so: components.securitySchemes names the credential and security says it applies. Bearer tokens and API keys in a header or the query string are understood. What the document never holds is the value.
Each scheme becomes an environment variable instead, so the document stays committable and the credential stays with whoever deployed it. That is the same line the signing mnemonic already sits on. The document is the integration and decides which API is wrapped. The environment is the deployment and decides who is running it. Neither has to know the other to be written, and a deployment that forgot a key is refused at boot rather than failing every call.
The credential is attached to the upstream request after everything the caller sent, so a caller cannot shadow it. It is not a parameter, which means it is absent from the request hash and from the document the Airnode publishes.
This is also where a private upstream URL stops being a convention and starts being enforcement. An Airnode over a public endpoint keeps its URL to itself, but a determined caller could find it. An Airnode holding a key is one a caller cannot go around, because the key is the thing they do not have.
Constants the upstream wants
An API is sometimes reachable only with a header that is neither yours to keep secret nor the caller's to choose. A gateway fronting many APIs is the usual case, where the request has to name which of them it is for. Say so under x-airnode-headers at the top level of the document, and every upstream request carries it.
It sits alongside servers because it is the same kind of fact, how to reach the upstream rather than what to ask it, and unlike a key it stays in the document, since publishing it gives nothing away. A caller never sees it and never sends it, so it is absent from the request hash and from the document the Airnode publishes, exactly as a credential is.
Some APIs route on a query parameter rather than on the path, so every product they sell answers on one URL and the parameter says which one you want. Put it in the query string of the path key, and you get an operation per product:
json
"paths": {
"/xml/v2?product=current_conditions": { "get": { "operationId": "currentConditions" } },
"/xml/v2?product=tide_forecast": { "get": { "operationId": "tideForecast" } }
}OpenAPI keys operations by path, so without this the whole API is a single operation and your callers choose a product by sending the discriminator themselves. That is the opposite of narrowing by declaring less, and it leaves an agent reading your document nothing to tell one product from another. These pairs are applied after everything the caller sent, like a header constant, so no caller can move an operation onto a product you did not offer.
Do not also declare that parameter under parameters. We refuse a document that does, because a caller could then send a value we would accept and put in the request hash and then not send, leaving an attestation that names a request your API never answered. A {placeholder} in the query string is refused too, since only the path half is substituted, and so is a name the key sets twice.
A query string on a path key is our own extension, so a strict OpenAPI validator objects to the key even though we accept it.
Other APIs route on a field in the request body instead, so every product answers on one path and one method, and the body says which one. Hyperliquid's read API is the case: POST /info with {"type": "l2Book"}. Name the field and its value under x-airnode-body on the operation, and every request that operation makes carries it:
json
"paths": {
"/info#l2Book": {
"post": {
"operationId": "l2Book",
"x-airnode-body": { "type": "l2Book" },
"requestBody": { "content": { "application/json": { "schema": {
"type": "object",
"required": ["coin"],
"properties": { "coin": { "type": "string", "examples": ["BTC"] } }
} } } }
}
}
}It sits on the operation rather than at the top of the document because it is what tells one operation from another. That is the same reason a query constant sits on the path key. The value is any JSON, unlike a header constant, since that is what a body carries. An operation routed by one takes a body even where it declares no parameter in it.
Everything a query constant does, this does too. We apply it after everything the caller sent, keep it out of the request hash and out of the published document, and refuse a body property of the same name. We refuse a GET as well, since it has no body to put one in.
The key still has to be unique, because OpenAPI keys operations by path and here every operation shares one. That is what the #l2Book is for. A fragment addresses nothing on the server and is never sent, here as anywhere else, so it tells two keys apart and reaches nobody. Reach for it wherever a path and a method together cannot separate two operations. A query constant is the wrong tool for that, since the upstream receives it.