# Implement Offer Fetch with Protocol v1.0

Source: https://docs.aon.pro/partner/offer-fetch

> Derived from the same AON Docs release as the source page.

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](https://github.com/agentoffernetwork/protocol/blob/main/v1.0/specs/offer-provider-api.md). 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`, and `X-AON-Signature` before parsing business logic.
-   Parse the `OfferProvider` request body with `request_id`, `context`, and `intent`.
-   Match offers from your catalog and return the v1.0 Partner supply envelope with `request_id`, `protocol_version`, `language`, and `offers`.
-   For each returned offer, populate `offer_info.category.id` with an AON Taxonomy v1 id from the [Category Taxonomy](https://docs.aon.pro/protocol/category-taxonomy).
-   Keep each Offer's required document-model marker at `version: "3.0"`; the OfferProvider transport selector and body echo remain `1.0`.
-   When you have canonical flight or hotel facts, add the optional closed `offer_info.details` profile described in [Travel offer details](https://docs.aon.pro/partner/offer-fetch#travel-offer-details).
-   Treat `X-AON-Test: true` as 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](https://docs.aon.pro/partner/offer-fetch#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](https://docs.aon.pro/partner/offer-fetch#request-body-schema) |

> The URL you save in Partner Portal is the URL AON calls. If you want the protocol's `/v1/offers/query` path on your side, register `https://api.example.com/aon/v1/offers/query` directly.

### 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:

```text
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`](https://github.com/agentoffernetwork/examples/blob/main/v1.0/http/offer-provider/hmac-signing-cases.md) in the protocol examples repo.

```js
// 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
# 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
// 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
// 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
// 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`](https://github.com/agentoffernetwork/schema/blob/main/v1.0/json-schema/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

Parameter

Description

`request_id`

string (uuid)Required

AON-generated request correlation id. UUIDv7 is recommended. Partners may echo it in logs or \`X-AON-Request-Id\`.

`context`

objectRequired

Bounded platform, session, and conversation context. Long-term user profiles are not part of the v1.0 wire contract.

`intent`

objectRequired

Current-turn multimodal intent with required provenance and at least one entry in intent.content\[\].

`constraints`

objectOptional

Partner-facing supply-side constraints. Current dispatches expose category\_ids.

`force_offer`

booleanOptional

Request a fallback recommendation when normal matching returns no offer.

`response_options`

objectOptional

Public projection controls. \`thinking\_mode\` defaults to true; when false, AON omits \`match\_reason\` from its later Query response. Partner supply never returns that field.

`timestamp`

stringOptional

RFC 3339 dispatch time; informational, not the signing timestamp.

`test_mode`

booleanOptional

Body mirror of X-AON-Test. Header wins on disagreement.

### Example payload

```json
{
  "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`](https://github.com/agentoffernetwork/schema/blob/main/v1.0/json-schema/offer-provider-request.json).

## Handler flow

The minimum conformant handler walks 6 steps in this order:

```text
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 200
```

Step-by-step:

1.  **Verify timestamp**. If `|now - ts| > 300s`, return `401 UNAUTHORIZED` with `message: "timestamp outside allowed skew"`.
2.  **Verify signature**. Compute `HMAC_SHA256(secret, signing_string)` over the raw bytes you received, hex-encode lowercase, and constant-time compare.
3.  **Check nonce**. Keep a short-TTL set of `(appkey, nonce)` for the last 5 minutes. Duplicate nonces should return `401 UNAUTHORIZED`.
4.  **Parse the body**. Return `400 BAD_REQUEST` on malformed JSON or missing REQUIRED fields such as `request_id`, `context`, `intent.provenance`, or `intent.content[]`.
5.  **Match offers**. Apply `constraints.*` first, then rank against `intent.content[]`. Empty results are normal; return `200` with `offers: []`, not `404`.
6.  **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.

```json
{
  "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`](https://github.com/agentoffernetwork/schema/blob/main/v1.0/json-schema/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:

```json
{
  "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.

[**Flight**`details.profile: "flight"`Priced itinerary, travelers, ordered legs, and segments →](https://docs.aon.pro/partner/offer-profiles/flight)[**Hotel Rate**`details.profile: "hotel_rate"`Starting nightly reference; stay dates and room may be unknown →](https://docs.aon.pro/partner/offer-profiles/hotel-rate)

[Browse all registered Supply Offer Profiles](https://docs.aon.pro/partner/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](https://docs.aon.pro/protocol/category-taxonomy) for the full list.

## Error envelope

Errors follow AON's project-wide `ApiResponse` contract:

```json
{
  "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](https://github.com/agentoffernetwork/protocol/blob/main/v1.0/specs/offer-provider-api.md)** - the document this page summarizes.
-   **[OfferProvider request schema](https://github.com/agentoffernetwork/schema/blob/main/v1.0/json-schema/offer-provider-request.json)** - request body JSON Schema for AON -> Partner dispatch.
-   **[OfferProvider response schema](https://github.com/agentoffernetwork/schema/blob/main/v1.0/json-schema/offer-provider-response.json)** - raw success and error envelopes.
-   **[Partner Offer Schema](https://github.com/agentoffernetwork/schema/blob/main/v1.0/json-schema/offer-partner-schema.json)** - every Partner-authored field on each `offers[]` element.
-   **[Offer field semantics](https://github.com/agentoffernetwork/protocol/blob/main/v1.0/specs/offer-field-semantics.md)** - identity, targeting, commission, URI, and projection semantics.
-   **[HMAC signing test vectors](https://github.com/agentoffernetwork/examples/blob/main/v1.0/http/offer-provider/hmac-signing-cases.md)** - 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.

[Continue to Postbacks & Attribution](https://docs.aon.pro/partner/postbacks)
