# Webhook Protocol v1.0

Source: https://docs.aon.pro/api/webhooks

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

AON sends `conversion` and four earning status events to the HTTPS endpoint configured for a Developer Application. Verify every delivery before parsing JSON or applying a business side effect.

> Canonical source and runtime evidence are different
>
> The protected [Protocol v1.0 postback specification](https://github.com/agentoffernetwork/protocol/blob/main/v1.0/specs/postback.md) and [schema fixtures](https://github.com/agentoffernetwork/schema/tree/main/v1.0/fixtures) define the canonical contract. Publication does not prove that a particular runtime, OpenAPI document, SDK, or Portal follower has deployed and validated it. Treat each follower as unvalidated until its owner publishes evidence; production support requires separate runtime and release evidence.

## Required headers

Every Protocol v1.0 delivery uses `POST`, `Content-Type: application/json`, and exactly one value for each security/control header:

| Header | Required value or responsibility |
| --- | --- |
| `AON-Protocol-Version` | Exact value `1.0` |
| `X-AON-Key` | Opaque callback key id selecting one current or grace secret |
| `X-AON-Timestamp` | Canonical unsigned Unix epoch seconds for this attempt |
| `X-AON-Signature` | Lowercase-hex HMAC-SHA256 over the exact wire inputs |

AON always delivers these Webhooks on Protocol v1.0 with `AON-Protocol-Version: 1.0`, whichever protocol version or placement type (including Offer Wall placements) your Offer Query requests used.

Reject a missing, empty, or duplicate header before HMAC verification or JSON parsing. Never choose the first or last value from duplicate security headers.

## Verify the raw body

For every attempt, AON signs this byte string:

```text
POST\n{request-target}\n{exact-raw-body}\n{timestamp}
```

`request-target` is the exact origin-form path and raw query. Preserve repeated slashes, percent encoding, query order, and spelling. `exact-raw-body` is the byte sequence received on the wire. Verify the HMAC over those bytes before parsing or re-serializing JSON, compare the lowercase hexadecimal signature in constant time, then validate the fields defined for the branch selected by `event_type`; ignore any field you do not recognize (see [Unknown fields](https://docs.aon.pro/api/webhooks#unknown-fields)).

`X-AON-Timestamp` is the attempt signing time. The payload `timestamp` is the business event time, normalized to UTC; the two fields are intentionally different.

## Payload fields

AON sends only the fields below for this revision of the `conversion` event. Fields marked optional are omitted when absent — they are never sent as `null`. Conversion bodies have this exact field order:

```text
event_id, event_type, aon_click_id?, offer_instance_id?, offer_id,
application_id, placement_id?, event_name, amount, currency, sub_id?,
sub_id_2?, sub_id_3?, sub_id_4?, sub_id_5?, timestamp
```

| Field | Required | Meaning |
| --- | --- | --- |
| `event_id` | Yes | Unique id for this event |
| `event_type` | Yes | Always `conversion` |
| `aon_click_id` | No | Opaque AON click id (begins with `aci_`) this conversion is attributed to |
| `offer_instance_id` | No | String. Id of the Offer delivery (Query/MCP result or offer wall) behind this conversion; see [Using offer\_instance\_id](https://docs.aon.pro/api/webhooks#using-offer_instance_id) |
| `offer_id` | Yes | The Offer that converted |
| `application_id` | Yes | Your Developer Application id |
| `placement_id` | No | The placement the traffic came through |
| `event_name` | Yes | The Offer goal that was met |
| `amount` | Yes | Your expected net earning in USD after the platform fee, rounded up to the next cent (minimum `"0.010000"` when it is above zero), as a fixed six-decimal JSON string; `"0.000000"` when the conversion does not earn |
| `currency` | Yes | Always `USD` |
| `sub_id`, `sub_id_2` … `sub_id_5` | No | Your attribution values from the original link |
| `timestamp` | Yes | Conversion event time, UTC |

`amount` is a JSON decimal string in USD with exactly six decimal places, never an integer number of cents. Your net share is rounded up to the next 0.01 USD, with a minimum of `0.01` whenever it is above zero. For example, a `0.001 USD` commission with a 20% platform fee produces `"amount": "0.010000"`; `"96.000000"` means 96 dollars. Rounding happens after fee deduction and any currency conversion. Conversions settled before this change may still carry sub-cent amounts. Parse the string with a decimal library for calculations; do not convert it to a JavaScript `Number` or Python `float`.

`amount` is an expectation at delivery time. Risk freezes emit no event. Subscribe to earning status events for later changes, including reversals.

## Earning status events

The existing Agent payload schema uses five `oneOf` branches, selected by `event_type`: `conversion`, `earning_confirmed`, `earning_available`, `earning_paid_out`, and `earning_rejected`. All five branches use the same six-decimal string format for `amount`. All events use the same endpoint, headers, raw-body HMAC, 2 MiB limit, durable idempotency rule and five-attempt retry ladder.

| Event | Meaning |
| --- | --- |
| `earning_confirmed` | The associated Partner statement has been confirmed. |
| `earning_available` | The earning has become available for payout. |
| `earning_paid_out` | AON has paid out this earning in full; this does not certify receipt in the developer's bank account. This state is terminal: AON does not register further earning status events for it. |
| `earning_rejected` | The earning was rejected by the merchant, reversed, or marked refunded in a review. |

Earning bodies have this exact field order. Optional fields are omitted, never null:

```text
event_id, event_type, earning_id, sequence, conversion_event_id,
offer_instance_id?, offer_id, application_id, placement_id?, amount, currency,
reason?, sub_id?, sub_id_2?, sub_id_3?, sub_id_4?, sub_id_5?, timestamp
```

| Field | Meaning |
| --- | --- |
| `event_id` | `earning:` followed by the immutable outbox UUID; unchanged on retry. |
| `event_type` | One of the four earning event names above. |
| `earning_id` | Lowercase hyphenated settlement UUID, identical to the Earnings API id. |
| `sequence` | Positive JSON integer increasing within this earning; gaps are allowed. |
| `conversion_event_id` | The originating conversion's event id, even if its webhook was never delivered. |
| `offer_instance_id` | Optional string. The `offer_instance_id` of the originating conversion — the same value as in that conversion event. Not unique; omitted when absent, never null. |
| `offer_id` | The Earnings API Offer identity; canonical resolution may differ from the conversion's Offer id. Join events using `conversion_event_id`. |
| `application_id` | Developer Application id. |
| `placement_id` | Optional placement identity. |
| `amount` | Net earning in USD as a fixed six-decimal JSON string, including review adjustments; rejection carries the last observed live amount, or the original amount when none was observed. |
| `currency` | Always `USD`. |
| `reason` | Required only for `earning_rejected`; forbidden on the other earning branches. |
| `sub_id`, `sub_id_2` … `sub_id_5` | Optional. The originating conversion's attribution slots, rendered by the same rule as the conversion event (leading and trailing whitespace removed; omitted when empty, never null). Present on every earning branch, including `earning_rejected`, even when the conversion event was never delivered. |
| `timestamp` | Business time of this state, RFC3339 seconds in UTC (`...Z`); not a sorting key. |

The `reason` closed set is `merchant_cancelled`, `duplicate`, `invalid_order`, `fraud`, `returned`, `reversed`, or `refund`; operator free text is never sent. Conversion-only fields (`event_name`, `aon_click_id`) are forbidden in earning bodies.

Use `(application_id, event_id)` for deduplication. Track the largest `sequence` per `earning_id` for the latest state; delivery can be duplicated or arrive out of order, and timestamps need not increase. Do not add the amounts of these snapshots together. Amounts can differ between events because of review adjustments or USD rounding (even by one cent); use the amount of the earning event with the largest sequence. If an adjustment and rejection occur between two observations, the rejection may carry the amount before the adjustment. Earning status events still reflect existing cent-based financial records: `"5.000000"` represents the recorded 5 USD amount. The six-decimal wire format does not increase ledger or payout precision. Only conversions settled before the round-up change can carry sub-cent amounts; later earning snapshots follow the financial records. In rare cases a rejection is withdrawn and the earning continues.

Each event has an independent subscription. The first earning subscription establishes a baseline at the configuration transaction's commit time: existing states are not replayed. Reopening subscriptions after all four were off also establishes a baseline; changes while all were off are not backfilled. While any earning event is enabled, all earning status changes are observed, but only currently subscribed events are registered. Opening another switch does not rebuild that baseline. Disabling a switch stops new event registration; already registered events continue delivery. Skipped registrations may consume sequence numbers. A risk freeze emits no earning event. Available may return to confirmed; unsubscribed intermediate states are not guaranteed to arrive.

### Crediting users by sub\_id

If you credit your own end users by `sub_id`, follow these rules:

1.  The amounts on events for the same earning are snapshots, not increments. Never add them together; use the amount of the event with the largest `sequence` for that `earning_id`.
2.  Deduplicate by `(application_id, event_id)`.
3.  Key credits by `earning_id` and record the amount already credited to the user for that earning. On `earning_rejected`, reverse only the amount already credited. A rejection can arrive for an earning you never credited, or whose conversion event you never received; then reverse nothing.
4.  A rejection can be withdrawn: if a later event with a larger `sequence` returns the earning to a non-rejected state, credit it again.
5.  When the conversion carried no `sub_id`, the `sub_id` fields are absent from its earning events.
6.  The `sub_id` slots are only populated when you pass `sub_id` values on the click link (see [Attribution with sub\_id](https://docs.aon.pro/guides/offer-wall#attribution-with-sub_id)).

### Using offer\_instance\_id

`offer_instance_id` identifies one delivery of an Offer — a Query or MCP result, or an offer wall. It is optional, always a string when present, and omitted — never `null` — when AON has no value. Treat it as an opaque string; do not assume a UUID format.

-   **Same value as your Query.** It equals the `offer_instance_id` returned in the Query or MCP response for that Offer, and the value of the `aon_tracking_id` link macro.
-   **Not the same as `aon_click_id`.** `aon_click_id` identifies one click; `offer_instance_id` identifies one delivery. One delivery can be clicked several times, so several clicks and several conversions can share one `offer_instance_id`.
-   **Not unique and not an idempotency key.** Multiple conversions (for example several goals of one Offer) and their earning events carry the same value. Deduplicate by `(application_id, event_id)`.
-   **Offer walls: AON generates it.** On a static offer wall AON generates the value when the user clicks; on a hosted offer wall it is generated each time the wall loads. You never hold it in advance, so credit users by `sub_id`.
-   **Third-party MCP hosts.** When your traffic reaches AON through a third-party MCP host, the Query result lives in the model's context and your server may never have seen the value.

### Unknown fields

Receivers must ignore any top-level field they do not recognize for the event's `event_type`, including a field defined only for another event type, and must not reject the event for it. AON may add optional fields to any event type within Protocol v1.0 without changing `AON-Protocol-Version`. The signature and idempotency always use the original raw bytes.

The published JSON Schema describes exactly what AON sends; do not use it unmodified to reject events. If you validate with the Schema, first reduce the body:

1.  Read `event_type` and select the Schema branch whose `event_type` `const` equals it. An unknown `event_type` is handled by your event-type rule, not by this one.
2.  Remove every top-level field that is not a key of that branch's `properties`.
3.  Validate the reduced object against that branch.

## SDK receivers

Upgrade to `@agentoffernetwork/sdk` 1.1.0 or later — earlier versions reject bodies that carry `offer_instance_id`. Version 1.1.0 exposes it as `offerInstanceId` and ignores fields it does not recognize.

Upgrade your SDK to a release that supports all five event types before enabling earning subscriptions. The existing functions `verify_agent_conversion_webhook` and `verifyAgentConversionWebhook` accept all five events; they still verify the raw body before parsing. Their result now contains a union payload. Narrow by `event_type` (Python) or `eventType` (TypeScript) before using event-specific fields.

```ts
const verified = await verifyAgentConversionWebhook(input)
if (verified.payload.eventType === "conversion") {
  recordConversion(verified.payload.eventName)
} else {
  recordEarning(verified.payload.earningId, verified.payload.sequence)
}
```

SDK verification does not store deduplication keys or reorder events for you.

## Idempotency

Use `(application_id, event_id)` as the durable idempotency key and retain the SHA-256 digest of the exact raw body for at least 24 hours.

-   First valid key and body: apply the business effect once and store the digest in the same transaction.
-   Same key and same digest: return 2xx without another business effect.
-   Same key and different digest: return HTTP 409 and do not process it.

An in-memory cache or SDK signature verifier does not provide this durable transaction.

## Five delivery attempts

Any 2xx response completes delivery. AON does not follow redirects and ignores the response body. A timeout, 3xx, or other non-2xx response follows one fixed five-attempt ladder:

| Attempt | Delay after the previous attempt |
| --- | --- |
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |

There is no sixth attempt. Across retries, `event_id` and the exact raw body stay frozen. Each attempt gets a fresh timestamp and matching signature.

## Frozen and current facts

Configuration changes affect different parts of the delivery lifecycle:

-   **Event-time opt-in is frozen.** A conversion records whether it was eligible when the event was accepted. Disabling the current subscription does not cancel an already eligible conversion.
-   **Target URL is frozen at outbox creation.** Changing the endpoint affects future outbox entries, not the target already stored on a queued entry.
-   **Wire version and raw body are frozen at outbox creation.** A v1.0 row stays v1.0 across every attempt and is never downgraded as a fallback.
-   **Signing secret and key version are current per attempt.** The worker reads the application's current callback secret and key version when it attempts delivery. A rotation can therefore change `X-AON-Key` and the signature for an already queued entry while its target and body remain unchanged.

Keep the current and previous key generations available during your receiver's rotation window. Unknown or expired key ids must fail closed; do not try every secret as a fallback.

## Configuration is not delivery health

A saved endpoint, enabled subscription, configured secret, or successful Portal response is a configuration fact. It is not proof that a worker is running or that a delivery reached your receiver. Runtime support is closed by deployment observation and a signed callback canary, not by this static page.

## Related pages

-   [Read the Reporting v2 contract](https://docs.aon.pro/api/reporting)
-   [Run the query-only Public Test Pair](https://docs.aon.pro/quickstart/public-test-sandbox)
-   [Browse the canonical Protocol v1.0 source guide](https://docs.aon.pro/protocol)
