Webhook Protocol v1.0
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 and schema 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:
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).
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:
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 |
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:
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:
- 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
sequencefor thatearning_id. - Deduplicate by
(application_id, event_id). - Key credits by
earning_idand record the amount already credited to the user for that earning. Onearning_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. - A rejection can be withdrawn: if a later event with a larger
sequencereturns the earning to a non-rejected state, credit it again. - When the conversion carried no
sub_id, thesub_idfields are absent from its earning events. - The
sub_idslots are only populated when you passsub_idvalues on the click link (see 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_idreturned in the Query or MCP response for that Offer, and the value of theaon_tracking_idlink macro. - Not the same as
aon_click_id.aon_click_ididentifies one click;offer_instance_ididentifies one delivery. One delivery can be clicked several times, so several clicks and several conversions can share oneoffer_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:
- Read
event_typeand select the Schema branch whoseevent_typeconstequals it. An unknownevent_typeis handled by your event-type rule, not by this one. - Remove every top-level field that is not a key of that branch's
properties. - 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.
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-Keyand 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.