Appearance
Attestation
Every successful response is an attestation: the upstream data plus a signature that binds it to the Airnode that served it, the request it answers and the time it was served. A POST / response carries it as its body:
json
{
"airnode": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
"requestHash": "0x…",
"timestamp": "1752580000",
"data": "{\"ethereum\":{\"usd\":1920.51}}",
"signature": "0x…"
}airnode is the address whose signature attests to the response. requestHash identifies the request it answers. timestamp is the unix seconds at which it was signed, carried as a string. data is the upstream response body exactly as received, as one string. A request that carries a projection gets the text of a JSON object keyed by the names it asked for instead, each value as the upstream wrote it.
A response from the passthrough carries the same five fields elsewhere. airnode, requestHash, timestamp and signature are the X-Airnode-Address, X-Airnode-Request-Hash, X-Airnode-Timestamp and X-Airnode-Signature headers, and data is the body exactly as received, as one string.
This mirrors the scheme the API3 signed API already runs in production, generalized from oracle values to arbitrary responses.
The request hash
requestHash binds the attestation to the request it answers, so a response cannot be replayed as the answer to a different one. It is derived from the operation name and its parameters:
requestHash = keccak256(utf8(JSON.stringify([operation, canonical(parameters)])))canonical replaces every object, at any depth, with its entries sorted by key, so the same request written by two serializers reaches the same hash and parameter order never changes a request's identity. Keys are compared by Unicode code point, which is the order of their UTF-8 bytes, so B comes before _id and both before a in any locale and any language, Solidity included. An array keeps its order, which is its own to mean something by. The request from the HTTP interface serializes as:
json
[
"coinPrice",
[
["ids", "ethereum"],
["vs_currencies", "usd"]
]
]A request that carries a projection appends it as a third element, canonicalized the same way, so the hash names the fields the response was cut down to:
json
[
"coinPrice",
[
["ids", "ethereum"],
["vs_currencies", "usd"]
],
[["price", "/ethereum/usd"]]
]A request without a projection keeps the two element form, so every hash derived before projections existed still matches.
The passthrough form
A request through the passthrough names no operation, so its hash is derived from the request as it was sent: the method in upper case, the path after /api with its dot segments resolved, as a URL parser resolves them and as the upstream receives it, the query entries in the order they were sent, the header parameters it sent, and the body as one string, or an empty string when there was none. The query keeps its order because that is the order the upstream receives it in, and an upstream can answer ?id=A&id=B differently from ?id=B&id=A. A leading tag keeps it apart from the envelope form, whose second element is always an array.
requestHash = keccak256(utf8(JSON.stringify(["passthrough", method, path, queryEntries, headerEntries, body])))The header parameters are the headers the document declares as parameters, on any of its operations, such as the x-chain that picks a chain on Birdeye. Each one the request sent reaches the upstream, whichever operation the route serves, and is an entry, with its name in lower case. A header sent more than once is one entry, its values joined with , in the order sent, as HTTP combines them, and the result trimmed of spaces and tabs, as the upstream receives it. A value is read as UTF-8, or as Latin-1 when its bytes are not UTF-8, which gives the value as typed whichever of the two a client sent it in. The entries are sorted by name in code point order, since header names ignore case and their order means nothing. They are covered because they select what an upstream answers, as the query does. Content-Type and Accept also reach the upstream and are left out: they set how the request and the answer are written, which the signed bytes show, and an HTTP client adds them on its own.
A GET of /api/simple/price?vs_currencies=usd&ids=ethereum serializes as:
json
[
"passthrough",
"GET",
"/simple/price",
[
["vs_currencies", "usd"],
["ids", "ethereum"]
],
[],
""
]The x-airnode- parameters are the Airnode's own and are left out of the query before hashing, so a request that carried x-airnode-sign=false and one that did not name the same request. A projection is the exception, since it changes the body that is signed: its entries join the hash as a seventh element, sorted by name, exactly as the envelope form appends them as a third. The same request with x-airnode-project=price:/ethereum/usd serializes as:
json
[
"passthrough",
"GET",
"/simple/price",
[
["vs_currencies", "usd"],
["ids", "ethereum"]
],
[],
"",
[["price", "/ethereum/usd"]]
]The digest and the signature
The signature is an EIP-191 secp256k1 signature over a keccak256 digest of the request hash, the timestamp and the response:
dataBytes = utf8(data)
digest = keccak256(abi.encodePacked(bytes32 requestHash, uint256 timestamp, bytes dataBytes))
signature = sign(keccak256("\x19Ethereum Signed Message:\n32" ‖ digest))timestamp is packed as a uint256. Nothing is parsed and written again on the way, so every number reaches you as the upstream wrote it: 0.00000071234 stays 0.00000071234 rather than becoming 7.1234e-7, and an integer past 2^53 keeps every digit. A verifier in any language hashes the string it received, with no JSON serializer to match. A projection is the one body the Airnode writes itself: an object around the values it names, each copied from the upstream's text exactly as written. The names come in code point order, the order the request hash has them in, so the hash fixes the bytes. Asking for the same names in another order cannot sign the same hash over a reordered object.
A data that is not a string is hashed as JSON.stringify(data), the form an Airnode before version 0.2.0 signed.
The EIP-191 prefix means the digest is signed the way every Ethereum wallet signs a raw 32 byte message, so any tooling that recovers a personal_sign address can verify an attestation.
Verifying a response
Recover the signer of signature over the digest and check it equals airnode. With viem that is:
ts
import { encodePacked, keccak256, toHex, verifyMessage } from 'viem';
const digest = keccak256(encodePacked(['bytes32', 'uint256', 'bytes'], [requestHash, BigInt(timestamp), toHex(data)]));
const valid = await verifyMessage({ address: airnode, message: { raw: digest }, signature });Hash data as the string it arrived as, and parse it only to read it. For a passthrough response read the four fields from the headers and pass the response body as the string it arrived as, without parsing it first:
ts
const data = await response.text();
const airnode = response.headers.get('X-Airnode-Address');
const requestHash = response.headers.get('X-Airnode-Request-Hash');
const timestamp = response.headers.get('X-Airnode-Timestamp');
const signature = response.headers.get('X-Airnode-Signature');A byte order mark at the start of an upstream body is dropped before signing, as response.text() and every other UTF-8 decoder drop it, so the text you read is the text that was signed.
The address to check against comes from the x-airnode extension of the document the Airnode serves at GET /, and what ties that address to a real-world provider is its listing on AirnodeHub.
There is deliberately no verify endpoint on an Airnode: an Airnode grading its own work would prove nothing, so verification stays with you. A listing's page on AirnodeHub runs the check above in the browser.