Implement Offer Fetch with Protocol v1.0
POST {offer_fetch_url} AON -> Partner
Offer Fetch is the AON -> Partner supply-side contract. AON sends signed
POST requests to the exact URL you configure in
Partner Portal, and your backend returns eligible offers in the OfferProvider
response envelope. Partner Portal manages account setup and operational access
for this flow.
Continue here after an approved Partner has valid credentials and an exact Offer Fetch URL ready to register in Partner Portal. The endpoint must be under your team's control and able to receive signed AON requests.
Authoritative spec
This page is a partner-friendly implementation guide. The normative specification lives in the public protocol repository: OfferProvider API spec. When summary and spec disagree, the spec wins.
Canonical OfferProvider supply contract
This guide covers the current v1.0 Partner supply carrier. Return stable
source_offer_id; AON resolves its own offer_id, creates
offer_instance_id, and authors any match_reason for the later public
Query response. Service access and operations are configured separately from
this protocol contract.
Protocol v1.0 wire contract
Send and echo the exact negotiation value 1.0. Do not rename, add, remove,
or reinterpret fields from the canonical v1.0 OfferProvider schema.
This transport version is separate from each Offer's required document-model
marker, which remains version: "3.0". Do not replace it with "1.0" or
omit it.
Requiredness, validation, signing, retry, test mode, and postback behavior
are defined by the linked v1.0 specifications.
Implementation checklist
- Configure your Offer Fetch URL through an enabled Partner account and follow the Portal setup requirements for your integration.
- Verify
X-AON-Timestamp,X-AON-Nonce, andX-AON-Signaturebefore parsing business logic. - Parse the
OfferProviderrequest body withrequest_id,context, andintent. - Match offers from your catalog and return the v1.0 Partner supply envelope
with
request_id,protocol_version,language, andoffers. - For each returned offer, populate
offer_info.category.idwith an AON Taxonomy v1 id from the Category Taxonomy. - Keep each Offer's required document-model marker at
version: "3.0"; the OfferProvider transport selector and body echo remain1.0. - When you have canonical flight or hotel facts, add the optional closed
offer_info.detailsprofile described in Travel offer details. - Treat
X-AON-Test: trueas non-production traffic and suppress side effects. - Keep protocol and schema questions anchored to the GitHub sources; the spec wins.
What this page is and is not
This page covers everything you need to stand up an Offer Fetch endpoint and pass AON's onboarding compliance check:
- How AON calls you: method, URL, headers, and signing.
- What the request body contains.
- The 6-step path your handler should walk.
- The response envelope for success and errors.
- Test mode semantics.
This page does not restate the full Partner Offer Schema or invent new OfferProvider fields. For normative field definitions, follow the protocol and schema links in Authoritative protocol references.
AON calling convention
AON sends every offer fetch as a signed POST to the complete URL you register
in Partner Portal as your Offer Fetch URL.
POST {offer_fetch_url}
| Property | Value |
|---|---|
| Method | POST |
| URL | The exact URL you saved as Offer Fetch URL; AON does not append a path |
Content-Type | application/json |
| Body | OfferProvider request body; see Request body schema |
The URL you save in Partner Portal is the URL AON calls. If you want the protocol's
/v1/offers/querypath on your side, registerhttps://api.example.com/aon/v1/offers/querydirectly.
Required headers
| Header | Value |
|---|---|
X-AON-Key | The appkey AON issued to your Partner account |
X-AON-Timestamp | Unix epoch seconds as an ASCII decimal integer |
X-AON-Nonce | Unique-per-request random string; UUIDv4 recommended |
X-AON-Signature | Lowercase hex HMAC-SHA256(secret, signing_string) |
AON-Protocol-Version | Exact value 1.0; omitted or unsupported values fail closed |
X-AON-Test | true when AON is sending a test request |
Signing string
The signing string is exactly:
POST\n
{path}\n
{raw_request_body_bytes}\n
{X-AON-Timestamp}\n
{X-AON-Nonce}Notes:
- Use the raw UTF-8 bytes AON sent on the wire. Do not re-serialize, re-order keys, or normalize whitespace before hashing.
- Compare signatures in constant time to avoid timing side-channels.
Sample verify implementations
Minimal verify(secret, signing_string, received_signature_hex) -> bool
functions in five languages, each using only the standard library. These
snippets cover signature verification only; a complete handler also needs the
timestamp window check and nonce anti-replay.
For full reproducible test vectors and a generator script, see
hmac-signing-cases.md
in the protocol examples repo.
// Node.js (built-in `crypto`)
const crypto = require('crypto');
const HEX_64 = /^[0-9a-f]{64}$/i;
function verify(secret, signingString, receivedSigHex) {
if (!HEX_64.test(receivedSigHex)) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(signingString, 'utf8')
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(receivedSigHex, 'hex'),
);
}# Python 3.7+ (built-in `hmac` + `hashlib`)
import hmac
import hashlib
def verify(secret: bytes, signing_string: bytes, received_sig_hex: str) -> bool:
expected = hmac.new(secret, signing_string, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received_sig_hex.lower())// Go 1.18+ (built-in `crypto/hmac` + `crypto/sha256`)
package partner
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
)
func Verify(secret, signingString []byte, receivedSigHex string) bool {
mac := hmac.New(sha256.New, secret)
mac.Write(signingString)
expected := mac.Sum(nil)
received, err := hex.DecodeString(receivedSigHex)
if err != nil {
return false
}
return hmac.Equal(expected, received)
}// Java 8+ (built-in `javax.crypto.Mac` + `java.security.MessageDigest`)
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
public static boolean verify(byte[] secret, byte[] signingString, String receivedSigHex)
throws Exception {
byte[] received = hexDecode(receivedSigHex);
if (received == null) return false;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret, "HmacSHA256"));
byte[] expected = mac.doFinal(signingString);
return MessageDigest.isEqual(expected, received);
}
private static byte[] hexDecode(String s) {
if (s == null || s.length() != 64) return null;
byte[] out = new byte[32];
for (int i = 0; i < 64; i += 2) {
int hi = Character.digit(s.charAt(i), 16);
int lo = Character.digit(s.charAt(i + 1), 16);
if (hi < 0 || lo < 0) return null;
out[i / 2] = (byte) ((hi << 4) + lo);
}
return out;
}<?php
// PHP 7.2+ (built-in `hash_hmac` + `hash_equals`)
function aon_verify(string $secret, string $signingString, string $receivedSigHex): bool {
$expected = hash_hmac('sha256', $signingString, $secret);
return hash_equals($expected, strtolower($receivedSigHex));
}Each snippet uses its language's constant-time HMAC compare primitive:
crypto.timingSafeEqual, hmac.compare_digest, hmac.Equal,
MessageDigest.isEqual, or hash_equals.
Timestamp window
Reject any request where |server_now - X-AON-Timestamp| > 300 seconds with
401 UNAUTHORIZED and message "timestamp outside allowed skew". Do this
before verifying the signature so tampered timestamps do not waste HMAC compute.
The 5-minute skew is part of the protocol contract for onboarding compliance.
Request body schema
The body follows
offer-provider-request.json.
The AON -> Partner channel requires request_id, context, and intent on
every dispatch.
Supply-side constraints
The OfferProvider request uses constraints, matching the public Query root field name. AON Taxonomy v1 dispatches constraints.category_ids.
When constraints.category_ids is present:
| Rule | Meaning |
|---|---|
| Multiple ids | OR logic. Return offers that match any requested id. |
| Parent ids | A parent id matches its whole subtree, so travel_tourism includes deeper travel ids. |
| Case | Ids are lowercase and case-sensitive. Do not send display names. |
others | others is a standard Level 1 id. It does not stand in for invalid or unknown ids. |
Top-level
request_idAON-generated request correlation id. UUIDv7 is recommended. Partners may echo it in logs or `X-AON-Request-Id`.
contextBounded platform, session, and conversation context. Long-term user profiles are not part of the v1.0 wire contract.
intentCurrent-turn multimodal intent with required provenance and at least one entry in intent.content[].
constraintsPartner-facing supply-side constraints. Current dispatches expose category_ids.
force_offerRequest a fallback recommendation when normal matching returns no offer.
response_optionsPublic projection controls. `thinking_mode` defaults to true; when false, AON omits `match_reason` from its later Query response. Partner supply never returns that field.
timestampRFC 3339 dispatch time; informational, not the signing timestamp.
test_modeBody mirror of X-AON-Test. Header wins on disagreement.
Example payload
{
"request_id": "01984dc5-3b32-7c1a-9e8b-2f1a7b4d8c11",
"timestamp": "2026-05-04T08:30:00Z",
"test_mode": false,
"context": {
"platform": { "name": "PartnerDemo", "version": "2.0.0", "channel": "web" },
"session": {
"previous_request_id": "01984dc5-3b32-7c1a-9e8b-2f1a7b4d8c10",
"recent_topics": ["Tokyo hotels"]
}
},
"intent": {
"content": [
{ "type": "input_text", "text": "weekend hotel deals in Tokyo under $200" }
],
"provenance": "user_expressed",
"signals": {
"budget": { "max": 200, "currency": "USD" },
"timeframe": "this_week"
}
},
"constraints": {
"category_ids": ["travel_tourism"]
},
"force_offer": false,
"response_options": { "thinking_mode": true }
}Pin intent.content[] and intent.provenance first. Forward only the bounded
context and structured intent.signals fields defined by the v1.0 schema. A
budget without an explicit ISO 4217 currency must not be turned into a numeric
budget constraint.
For the full field-by-field semantics, see
offer-provider-request.json.
Handler flow
The minimum conformant handler walks 6 steps in this order:
1. Verify X-AON-Timestamp window -> reject 401 on skew
2. Verify X-AON-Signature HMAC -> reject 401 on mismatch
3. Check X-AON-Nonce against a short-TTL replay store
4. Parse JSON body to OfferProvider request
5. Match offers from your catalog
6. Assemble the response envelope and return 200Step-by-step:
- Verify timestamp. If
|now - ts| > 300s, return401 UNAUTHORIZEDwithmessage: "timestamp outside allowed skew". - Verify signature. Compute
HMAC_SHA256(secret, signing_string)over the raw bytes you received, hex-encode lowercase, and constant-time compare. - Check nonce. Keep a short-TTL set of
(appkey, nonce)for the last 5 minutes. Duplicate nonces should return401 UNAUTHORIZED. - Parse the body. Return
400 BAD_REQUESTon malformed JSON or missing REQUIRED fields such asrequest_id,context,intent.provenance, orintent.content[]. - Match offers. Apply
constraints.*first, then rank againstintent.content[]. Empty results are normal; return200withoffers: [], not404. - Return the envelope. Make sure each offer satisfies every REQUIRED field in the Partner Offer Schema.
When X-AON-Test: true, still walk steps 1-6, but skip tracking, billing,
fulfillment, inventory decrement, conversion accounting, and other side
effects.
Success response envelope
Return the response header AON-Protocol-Version: 1.0. Where the response
type defines a body echo, return the exact protocol_version: "1.0" value.
{
"request_id": "01984dc5-3b32-7c1a-9e8b-2f1a7b4d8c11",
"protocol_version": "1.0",
"language": "en-US",
"offers": []
}| Field | Required? | Notes |
|---|---|---|
request_id | REQUIRED | Echoes the incoming request_id so the agent -> AON -> Partner chain shares one correlation id. |
protocol_version | REQUIRED | Exact value 1.0 for this response profile. |
language | REQUIRED | Language of Partner-authored user-facing Offer content under the current language profile. |
offers[] | REQUIRED | Array of Partner supply Offers from offer-partner-schema.json. May be empty. |
Do not add pagination or diagnostic top-level fields to the current success
envelope. The v1.0 response schema carries request_id, protocol_version,
language, and offers;
future pagination metadata will arrive through a schema revision if AON needs
it on the Partner channel.
Each offers[] element must satisfy the REQUIRED fields in
offer-partner-schema.json:
source_offer_id, version, offer_info, entity, action, and goals.
Do not return AON-owned offer_id, offer_instance_id, or match_reason;
AON adds those fields after source identity resolution and eligibility
evaluation. Each goal declares its public event and gross Partner-to-AON
commission basis. Inside
offer_info, title, category, and description are required; offer_type
is an optional fulfillment hint. Every offer must carry
offer_info.category.id. Do not emit legacy bid or internal-only aliases in
a v1.0 response.
offer_info.category is an object, not a flat category_id string: set its
id to the best-fit AON Taxonomy v1 id for the offer. A complete minimal offer
that satisfies every REQUIRED field looks like this:
{
"source_offer_id": "hotel-deluxe-king-001",
"version": "3.0",
"offer_info": {
"title": "The Manhattan Grand - Deluxe King Room",
"offer_type": "online_service",
"category": { "id": "travel_tourism.accommodations" },
"description": "Luxury midtown hotel with rooftop pool and breakfast."
},
"entity": {
"id": "ent_manhattan_grand",
"name": "The Manhattan Grand Hotel"
},
"action": {
"type": "open_url",
"name": "Reserve a room",
"payload": { "url": "https://www.manhattangrand.example/book/deluxe-king" }
},
"goals": [
{
"event": "conversion",
"pricing": { "model": "cpa", "amount": "42.00", "currency": "USD" }
}
]
}Travel offer details
Every Partner Offer still requires the universal Offer shell, including
source_offer_id, version: "3.0", offer_info.title,
offer_info.category.id, offer_info.description, entity, action,
and goals. A registered profile adds structured domain facts; it does not
replace those fields.
Use offer_info.details only when your source can satisfy a complete registered
shape. Generic Offers remain valid and omit offer_info.details. Do not send
custom profile names, null placeholders, partial profile objects, or facts
your source does not know.
details.profile: "flight"Priced itinerary, travelers, ordered legs, and segments →Hotel Ratedetails.profile: "hotel_rate"Starting nightly reference; stay dates and room may be unknown →Browse all registered Supply Offer Profiles for searchable fields, requirement rules, source links, and complete fixture-derived examples.
source_offer_id remains opaque and stable within the identity namespace
configured for your integration. AON resolves (Partner, identity namespace, source_offer_id) to one globally unique canonical offer_id. Do not rotate
the source value between requests for the same inventory item.
Choose a real, active offer_info.category.id from AON Taxonomy v1. Level 1
ids remain valid when you cannot confidently choose a deeper child; when you
can, prefer the most specific id. See the
Category Taxonomy for the full list.
Error envelope
Errors follow AON's project-wide ApiResponse contract:
{
"code": "BAD_REQUEST",
"message": "intent.content must contain at least one item",
"data": {},
"extra": {}
}code is a machine-readable string; message is a human-readable summary.
Always include data: {} and extra: {} even when empty.
| HTTP | code | When |
|---|---|---|
| 400 | BAD_REQUEST | Malformed body or missing REQUIRED fields. |
| 401 | UNAUTHORIZED | Missing/invalid auth header, signature mismatch, expired timestamp, or replayed nonce. |
| 403 | FORBIDDEN | Valid appkey but the Partner account is suspended or not permitted for this query. |
| 429 | RATE_LIMITED | Frequency cap exceeded. Include a Retry-After header when you can. |
| 500 | INTERNAL_ERROR | Unexpected failure on your side. |
OfferProvider error envelope deliberately uses ApiResponse
({code, message, data, extra}), not the agent-facing {error: {...}} shape.
Success responses do not include code; code only appears in the error
envelope.
Test mode
AON signals a test request in two places that must carry identical semantics:
- Header:
X-AON-Test: true, which is authoritative. - Body:
test_mode: true.
When either signal is true, your handler must:
- Return shape-compatible offers so AON's onboarding validator can inspect the response.
- Suppress all tracking, billing, fulfillment, inventory decrement, and conversion side-effects.
- Tag your own logs to distinguish test from production traffic.
Recommended pattern: branch once at the top of the handler on
req.headers["X-AON-Test"] === "true", set a test_mode boolean, and
short-circuit side-effecting writes for the rest of the request.
Offer Fetch completion
This step is complete when the exact Offer Fetch URL is registered. A signed fetch test succeeds with the v1.0 OfferProvider envelope. Test mode has no production side effects. Continue to Postbacks & Attribution to complete the Provider click and postback test; a successful fetch test alone is not a live integration claim.
Authoritative protocol references
When you need the complete normative spec, auth signing math, full schema, test vectors, conformance tests, or design rationale, go to the public source:
- OfferProvider API spec - the document this page summarizes.
- OfferProvider request schema - request body JSON Schema for AON -> Partner dispatch.
- OfferProvider response schema - raw success and error envelopes.
- Partner Offer Schema - every Partner-authored field on each
offers[]element. - Offer field semantics - identity, targeting, commission, URI, and projection semantics.
- HMAC signing test vectors - reproducible signing cases for verifying your implementation.
When this partner-facing summary and the protocol spec disagree, the spec wins. File protocol-level issues against the protocol repo rather than treating this docs page as the source of truth.