Skip to content
 

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:

HeaderRequired value or responsibility
AON-Protocol-VersionExact value 1.0
X-AON-KeyOpaque callback key id selecting one current or grace secret
X-AON-TimestampCanonical unsigned Unix epoch seconds for this attempt
X-AON-SignatureLowercase-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
FieldRequiredMeaning
event_idYesUnique id for this event
event_typeYesAlways conversion
aon_click_idNoOpaque AON click id (begins with aci_) this conversion is attributed to
offer_instance_idNoString. Id of the Offer delivery (Query/MCP result or offer wall) behind this conversion; see Using offer_instance_id
offer_idYesThe Offer that converted
application_idYesYour Developer Application id
placement_idNoThe placement the traffic came through
event_nameYesThe Offer goal that was met
amountYesYour 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
currencyYesAlways USD
sub_id, sub_id_2 … sub_id_5NoYour attribution values from the original link
timestampYesConversion 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.

EventMeaning
earning_confirmedThe associated Partner statement has been confirmed.
earning_availableThe earning has become available for payout.
earning_paid_outAON 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_rejectedThe 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
FieldMeaning
event_idearning: followed by the immutable outbox UUID; unchanged on retry.
event_typeOne of the four earning event names above.
earning_idLowercase hyphenated settlement UUID, identical to the Earnings API id.
sequencePositive JSON integer increasing within this earning; gaps are allowed.
conversion_event_idThe originating conversion's event id, even if its webhook was never delivered.
offer_instance_idOptional 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_idThe Earnings API Offer identity; canonical resolution may differ from the conversion's Offer id. Join events using conversion_event_id.
application_idDeveloper Application id.
placement_idOptional placement identity.
amountNet 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.
currencyAlways USD.
reasonRequired only for earning_rejected; forbidden on the other earning branches.
sub_id, sub_id_2 … sub_id_5Optional. 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.
timestampBusiness 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).

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.

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:

AttemptDelay after the previous attempt
1Immediate
21 minute
35 minutes
430 minutes
52 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.