Appearance
Attestation
Every POST / 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.
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: the JSON value when the body parses as JSON, a string or number as much as an object or array, and the text as received otherwise.
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. 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"]
]
]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 = data when data is a string
dataBytes = JSON.stringify(data) otherwise
digest = keccak256(abi.encodePacked(bytes32 requestHash, uint256 timestamp, bytes dataBytes))
signature = sign(keccak256("\x19Ethereum Signed Message:\n32" ‖ digest))dataBytes enters the digest as UTF-8 and timestamp is packed as a uint256. For a JSON body the signature covers its compact serialization rather than the bytes the upstream happened to send, so the attested thing is the JSON value and not the whitespace around it. One consequence is intended: the signature commits to bytes, not to which form carries them, so an attestation also verifies with its JSON data re-encoded as the equivalent compact string. Both forms state the same thing. 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 bytes = typeof data === 'string' ? data : JSON.stringify(data);
const digest = keccak256(encodePacked(['bytes32', 'uint256', 'bytes'], [requestHash, BigInt(timestamp), toHex(bytes)]));
const valid = await verifyMessage({ address: airnode, message: { raw: digest }, signature });Re-serializing is safe because JSON key order survives a round trip through JSON.parse, so JSON.stringify over the received data reproduces the signed bytes exactly.
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 the consumer. A listing's page on AirnodeHub runs the check above in the browser.