Appearance
From API to contract
The starter template runs this flow for a Nodary ETH/USD price. Adapt it to any listing on AirnodeHub.
Find an operation
Find a listing on AirnodeHub, or connect your agent to the MCP server at https://airnodehub.api3.org/mcp and use search-listings. A GET of the Airnode's URL returns its operations and their parameters. The x-airnode extension of that document has the address that the Airnode signs with. Store this address in your contract. Over MCP, call-operation refuses priced operations, so call those on the Airnode itself. resolve-intent is rate-limited.
Choose a route
An Airnode takes a request in two ways: POST / and the passthrough. Both sign the response, and a contract verifies the signature the same way. They hash the request differently, so a contract that stores one request hash accepts responses from one route only.
POST / | Passthrough | |
|---|---|---|
| Request | A JSON body with the operation, its parameters and the projection | The provider's own method, path, query and body, sent to /api/…. The projection goes in the query as x-airnode-project. |
| Response | A JSON object with airnode, requestHash, timestamp, data and signature | The body is data. The rest comes in the X-Airnode-* headers. |
| Request hash | The operation, the parameters and the projection, with keys sorted | The method, the path, the query, the declared header parameters and the body, as sent, and the projection |
The starter's Nodary request, through each route:
sh
curl --request POST https://airnode-nodary.fly.dev/ \
--header 'Content-Type: application/json' \
--data '{"operation":"latestFeeds","parameters":{"name":"ETH/USD"},"responseProjection":{"price":"/ETH~1USD/value","updatedAt":"/ETH~1USD/timestamp"}}'
curl --include 'https://airnode-nodary.fly.dev/api/feed/latest?name=ETH/USD&x-airnode-project=price:/ETH~1USD/value&x-airnode-project=updatedAt:/ETH~1USD/timestamp'Both sign the same data. The starter's scripts call POST /, or the passthrough when you give them --passthrough. The AirnodeHub listing pages and its MCP server use the passthrough. If your contract builds the request hash, use POST /, as Build the hash in the contract explains.
Fix the request hash
A request is an operation, its parameters and a projection that cuts the response down to the fields that your contract needs. Its hash depends only on the request, so you can compute it before any data exists and store it in your contract.
Every response also carries the hash of its request: in the requestHash field through POST /, and in the X-Airnode-Request-Hash header through the passthrough. You can copy the hash from one response in place of computing it. Use it off-chain only, to store it or to check that a response answers your request. The contract verifies against the hash that it stores or builds, never against the one that comes with a response.
Same request, different hashes
POST / and the passthrough hash the same request differently. For example, the Nodary price request hashes to 0x180eae… through POST / and to 0x81425f… through the passthrough, although both sign the same data. A contract that stores one of these hashes rejects every response from the other route. Compute the hash for the route that your client calls.
POST /
requestHash = keccak256(JSON.stringify([operation, canonical(parameters), canonical(projection)]))canonical writes each object as an array of [key, value] entries, sorted by key in byte order. The Nodary request hashes this string:
text
["latestFeeds",[["name","ETH/USD"]],[["price","/ETH~1USD/value"],["updatedAt","/ETH~1USD/timestamp"]]]Passthrough
requestHash = keccak256(JSON.stringify(["passthrough", method, path, query, headers, body, canonical(projection)]))pathis the path after/api, as sent, with its percent-encoding.queryis the entries in the order sent, decoded, without thex-airnode-parameters. Each value is a string.headersis the header parameters that the Airnode declares, with lower-case names, sorted. Most listings have none.bodyis the body as sent, or""for a GET.
The Nodary request hashes this string:
text
["passthrough","GET","/feed/latest",[["name","ETH/USD"]],[],"",[["price","/ETH~1USD/value"],["updatedAt","/ETH~1USD/timestamp"]]]Without a projection, both forms leave out the last element. Attestation has the full scheme.
Only the exact request passes
The contract stores the hash, not the request. A projection with all the fields that the contract reads, plus one more, does not pass.
| Change to the request | POST / | Passthrough |
|---|---|---|
| Another order of the projected fields | Same | Same |
| Another order of the parameters | Same | Different |
| One more or one fewer projected field | Different | Different |
| Another field name or pointer | Different | Different |
| No projection | Different | Different |
Another parameter value, such as BTC/USD | Different | Different |
| An optional parameter sent with its default value, in place of no parameter | Different | Different |
123 in place of "123" | Different | Different in a body. A path or query value is always a string. |
Another percent-encoding of a path value, such as US%3AAAPL for US:AAPL | Different | |
| Another percent-encoding of a query value | Same | |
| Other whitespace or key order in a POST body | Different | |
| The other route | Different | Different |
The Airnode hashes the parameters as you send them. It does not add default values or convert types.
Fetch a signed response
Send the request through the route that you chose. This is a signed response to the POST / request above, from 19 October 2026:
json
{
"airnode": "0xE70f1e8b22a21e4Bb5188918a3033341b281E4c0",
"requestHash": "0x180eae9387bb66a72380e61ebe0a00d19f8414a716a37ebe1df124b66acfff66",
"timestamp": "1792403240",
"data": "{\"price\":2443.95,\"updatedAt\":1792403240691}",
"signature": "0xb034c17a11679ff06e50445c84419c34ba0aee5669bbda3a0dd563d2437928a17e5b34826fd505b0d1cace3712570df07de0d0a7eb2e1610c84cd4a0d71827e21b"
}Use it to test your signature check with a fixed test clock. A live freshness check rejects it, so fetch a current response for a demo.
Paying with x402
A priced operation answers with 402 and its price, through either route. Your client signs a payment on Base Sepolia (chain ID 84532) and sends the call again, with no account and no API key. An Airnode accepts either of these two tokens, and charges the same amount in each:
| Token | Address on Base Sepolia | Where to get it |
|---|---|---|
| Testnet USDC | 0x036CbD53842c5426634e7929541eC2318f3dCF7e | Circle's faucet. Choose Base Sepolia. |
| AirnodeHub USD (aUSD), API3's test dollar | 0xbeA34973e5f67F18F656A2C6070C64326C791808 | The aUSD faucet. |
The payer needs only the token and no ETH, because the x402 facilitator submits the transfer and pays its gas.
To get aUSD, mint it with a browser wallet on the aUSD faucet, or call mintFromFaucet on the token. Each account can mint up to the faucet limit in total. aUSD has 6 decimals, so 1 aUSD is 1000000. A mint is a transaction, so the account needs Base Sepolia ETH for its gas, from a faucet such as Coinbase's or Alchemy's.
The starter's createPayingFetch wraps fetch with the @x402/fetch client. Pass it to either fetch function:
ts
const payingFetch = createPayingFetch(payerPrivateKey, 'USDC');
const signedResponse = await fetchSignedResponse(airnodeUrl, request, payingFetch);It pays only in the asset that you give it, USDC or aUSD. On its own, the @x402/fetch client pays only in assets that it knows, such as USDC, so createPayingFetch adds aUSD to its spendControls.allowedAssets. The starter's scripts pay when PAYER_PRIVATE_KEY is set. Payments has the details.
Submit it
Anyone can send timestamp, data and signature to your contract: a user, a keeper bot or an agent. data is the string that the Airnode signed, {"price":2443.95,"updatedAt":1792403240691}. It goes to the contract as the bytes of that string, without parsing it and writing it again.
Verify, then act
The starter's example contract does this:
solidity
AirnodeV2Attestation.verify(AIRNODE_V2, requestHash, timestamp, data, signature);
if (timestamp + MAX_RESPONSE_AGE < block.timestamp) revert ResponseTooOld(timestamp);
uint256 price = ProjectedJson.readUint(data, "price", 18);A valid signature alone does not make a contract safe. Keep all three checks:
- The signer. Compare it with the address that your contract stores. Never trust the address that comes with the response.
- The request. Give
verifythe request hash that your contract stores or builds, never the one that comes with the response. Then a response for another flight or another city fails. - The time.
timestampis when the Airnode signed. Decide what fresh means for your case. For example, a flight-delay claim should accept only a status signed after the scheduled arrival.
Also check the time of the observation where it matters, because a fresh signature can cover an old value. If an action must happen only once, such as a payout, do not let the same response settle it twice.
Accepting more than one request
Store a hash for each request
Compute each hash off-chain, and store it with its item. The starter has deriveRequestHash for POST / and derivePassthroughRequestHash for the passthrough. You can also copy the hash from one response.
solidity
mapping(uint256 policyId => bytes32 requestHash) public policyRequestHashes;A hash does not show its request. Thus whoever stores a hash decides what the contract accepts. If a user supplies the hash for their own bet or policy, the other party must make sure off-chain that the hash is for the intended request.
Build the hash in the contract
The contract fixes the operation and the projection, and takes only the parameter values. A caller can then choose the feed, but not the fields. Write the string that the Airnode hashes as a template, and put each value in its place.
Use POST / when the contract builds the hash. Its hash does not change with the order of the parameters, with percent-encoding or with the bytes of a body. A passthrough hash is harder to build for:
- A POST body, which is hashed byte for byte, with its key order and whitespace. Hyperliquid, Morpho (with a fixed GraphQL query of up to 951 bytes), Kleros Scout and dRPC send one.
- A path value with
,:|+;or/, which URL builders encode differently. Examples are Visual Crossing locations, DefiLlama coins, Ticker Layer symbols, GeoDB locations and eBay item IDs. - Two or more query parameters, which the client must send in the order of the template.
A simple GET route, such as Nodary or a PandaScore match, also works through the passthrough. Encode each path value with LibString.encodeURIComponent, as the AirnodeHub listing pages do.
The first function below gives the same hash as pnpm request-hash for any Nodary feed name, for about 5,500 gas. The second one is for a PandaScore match, for about 1,000 gas.
solidity
import {LibString} from "solady/utils/LibString.sol";
import {ProjectedJson} from "./ProjectedJson.sol";
// Hashes ["latestFeeds",[["name","ETH/USD"]],[["price","/ETH~1USD/value"],["updatedAt","/ETH~1USD/timestamp"]]]
function deriveNodaryRequestHash(string memory feedName) pure returns (bytes32) {
string memory name = LibString.escapeJSON(feedName);
string memory feedToken = LibString.escapeJSON(ProjectedJson.escapePointerToken(feedName));
return keccak256(bytes(string.concat(
'["latestFeeds",[["name","', name, '"]],',
'[["price","/', feedToken, '/value"],["updatedAt","/', feedToken, '/timestamp"]]]'
)));
}
// Hashes ["getMatch",[["match_id_or_slug","1234567"]],[["status","/status"],["winnerId","/winner_id"]]]
function deriveMatchRequestHash(uint256 matchId) pure returns (bytes32) {
// The parameter is declared as a string, so the ID goes in quotes, as the client sends it.
return keccak256(bytes(string.concat(
'["getMatch",[["match_id_or_slug","', LibString.toString(matchId), '"]],',
'[["status","/status"],["winnerId","/winner_id"]]]'
)));
}- Key order: in the template, write the parameters, and the projected fields, in the byte order of their names, such as
pricebeforeupdatedAtandZbeforea. This is the order that the Airnode sorts them into before it hashes. The client can send them in any order. - Strings: escape each string value with
LibString.escapeJSON. Escape a value in a pointer withProjectedJson.escapePointerTokenfirst. - Integers: write an integer without quotes, with
LibString.toString, up to 2^53 - 1. JavaScript reads a larger integer inexactly, so send it as a string. - Decimals: the Airnode reads the numbers of a
POST /request as JavaScript numbers and hashes them as JavaScript writes them, such as51.5for51.50and5e-7for0.0000005. Write a decimal parameter the same way. This applies to the request only. The response keeps each number as the API wrote it. - Types: use the type that your client sends.
123and"123"give different hashes. - Test: compare the hash with the
requestHashof one real response in a Foundry test. This finds a wrong key order, name or type.
The contract still decides which values it accepts. For example, store the feed name of each market.
When every real response fails
If verify reverts with NotSignedByAirnode for every real response, look at the address in the error:
- The address that the Airnode signs with: the contract stores a wrong Airnode address.
- An unknown address: the stored request hash is not the one that the Airnode signed. Compare it with the
requestHashof the response, or with itsX-Airnode-Request-Hashheader for the passthrough. Then look for the cause in the table.
To find this before you send a transaction, build the request and its hash from the same request object, as the starter does for each route. verifySignedResponse then compares the hashes off-chain.