Appearance
The HTTP interface
An Airnode is three endpoints, and it describes them itself: GET / for the document, POST / for an operation named in a body, and /api/… for a request forwarded as the provider documents it. This is the pull model: a consumer asks and the Airnode answers, and a polling module that pushes on a timer does not exist yet.
Every response allows any origin (Access-Control-Allow-Origin: *). An Airnode holds no cookies or caller identity a cross-origin read could leak, and a browser running an agent is a consumer like any other.
GET / describes the Airnode
GET / returns an OpenAPI 3.1 document for the Airnode itself, so an agent that already speaks OpenAPI needs nothing bespoke to call it. Discovery is free and machine-readable. The document is titled after the API it wraps, lists the Airnode's own URL under servers, covers GET / and POST / as paths, and describes the passthrough under x-airnode.passthrough.
Every operation the Airnode serves appears as one accepted shape of the POST / request body, as a oneOf of variants. A variant pins the operation field to one operationId with a const and declares that operation's parameters as a strict object: the required ones are listed and unknown properties are refused. Each parameter is published exactly as the upstream's document declared it, nesting and all, so a caller can tell which parameters go with which operation without a separate lookup. A parameter that takes a closed set publishes it as an enum. A caller then reads the values from the schema rather than from a remark in the description.
A variant's own description is the operation's summary, or its description when it has no summary, and it is what a caller chooses by. An operation that has both publishes its description on the operation field: how to make the call and how to read the answer, for the caller who is about to make it.
Two extensions sit on the operation field of each variant, since OpenAPI has no field for either. x-airnode-price is what a call costs, present only when the deployment charges for that operation. It repeats per operation what x-airnode.payment below says for the whole document, so a caller reading one variant needs nothing else. x-airnode-returns names the top-level fields of the response and their types, and that is all of the response shape the document carries. The fields of every oneOf and anyOf alternative are merged into one map, so a listed field need not appear in every response, and a field that can take several types lists them separated by |, null among them. The Airnode does not publish the full schema its integration checks hold the upstream to, so the shape a projection needs comes from a first call made whole.
The shape every response takes is published under components.schemas.Attestation, and everything a verifier needs sits under the x-airnode extension:
json
"x-airnode": {
"address": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
"version": "0.1.0",
"attestation": {
"scheme": "eip191-secp256k1",
"digest": "keccak256(abi.encodePacked(bytes32 requestHash, uint256 timestamp, bytes dataBytes))",
"dataBytes": "UTF-8 of data",
"requestHash": "keccak256(utf8(JSON.stringify([operation, parameterEntriesSortedByName]))), where an object value is itself entries sorted by name, at any depth, and an array keeps its order. Names are compared by Unicode code point",
"verify": "Recover the signer of the signature over the digest and check it equals the address above."
}
}version is the Airnode build serving the document, which is not the same thing as info.version above: that one is the wrapped API's own. It is there so a consumer that read the document once and has been calling against it since can tell that what it is calling changed.
What the document leaves out matters as much as what it contains: the upstream host and the credential never leave the process, since publishing them would let a consumer skip the Airnode and call the provider's API directly. The paths and where each parameter goes are published under x-airnode.passthrough, since the passthrough is reached through the Airnode and serves only the routes the document declares.
POST / performs an operation
The body names an operation and its parameters:
json
{ "operation": "coinPrice", "parameters": { "ids": "ethereum", "vs_currencies": "usd" } }The response is the upstream data plus the attestation over it. data is the upstream body exactly as received, as one string, since that is what the signature covers:
json
{
"airnode": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
"requestHash": "0x…",
"timestamp": "1752580000",
"data": "{\"ethereum\":{\"usd\":1920.51}}",
"signature": "0x…"
}An unknown operation is a 404, a missing, unknown or mistyped parameter is a 400, and an upstream failure is a 502. An upstream response too large to hold and sign is a 502 as well, since the whole body is signed rather than streamed; the limit is 50 MB by default. Unknown parameters are rejected rather than dropped, so a caller mistyping a name hears about it instead of silently receiving an attestation over a request they did not mean to make. A value outside a declared enum is not one of these. The Airnode publishes the set but does not enforce it, so such a value reaches the upstream, which answers it.
A path parameter that is empty, . or .., or has such a part between slashes as ../x does, is a 400 too, before the upstream is called. Filled into the path, it would take the request to a route the document does not declare, as .. in /coins/{id}/history would reach /history. A slash inside a value is sent as %2F, but some upstreams decode it before they route, so ETH/USD is sent and ../x is not.
When the upstream answered with an error status, the 502 carries that status as upstreamStatus beside error.
A parameter declared as a string also takes an integer when it goes in the path, the query or a header, and the request hash covers it as you sent it.
Ask for only the fields you need
A response is often far larger than the part you want, and an agent pays for every token of it. responseProjection names the fields to keep:
json
{
"operation": "coinPrice",
"parameters": { "ids": "ethereum", "vs_currencies": "usd" },
"responseProjection": { "price": "/ethereum/usd" }
}Each value is an RFC 6901 JSON Pointer into the upstream response, and each key is a name you choose. data is then the text of a JSON object of those names instead of the whole body, in Unicode code point order whatever order you wrote them in, with each value copied as the upstream wrote it:
json
"data": "{\"price\":1920.51}"The Airnode still fetches the whole response, so a projection saves context and not upstream latency. The projection is part of the signed request: requestHash covers it, so the attestation states which fields the caller asked for and what came back, and a projected response cannot pass for an unprojected one. Trimming the response yourself after the call leaves you holding data the signature no longer covers.
A pointer the response does not have is a 400 rather than a null, so a typo is never signed. A pointer that is not a JSON Pointer at all is a 400 before the upstream is called, so it costs nothing.
A projection names at most 20 fields, each with a pointer of its own and none inside another's value, which would copy that value twice: name the outer value or the fields inside it, not both. The empty pointer, which names the whole response, is refused, since the whole response is what a request without a projection gets. Any of these is a 400 before the upstream is called.
/api/… forwards a request
Some callers cannot speak the envelope. A JSON-RPC client wants a URL it can post to as it is, a library built against the provider's own reference wants the provider's paths and parameters, and a response that arrives inside data is one those tools cannot read. The passthrough is the same Airnode reached the way the provider documents it: send the request, method, path, query and body as they are, to the Airnode's URL followed by /api and the provider's path.
The routes are the operations. x-airnode.passthrough.routes in the document names, per operation, the method, the path and where each parameter goes, so the same document that describes an operation for the envelope says how to reach it here. A route the document does not declare is a 404, whatever the upstream would have said to it, which is what keeps the passthrough serving less than the API it wraps rather than everything the key can reach. So is a templated segment that decodes to an empty, . or .. part between slashes, such as ..%2Fx, since an upstream that decodes %2F would route it elsewhere. An operation a constant selects, a query product or a body field, is reached by sending that constant. A body that also spells the field's name another way, Method beside method, reaches no route, since an upstream that reads keys regardless of case would act on that one instead. A request that fits more than one route reaches the most specific: a literal path segment over a templated one, then the route with more constants, then the operation whose required query parameters it carries. Of what a caller sends, only Content-Type, Accept and the headers the document declares as parameters, on any of its operations, reach the upstream, and the credential is added last, as it is for the envelope. The declared headers are part of the request the signature is bound to, since they select what the upstream answers. The passthrough form says how.
What comes back is the upstream's answer as it is: its status, its content type and its body untouched, so a client built for the provider reads it unchanged. A signed body only loses a leading byte order mark, which a UTF-8 decoder drops anyway. An upstream refusal crosses the same way, a 429 as a 429, so a caller tells a rate limit from an outage without parsing prose. On success the attestation travels in headers instead of wrapping the body:
| Header | Carries |
|---|---|
X-Airnode-Address | The address whose signature attests to the response. |
X-Airnode-Request-Hash | The request it answers, as the passthrough form derives it. |
X-Airnode-Timestamp | Unix seconds at which it was signed. |
X-Airnode-Signature | The attestation itself, over the body exactly as received. |
X-Airnode-Operation | The operation the route reached, which is what the price is keyed on. |
Every query parameter starting with x-airnode- is the Airnode's own. It never reaches the upstream, and one the Airnode does not know is a 400 before anything is sent, so a misspelt one cannot end up signed as part of the request. The parameter an upstream takes its API key in is a 400 too, since the Airnode would sign your value and send its key in its place. Everything else in the query reaches the upstream as sent. Two exist:
x-airnode-sign=falseforwards without signing: no headers, no buffering, and the body streamed through with no size cap, which is the mode for a client that only wants the provider's shape at the Airnode's address.x-airnode-project=alias:pointer, once per field to keep, is the projection in query form. The body becomes an object of those aliases, in code point order and each value as the upstream wrote it, and the signature covers it, so?ids=ethereum&vs_currencies=usd&x-airnode-project=price:/ethereum/usdon the price route above answers{"price":1920.51}. A pointer the response does not have is a400, as it is in the envelope, as is a projection outside those limits, and a projection asked for together withx-airnode-sign=falseis applied and left unsigned.
A priced operation is priced whichever way it is reached. The passthrough answers an unpaid request for one with the same 402 and the same requirements as POST /, described under payments. An upstream's own 402 crosses like any other refusal, without the PAYMENT-REQUIRED header, so that header is what tells a price from a refusal.
Payments
An operation can be priced with x402, which fits the free GET / and paid POST / split exactly: discovery is free, the resource is paid. Pricing is opt-in and lives in the integration's code. An Airnode that names no price for an operation serves it free, exactly as before, so a free Airnode is unaffected by any of this.
A priced operation answers an unpaid POST / with 402. The payment requirements are in the PAYMENT-REQUIRED header, base64-encoded, as x402 v2 puts them. The body is {"error":"Payment required"}. The header decodes to:
json
{
"x402Version": 2,
"error": "Payment required",
"resource": {
"url": "https://airnode-anuqrng.fly.dev/",
"description": "AirnodeHub operation \"randomNumbers\"",
"mimeType": "application/json"
},
"accepts": [
{
"scheme": "exact",
"network": "eip155:84532",
"amount": "5000",
"asset": "0xbeA34973e5f67F18F656A2C6070C64326C791808",
"payTo": "0x…",
"maxTimeoutSeconds": 300,
"extra": {
"name": "AirnodeHub USD",
"version": "1"
}
}
]
}The caller signs an EIP-3009 transferWithAuthorization matching one entry, puts it in an x402 payment payload with that entry as accepted, and retries with the payload, base64-encoded, in the PAYMENT-SIGNATURE header. A header that does not decode to a payment payload is a 400. The Airnode has a facilitator verify the authorization, performs the operation, then has the facilitator settle it on-chain, and returns the attestation with the settlement receipt in the PAYMENT-RESPONSE header. A payment that does not verify or does not settle is a 402 with fresh requirements, and a failed settlement also carries its receipt. The Airnode never touches the chain itself: an x402 facilitator does the verifying and submitting and pays the gas.
accepts has one entry for each network and token that the Airnode takes payment in, as its integration names them. The caller picks the entry it pays with. Prices are in dollars, and every token is a USD stablecoin. Each entry's amount is the price in the smallest unit of that token. The GET / document lists the same networks and tokens under x-airnode.payment.accepts, each with its symbol and decimals.
Verification happens before the upstream is called and settlement only after it succeeds, so a call that fails upstream is never charged, and a caller who is charged always gets the data. Prices are per operation: an unpaid POST / reads the requested operation from the body and returns requirements for that operation's price, so the single shared POST / URL is not a limitation. Every price an Airnode charges is also published under x-airnode.payment in GET /, so an agent learns what a call costs from the same document that tells it how to make one.
The one setup detail that matters: the integration names a facilitator next to the address that gets paid, and the facilitator has to settle on every network in accepts. The x402 docs list facilitators. There is no default, so an Airnode is never left charging callers through a facilitator nobody chose.