> ## Documentation Index
> Fetch the complete documentation index at: https://agenticadvertisingorg-snap-format-preview-links.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Push Notifications

> AdCP push notifications: how sellers deliver async task status updates to your webhook endpoint via RFC 9421–signed POST requests (with legacy HMAC fallback). Setup, URL templates, and idempotency.

Push notifications let sellers deliver task status updates to you directly, instead of requiring you to poll. You provide a webhook URL in the task request; the seller POSTs status changes to that URL as the task progresses.

## How it works

1. A unique operation ID is generated per task invocation for webhook correlation
2. A webhook URL is built for your receiver. The URL may contain your own routing token, but it is opaque to the seller
3. `push_notification_config` is injected into the task request body with the URL and explicit `operation_id` — no shared secret required
4. The seller POSTs webhook notifications to your URL as the task status changes, signing each POST with its `adcp_use: "request-signing"` key published in its own brand.json `agents[]` entry; deprecated `webhook-signing` keys remain accepted during the compatibility window
5. You verify the signature against the seller's published JWKS and dedupe by `idempotency_key`
6. Each notification echoes the explicit `operation_id` back in the payload so you can correlate it without parsing the URL

```
create_media_buy request
  └── push_notification_config
        └── url: "https://you.com/adcp/webhook/create_media_buy/agent_123/route_abc123"
        └── operation_id: "op_456"
              // No shared secret — the seller signs with its own key, you verify against
              // its published JWKS. See "Signature verification" below.

              ↓ seller processes task ↓

POST https://you.com/adcp/webhook/create_media_buy/agent_123/route_abc123
  Signature-Input: sig1=("@method" "@target-uri" "@authority" "content-type" "content-digest");
                   created=1706097600;expires=1706097900;nonce="...";keyid="seller-webhook-2025";
                   alg="ed25519";tag="adcp/webhook-signing/v1"
  Signature: sig1=:<base64url-unpadded>:
  Content-Digest: sha-256=:<base64url-unpadded>:
  Content-Type: application/json

  {
    "idempotency_key": "whk_01HW9D3H8FZP2N6R8T0V4X6Z9B",   ← dedup by this
    "task_id": "task_456",
    "operation_id": "op_456",   ← echoed from push_notification_config.operation_id
    "status": "completed",
    "result": { ... }
  }
```

If you're using the `@adcp/sdk` library, this entire flow is handled automatically. As a **buyer**, configure `webhookUrlTemplate` and your agent URL on the client; `push_notification_config` is injected into every outgoing task call, and incoming webhooks are verified against the seller's JWKS automatically. As a **seller emitting webhooks**, publish a signing JWK at your brand.json `agents[]` entry. New signers use `adcp_use: "request-signing"`; if you want webhook-only key material, publish a second `request-signing` JWK with a distinct `kid`.

:::warning Legacy HMAC fallback (deprecated)
Buyers integrating with receivers that have not yet adopted the RFC 9421 webhook profile MAY opt into the legacy HMAC-SHA256 scheme by populating `push_notification_config.authentication.credentials`. That path is deprecated and removed in AdCP 4.0 — see [Legacy HMAC-SHA256 fallback](#legacy-hmac-sha256-fallback-deprecated) below. Because the inbound request that registers the webhook is typically not 9421-signed in 3.0, the `authentication` block is susceptible to on-path strip/inject — see [Downgrade and injection resistance](/docs/building/by-layer/L1/security#webhook-callbacks) for the operational mitigations.
:::

## Naming: snake\_case vs camelCase

This trips people up. There are two naming conventions in play:

| Context                            | Field name                 | Example                                                                      |
| ---------------------------------- | -------------------------- | ---------------------------------------------------------------------------- |
| **MCP task arguments** (AdCP JSON) | `push_notification_config` | `{ push_notification_config: { url: ..., operation_id: ... } }`              |
| **A2A configuration object**       | `pushNotificationConfig`   | `configuration: { pushNotificationConfig: { url: ..., operation_id: ... } }` |

The AdCP field name is always **`push_notification_config`** (snake\_case). It goes in the task request body alongside your other task parameters.

For A2A, the A2A protocol wraps it in a `configuration` envelope using camelCase — but the object's contents are identical.

## Adding push\_notification\_config to a request

### MCP

Include `push_notification_config` as a task argument, merged with the rest of your task parameters:

```json theme={null}
{
  "brand": { "brand_id": "acme" },
  "start_time": { "type": "date", "date": "2025-03-01" },
  "end_time": "2025-06-30T23:59:59Z",
  "packages": [...],
  "push_notification_config": {
    "url": "https://you.com/webhooks/adcp/create_media_buy/route_abc123",
    "operation_id": "op_abc123"
  }
}
```

`authentication` is omitted in the default case — the seller signs with its own `adcp_use: "request-signing"` key. Deprecated `webhook-signing` keys remain accepted during the compatibility window. Include `authentication.credentials` only if you need the legacy HMAC-SHA256 fallback.

### A2A

For A2A, skill parameters stay in `message.parts[].data.parameters`. The push notification config goes in the top-level `configuration` object:

```json theme={null}
{
  "message": {
    "parts": [{
      "kind": "data",
      "data": {
        "skill": "create_media_buy",
        "parameters": {
          "packages": [...]
        }
      }
    }]
  },
  "configuration": {
    "pushNotificationConfig": {
      "url": "https://you.com/webhooks/adcp/create_media_buy/route_abc123",
      "operation_id": "op_abc123"
    }
  }
}
```

## Operation IDs and URL templates

Operation IDs let you correlate incoming webhooks to the right task invocation. The pattern:

1. Buyer generates a unique operation ID per task call
2. Buyer threads it through to the seller as `push_notification_config.operation_id`. The webhook URL structure is the buyer's choice and is **opaque to the seller**
3. Seller echoes `operation_id` verbatim in every webhook payload — no URL parsing needed

**Normative wire contract:**

* **Buyers SHOULD** supply `operation_id` to the seller for every webhook registration and generate a unique value per task invocation (UUID recommended). Sellers MAY reject a webhook registration that omits `operation_id` with `INVALID_REQUEST`.
* **Sellers MUST** echo the buyer-supplied `operation_id` value in every webhook payload exactly as received. The payload field is the **only** source of truth for correlation.
* **Sellers MUST NOT** derive `operation_id` by parsing `push_notification_config.url` — the URL structure (path template, query parameters, opaque token, etc.) is implementation-defined from the seller's point of view and cannot be reliably reversed across implementations. A buyer's URL convention is not part of the protocol.
* **Receivers MAY** dispatch HTTP endpoints by URL path or query string, but **MUST NOT** use URL-derived values as the operation correlation key. The wire-level correlation identifier is the payload field.

This matches the precedent set by every comparable async-notification protocol in ad tech (OpenRTB `nurl`/`burl`, VAST tracking pixels, A2A `PushNotificationConfig`): the entity firing the HTTP call never parses the receiver's URL for correlation data.

**URL template pattern (buyer-side convention only):**

```
https://you.com/webhooks/{task_type}/{agent_id}/{route_token}
```

The template above is a useful **server-side routing aid for the buyer** — it lets a buyer's HTTP server dispatch on path segments without first parsing the body — but it is not normative and sellers cannot rely on it. A buyer who prefers `?route=…`, a flat path, or an entirely opaque token is fully conformant as long as the seller-side `operation_id` is supplied through the SDK's send-side API.

**Example (client library handles this automatically):**

```typescript theme={null}
import { randomUUID } from 'crypto';

const operationId = randomUUID(); // e.g. "cd51e063-2b79-4a6d-afac-ed7789c3a443"
const routeId = randomUUID(); // receiver-local routing token; opaque to the seller
const webhookUrl = `https://you.com/adcp/webhook/create_media_buy/${agentId}/${routeId}`;

// pass both webhookUrl and operationId in push_notification_config
```

The seller's webhook payload will include `"operation_id": "cd51e063-2b79-4a6d-afac-ed7789c3a443"`, so your handler can correlate to the right pending operation by reading the payload field directly. URL paths can select an HTTP handler, but not the protocol operation ID.

**Seller-SDK implementations** surface `operation_id` as an explicit parameter on the send-side webhook API (e.g., Python `WebhookSender.send_mcp(url=…, operation_id=…)`). The seller's application code threads the value through from the original task request to the webhook fire; the SDK never attempts to recover it from the URL.

### Echoing the caller's `context` object

When the originating request carried a top-level `context` object, the seller MUST echo that same object verbatim in every webhook payload for the same operation, alongside `operation_id`. This is the same contract that applies to synchronous and async-status responses — see [Context and sessions — Normative echo contract](/docs/building/by-layer/L2/context-sessions#normative-echo-contract). The echo MUST carry through `working`, `input-required`, `completed`, `failed`, and `canceled` deliveries; dropping `context` between the initial response and a later webhook breaks buyer-side correlation exactly where it's needed most. Buyers routing by `context.trace_id` or `context.internal_campaign_id` rely on verbatim echo on every delivery.

## When webhooks fire

Webhooks are sent for each status change after the initial response, as long as `push_notification_config` is in the request.

If the task completes synchronously (initial response is already terminal, such as `completed`, `failed`, or `rejected`), no webhook is sent — you already have the result.

Only operations whose initial response is non-terminal (`working` or `submitted`) may later emit AdCP task webhooks. For an inline terminal response, sellers MUST NOT synthesize a task webhook by inventing a `task_id` or replaying the inline result to `push_notification_config.url`; sync-only operations, and sync-only operation modes where `push_notification_config` is not meaningful, MAY reject that field as a well-formed runtime error instead. This is a seller-to-buyer wire rule: buyer SDKs MAY normalize synchronous responses into local callbacks, promises, or handler invocations, but those local SDK conveniences are not AdCP webhooks. If a future AdCP version adds a buyer-signaled sync-completion notification mode, it will be explicit and capability-advertised; the 3.x task webhook contract does not define one.

**Status changes that trigger webhooks:**

| Status           | Meaning                                        |
| ---------------- | ---------------------------------------------- |
| `working`        | Task is processing — may include progress info |
| `input-required` | Waiting for human approval or clarification    |
| `completed`      | Final result available                         |
| `failed`         | Task failed with error details                 |
| `canceled`       | Task was canceled                              |

## Webhook payload formats

### MCP

```json theme={null}
{
  "idempotency_key": "whk_01HW9D3H8FZP2N6R8T0V4X6Z9B",
  "task_id": "task_456",
  "operation_id": "cd51e063-2b79-4a6d-afac-ed7789c3a443",
  "task_type": "create_media_buy",
  "domain": "media-buy",
  "status": "completed",
  "timestamp": "2025-01-22T10:30:00Z",
  "message": "Media buy created successfully",
  "result": {
    "media_buy_id": "mb_12345",
    "packages": [
      { "package_id": "pkg_001", "context": { "line_item": "li_ctv_sports" } }
    ]
  }
}
```

Every webhook payload carries a required `idempotency_key` — a sender-generated key that is stable across retries of the same event. This is the canonical dedup field; see [Reliability](#reliability) below.

### Payload anatomy: envelope vs. result

Webhook receivers must distinguish the **wire envelope** from the task-specific **result**. The full MCP webhook envelope is the JSON object sent as the HTTP POST body. Delivery-report content lives under `result`; it is not valid as the top-level POST body by itself.

For a delivery reporting fire, the full wire payload looks like:

```json theme={null}
{
  "idempotency_key": "whk_20260526_example_000031",
  "operation_id": "delivery_report_67_2026_04",
  "task_id": "delivery_report_67_2026_04_000031",
  "task_type": "media_buy_delivery",
  "status": "completed",
  "timestamp": "2026-05-26T09:00:44.582Z",
  "message": "Scheduled media buy delivery report available",
  "result": {
    "notification_type": "scheduled",
    "sequence_number": 31,
    "reporting_period": {
      "start": "2026-05-25T00:00:00Z",
      "end": "2026-05-25T23:59:00Z"
    },
    "currency": "USD",
    "media_buy_deliveries": [
      {
        "media_buy_id": "mb_001",
        "status": "active",
        "totals": {
          "impressions": 125000,
          "spend": 5625.0,
          "clicks": 250
        },
        "by_package": []
      }
    ]
  }
}
```

This inner result object is valid delivery-report content, but it is **not valid as the top-level webhook POST body**:

```json theme={null}
{
  "notification_type": "scheduled",
  "sequence_number": 31,
  "reporting_period": {
    "start": "2026-05-25T00:00:00Z",
    "end": "2026-05-25T23:59:00Z"
  },
  "currency": "USD",
  "media_buy_deliveries": []
}
```

The top-level `status` is the async webhook status (`completed`, `failed`, `working`, and so on). The nested `media_buy_deliveries[].status` is the media buy lifecycle or reporting state (`active`, `paused`, `reporting_delayed`, and so on). Do not collapse the two fields.

For delivery-report data events such as `scheduled`, `final`, `delayed`, and `adjusted`, `notification_id` is absent by design; dedupe the transport event with `idempotency_key`. The `aggregated_totals` field is API-only for `get_media_buy_delivery` responses and must not be emitted in reporting webhook result payloads.

Signatures and content digests are computed over the exact raw JSON bytes sent as this full envelope. Re-serializing just the `result` object, adding whitespace, or changing field order before verification changes the bytes and breaks signature validation.

### A2A

A2A sends a `Task` object (for final states) or `TaskStatusUpdateEvent` (for progress). For final states (`completed`, `failed`), AdCP result data is in `.artifacts[0].parts[]`. For interim states (`working`, `input-required`), data is in `status.message.parts[]`.

```json theme={null}
{
  "id": "task_456",
  "contextId": "ctx_123",
  "status": {
    "state": "completed",
    "timestamp": "2025-01-22T10:30:00Z"
  },
  "artifacts": [{
    "artifactId": "result",
    "parts": [
      { "kind": "text", "text": "Media buy created successfully" },
      {
        "kind": "data",
        "data": {
          "media_buy_id": "mb_12345",
          "packages": [
            { "package_id": "pkg_001", "context": { "line_item": "li_ctv_sports" } }
          ]
        }
      }
    ]
  }]
}
```

### Protocol comparison

|                     | MCP                                       | A2A                                                                            |
| ------------------- | ----------------------------------------- | ------------------------------------------------------------------------------ |
| **Config field**    | `push_notification_config` (in task args) | `configuration.pushNotificationConfig` (separate from skill params)            |
| **Envelope**        | `mcp-webhook-payload.json`                | Native `Task` / `TaskStatusUpdateEvent`                                        |
| **Result location** | `result` field                            | `.artifacts[0].parts[].data` (final) / `status.message.parts[].data` (interim) |
| **Data schemas**    | Identical AdCP schemas                    | Identical AdCP schemas                                                         |

### Registration channel determines envelope shape

Webhook envelope shape is determined by **which registration mechanism the buyer used**, not by which transport the sync request was sent over:

| Registered via                                                                                                                                                                          | Delivered envelope                                             |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
| AdCP `push_notification_config` (task argument, MCP/A2A/REST)                                                                                                                           | [`mcp-webhook-payload.json`](#mcp)                             |
| A2A `TaskPushNotificationConfig` ([`CreateTaskPushNotificationConfig`](https://a2a-protocol.org/latest/specification/) RPC, or inline `task_push_notification_config` on `SendMessage`) | A2A native `Task` / `TaskStatusUpdateEvent` per A2A 1.0 §4.3.3 |

The two channels are independent. A buyer MAY register both for the same task and receive both webhooks per status change.

**Why this is the model, not "match inbound transport".** Each channel is purpose-built for its envelope: AdCP `push_notification_config` is the AdCP-layer registration for the AdCP `mcp-webhook-payload` shape; A2A `TaskPushNotificationConfig` is the A2A-layer registration for A2A's own `StreamResponse`-wrapped delivery. The buyer picks the channel that matches the receiver — there's no need for a discriminator field, and no ambiguity to override.

**Typical case: A2A sync, AdCP-shape webhooks.** A buyer orchestrating from an MCP-native runtime that uses A2A for one specific high-throughput sync operation puts `push_notification_config` in the AdCP task arguments inside its `SendMessage` body. The seller honors it as an AdCP-shape registration, regardless of A2A being the sync transport. The buyer's receiver gets the same `mcp-webhook-payload` shape it gets from every other AdCP webhook in its pipeline.

**A2A buyers wanting A2A-shape webhooks** register through A2A's native push notification mechanism; AdCP doesn't need to add anything for that case.

### Status-specific result data

| Status                 | `result` / `data` contains                            |
| ---------------------- | ----------------------------------------------------- |
| `completed` / `failed` | Full task response                                    |
| `working`              | Progress: `percentage`, `current_step`, `total_steps` |
| `input-required`       | Reason and any validation errors                      |
| `submitted`            | Minimal acknowledgment                                |

## Signature verification

Every AdCP 3.0 webhook is signed under the [RFC 9421 webhook profile](/docs/building/by-layer/L1/security#webhook-callbacks). The seller signs with its `adcp_use: "request-signing"` key published in its own brand.json `agents[]` entry; deprecated `webhook-signing` keys remain accepted during the compatibility window. You verify against the seller's published JWKS. No shared secret crosses the wire.

**Publisher sends three headers** (plus `Content-Type`):

```
Signature-Input: sig1=("@method" "@target-uri" "@authority" "content-type" "content-digest");
                 created=<unix>;expires=<unix>;nonce=<base64url>;
                 keyid=<kid>;alg="ed25519";tag="adcp/webhook-signing/v1"
Signature: sig1=:<base64url-unpadded>:
Content-Digest: sha-256=:<base64url-unpadded>:
```

Covered components are fixed: `@method`, `@target-uri`, `@authority`, `content-type`, `content-digest`. `content-digest` is REQUIRED — the body is the event; a signature that doesn't cover it isn't protecting the attack surface that matters.

**Verification** follows the 14-step [request verifier checklist](/docs/building/by-layer/L1/security#verifier-checklist-requests) with three webhook substitutions:

* Error codes use the `webhook_signature_*` prefix (see [Webhook error taxonomy](/docs/building/by-layer/L1/security#webhook-error-taxonomy)).
* `tag` MUST be `adcp/webhook-signing/v1`.
* Resolve `keyid` via the seller operator's `brand.json` `agents[].jwks_uri`, applying any publisher `adagents.json` `signing_keys[]` pin when present (you already have the seller's agent URL from your integration).

**Receiver implementation sketch:**

```typescript theme={null}
import { createRemoteJWKSet, jwtVerify } from 'jose';
// Use a validated RFC 9421 library (e.g., `http-message-signatures`) pinned to the AdCP profile.

app.post('/webhooks/adcp/*', async (req, res) => {
  try {
    // 1. Parse Signature-Input / Signature headers and reject on malformed.
    // 2. Resolve keyid against the seller operator's brand.json JWKS.
    // 3. Run the AdCP webhook verifier checklist (14 steps).
    await verifyAdcpWebhookSignature(req, {
      sellerAgentUrl: req.sellerContext.agentUrl, // known from your integration
      requiredTag: 'adcp/webhook-signing/v1',
      allowedAlgs: ['ed25519', 'ecdsa-p256-sha256'],
    });
  } catch (err) {
    return res.status(401)
      .setHeader('WWW-Authenticate', `Signature error="${err.code}"`)
      .end();
  }

  // 4. Dedup by idempotency_key before applying side effects (see Reliability below).
  processWebhook(req.body);
  res.status(200).end();
});
```

:::caution Raw body and content-digest
Your `Content-Digest` verification (step 11 of the checklist) requires the raw HTTP body bytes. Capture them before JSON parsing — any re-serialization will break the digest match.

In Express:

```typescript theme={null}
app.use(express.json({
  verify: (req, _res, buf) => { (req as any).rawBody = buf.toString('utf-8'); },
}));
```

:::

:::note Replay protection
The `created`/`expires`/`nonce` sig-params enforce a 5-minute max validity window and `(keyid, nonce)` replay dedup. See [Transport replay dedup](/docs/building/by-layer/L1/security#transport-replay-dedup) for the per-keyid cap and memory-bounding rules.
:::

### Legacy HMAC-SHA256 fallback (deprecated)

:::warning Deprecated — removed in AdCP 4.0
The HMAC-SHA256 scheme below is a compatibility affordance for 3.x only. New integrations SHOULD omit `push_notification_config.authentication` and use the [9421 webhook profile](#signature-verification) above. Sellers MAY decline to support the legacy scheme.
:::

Buyers can opt into HMAC-SHA256 by populating `push_notification_config.authentication.credentials`. When present, the seller signs with HMAC-SHA256 using a shared secret and includes a timestamp for replay protection.

**Configuration (legacy):**

```json theme={null}
{
  "authentication": {
    "schemes": ["HMAC-SHA256"],
    "credentials": "your_shared_secret_min_32_chars"
  }
}
```

**Publisher sends two headers (legacy):**

```
X-ADCP-Signature: sha256=<hex digest>
X-ADCP-Timestamp: <unix timestamp in seconds>
```

**Signature algorithm (legacy):**

The signed message is `{unix_timestamp}.{raw_json_body}` — the Unix timestamp (in seconds), a dot, then the exact JSON bytes being sent in the HTTP body.

```
Signature = sha256= + hex( HMAC-SHA256( secret, "{timestamp}.{rawBody}" ) )
```

The `rawBody` **must** be the exact bytes sent on the wire. When serializing a JSON payload to produce the body, use **compact separators** (`","` and `":"`, no surrounding whitespace) — this matches JavaScript `JSON.stringify` and most HTTP-client defaults, and is what the receiver sees as `raw_body`. The common cross-SDK failure here is a signer that calls a language default which inserts spaces (e.g., Python `json.dumps(payload)`) while the HTTP client writes compact bytes on the wire — the signer then signs over bytes the receiver never sees. Use `json.dumps(payload, separators=(",", ":"))` (or equivalent) for byte-equality. See [Webhook Security — legacy normative rules](/docs/building/by-layer/L1/security#legacy-hmac-sha256-fallback-deprecated-removed-in-40) for the full rules on canonical on-wire form and verifier input handling.

**Publisher implementation (legacy):**

```typescript theme={null}
import { createHmac } from 'crypto';

function signWebhook(rawBody: string, secret: string): { signature: string; timestamp: string } {
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const message = `${timestamp}.${rawBody}`;
  const hex = createHmac('sha256', secret).update(message).digest('hex');
  return { signature: `sha256=${hex}`, timestamp };
}
```

**Receiver implementation (legacy):**

```typescript theme={null}
import { createHmac, timingSafeEqual } from 'crypto';

function verifyWebhook(
  rawBody: string, signature: string, timestamp: string, secret: string,
): boolean {
  const ts = parseInt(timestamp, 10);
  if (isNaN(ts)) return false;
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - ts) > 300) return false;

  const message = `${ts}.${rawBody}`;
  const expected = `sha256=${createHmac('sha256', secret).update(message).digest('hex')}`;
  if (signature.length !== expected.length) return false;
  return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
```

Normative rules for the legacy scheme are in [Webhook Security](/docs/building/by-layer/L1/security#legacy-hmac-sha256-fallback-deprecated-removed-in-40).

### Legacy Bearer token (deprecated)

The A2A `authentication.schemes: ["Bearer"]` scheme is also supported for compatibility and removed in AdCP 4.0. Bearer provides no tamper protection on the body. The 9421 profile is stronger on signer identity (JWKS-anchored, rotatable, revocable) and key management (no shared secret on the wire); body-integrity protection is comparable to the legacy HMAC scheme since both cover the body bytes. Sellers SHOULD refuse Bearer for any mutating callback.

```json theme={null}
{
  "authentication": {
    "schemes": ["Bearer"],
    "credentials": "your_bearer_token_min_32_chars"
  }
}
```

```javascript theme={null}
app.post('/webhooks/adcp', (req, res) => {
  const token = req.headers.authorization?.replace('Bearer ', '');
  if (token !== process.env.ADCP_WEBHOOK_TOKEN) return res.status(401).end();
  processWebhook(req.body);
  res.status(200).end();
});
```

## Reliability

Webhooks use **at-least-once delivery** — you may receive the same event more than once, and events may arrive out of order.

### Dedup by `idempotency_key`

Every webhook payload — MCP task envelope, governance list-change webhooks (`collection_list_changed`, `property_list_changed`), artifact push webhooks, and rights `revocation-notification` — carries a required `idempotency_key`. Publishers generate this key once per distinct event and reuse it on every retry. Receivers MUST dedupe by it.

**Sender requirements:**

* The key MUST be cryptographically random (UUID v4 recommended). Sequential, timestamp-only, or otherwise predictable values are non-conformant: receivers dedupe on the raw value, so a predictable key lets an attacker pre-seed a receiver's cache to suppress a later legitimate event.
* The key MUST be stable across retries of the same event and MUST NOT be reused for a distinct event.

**Receiver requirements:**

* Dedup scope is `(authenticated sender identity, idempotency_key)`. "Authenticated sender identity" means the sender's cryptographic identity as established by signature verification — under the 9421 default, the resolved `keyid` → signer `agents[]` entry URL; under the legacy fallback, the credential binding from the verified HMAC secret or Bearer token. Never derive identity from a payload field. Keys from different senders MUST be kept in independent keyspaces; a receiver integrated with multiple sellers MUST NOT collapse them. During an HMAC→9421 migration, a receiver SHOULD map both sender-identity forms for the same logical seller to one keyspace so that a duplicate across schemes still dedupes.
* **Cross-endpoint dedup (MUST).** A receiver that exposes more than one webhook endpoint (per-integration, per-environment, per-tenant, or per-pod in a horizontally-scaled fleet) MUST share the `(sender identity, idempotency_key)` keyspace across every endpoint a given sender can reach — per-pod in-memory caches are non-conformant. Without a shared tier, the same signed event replayed to a sibling endpoint executes twice. See [Webhook replay dedup sizing](/docs/building/by-layer/L1/security#webhook-replay-dedup-sizing) for the transport-layer companion rule on `(keyid, nonce)` scoping.
* Dedup state MUST persist for at least 24h in durable storage that survives process restarts, pod replacements, and region failovers. Publishers SHOULD NOT retry beyond that window; retries arriving after the receiver's TTL will be reprocessed as fresh events. An in-memory-only cache (per-pod `Map` or LRU without a backing tier) is non-conformant — the asymmetry between the \~360 s signature-nonce window and the 24h idempotency window creates a **displaced-replay window** in which a legitimate signed retry (fresh nonce, same `idempotency_key`) passes signature verification and finds no cache entry because the receiver dropped in-memory state. Side effects run twice. Receivers whose cache tier cannot durably honor 24h MUST document the shorter effective window to every sender they integrate with — silent shortening is the unsafe mode.
* Receivers SHOULD bound dedup cache size per sender and return `429 Too Many Requests` (or drop the connection) rather than grow unbounded — a misbehaving or hostile seller emitting high-volume fresh keys is otherwise a storage-amplification vector.
* **Duplicates MUST be answered with `2xx`** (typically `200 OK`), not `409 Conflict`. At-least-once senders interpret any non-2xx response as "delivery failed" and retry with exponential back-off; returning `4xx` on a successfully-deduped event turns correct receiver behavior into a retry storm. A duplicate is a no-op, not an error.
* Webhook receivers do **not** verify payload equivalence across key reuse. If a sender reuses a key with a changed payload (a sender bug), the receiver's cached first copy wins and the second is silently deduped. This differs from the request-side `IDEMPOTENCY_CONFLICT` behavior — senders are solely responsible for generating a fresh key on every distinct event.

```javascript theme={null}
app.post('/webhooks/adcp', async (req, res) => {
  const payload = req.body;
  const { idempotency_key, task_id, status, timestamp, result } = payload;

  // Scope dedup to the authenticated sender — never trust a payload field for identity.
  const sender = req.verifiedSenderId; // set by 9421 verifier (keyid → agent URL) or legacy HMAC/Bearer middleware

  // Dedup: same (sender, idempotency_key) within the replay window → already processed.
  // Return 200 (not 409) so the sender stops retrying.
  if (await db.webhookAlreadyProcessed(sender, idempotency_key)) {
    return res.status(200).end();
  }
  await db.markWebhookProcessed(sender, idempotency_key); // before side effects — fail-closed on crash

  // Ordering: separately, don't apply a stale status on top of a newer one.
  // Ordering state is keyed on task_id, not idempotency_key — two distinct events
  // (different keys) can still arrive out of order. Still a 200: we received it cleanly.
  const task = await db.getTask(task_id);
  if (task?.updated_at >= timestamp) {
    return res.status(200).end();
  }

  await db.updateTask(task_id, { status, updated_at: timestamp, result });
  await triggerBusinessLogic(task_id, status);
  res.status(200).end();
});
```

**Always implement polling as backup.** Webhooks can fail due to network issues or server downtime. Use a slower poll interval when webhooks are configured (e.g., every 2 minutes instead of 30 seconds), and stop polling once you receive a terminal status via webhook.

### Diagnosing missing fires

When a buyer suspects a webhook isn't reaching its endpoint — gateway 5xx, stale-sequence dedup, drifted webhook URL, suppressed fires under a tripped circuit breaker — call [`get_media_buys`](/docs/media-buy/task-reference/get_media_buys#webhook-activity) with `include_webhook_activity: true`. Each returned media buy will carry a `webhook_activity` array of recent fires for the calling principal, including `idempotency_key` (matches the payload's dedup key — correlate against your own endpoint log), `status` (`success` / `failed` / `timeout` / `connection_error` / `pending`), `http_status_code`, `attempt`, and `error_message`. The scope is the calling principal's own fires; no operator ticket required.

## Best practices

1. **Always implement polling as backup** — webhooks can fail; poll at a reduced interval (e.g. every 2 minutes) when webhooks are configured, and stop once you receive a terminal status
2. **Dedupe by `idempotency_key`** — every payload carries a required key stable across retries; track processed keys for at least 24h
3. **Return 2xx on duplicates** — a successfully-deduped event is a no-op, not an error; returning non-2xx triggers the sender's retry back-off and creates retry storms
4. **Verify signatures before processing** — run the 9421 webhook verifier checklist (or the legacy HMAC check if you opted in) before any side effects
5. **Acknowledge immediately** — return `200` before doing any heavy processing to avoid seller timeouts and unnecessary retries
6. **Don't rely on URL structure** — use `operation_id` from the payload for business correlation; URL paths may only demux endpoints
7. **Plan for HMAC removal in 4.0** — if you're currently on the legacy HMAC fallback, migrate to the 9421 webhook profile during 3.x

## Payload extraction

Webhook receivers need to detect the format and extract AdCP data. The buyer typically knows the format because it configured the transport, but defensive detection is useful for multi-format receivers.

### Format detection

| Signal                                  | Format |
| --------------------------------------- | ------ |
| `status` is a string, `task_id` present | MCP    |
| `status` is an object with `.state`     | A2A    |

### Extraction

**MCP webhooks:** Extract data from the `result` field directly.

**A2A webhooks:** Use the [A2A response extraction](/docs/building/by-layer/L0/a2a-response-extraction) algorithm — final states extract from `.artifacts[0].parts[]` (last DataPart), interim states from `status.message.parts[]` (first DataPart).

```javascript theme={null}
function extractAdcpResponseFromWebhook(payload, knownFormat) {
  const format = knownFormat || detectFormat(payload);

  if (format === 'mcp') return payload.result ?? null;
  if (format === 'a2a') return extractAdcpResponseFromA2A(payload);
  return null;
}

function detectFormat(payload) {
  if (payload.status && typeof payload.status === 'object'
      && !Array.isArray(payload.status) && payload.status.state) return 'a2a';
  if (typeof payload.status === 'string' && payload.task_id) return 'mcp';
  return null;
}
```

### Security requirements

* **Content-Type validation**: Senders MUST send `application/json`. Receivers MUST reject other types before signature verification.
* **Payload size limit**: Receivers SHOULD enforce a 1MB limit. Reject before signature verification — computing a digest or HMAC over large payloads is a DoS vector. Return `413 Payload Too Large`.
* **Deduplication**: `idempotency_key` is the canonical dedup field. Signature verification (9421 or legacy HMAC) plus replay dedup protect the transport; `idempotency_key` protects against duplicate side effects at the application layer.
* **Format detection**: Auto-detection is a defensive fallback. Receivers SHOULD use the known format from their transport configuration (`knownFormat` parameter) rather than relying solely on payload inspection. A compromised intermediary could craft an ambiguous payload that routes extraction to the wrong path.

### Test vectors

Machine-readable test vectors are available at [`/static/test-vectors/webhook-payload-extraction.json`](https://adcontextprotocol.org/test-vectors/webhook-payload-extraction.json). Client libraries SHOULD validate their format detection and extraction logic against these vectors.

## Reporting webhooks

Reporting webhooks are separate from task status webhooks. They deliver periodic performance data for active media buys and are configured via `reporting_webhook` in `create_media_buy`, not via `push_notification_config`.

See [Task Reference](/docs/media-buy/task-reference) for details on `reporting_webhook`.

## Persistent channel contract

Task webhooks fire once per logical task and stop when the task settles. **Persistent webhooks** — `reporting_webhook` and `push_notification_config` on a media buy — outlive any single operation and fire repeatedly for the life of the resource. The contract below applies to persistent channels.

This section is the transport half of the [Snapshot and log contract](/docs/protocol/snapshot-and-log). For the read-side rules (snapshot is authoritative, replay = re-read), see that page.

### Delivery semantics

* **At-least-once delivery.** Sellers MAY re-fire the same logical event under retry. Receivers MUST dedupe transport retries by `idempotency_key`. For state-shaped events that also carry a typed `notification_id` (see [`mcp-webhook-payload.json`](https://adcontextprotocol.org/schemas/v3/core/mcp-webhook-payload.json) and [snapshot-and-log Rule 1](/docs/protocol/snapshot-and-log#1-two-distinct-ids-per-fire-and-per-state)), receivers MUST also track `notification_id` to correlate fires to current snapshot state — seeing the same `notification_id` under two different `idempotency_key` values is a re-emission signal, not a transport retry.
* **No ordering guarantee.** Two events on the same resource within seconds MAY arrive out of order. Receivers MUST reconcile via the resource snapshot rather than treating webhook ordering as canonical.
* **Idempotent application.** Apply the same payload twice and the resulting receiver state MUST be identical.

### Coalescence

For state-shaped event types, sellers SHOULD coalesce multiple near-simultaneous changes on the same resource into a single push. **Coalescence windows are per event type and not a flat ceiling** — a latency-sensitive event (fraud, brand safety) cannot wait the same window an advisory can.

| Event type                       | Default coalescence window    | Notes                                                                                                                                                |
| -------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `impairment` (general)           | 5 minutes (SHOULD NOT exceed) | Default for resource-state impairments — audience suspended, creative revoked, etc.                                                                  |
| `impairment` (latency-sensitive) | Sub-minute / no coalescence   | Fraud-driven, brand-safety-driven, or other classes where the buyer's response window is short. Sellers MUST NOT apply the general default to these. |
| Future advisory events           | Hours to daily                | Higher noise tolerance; bigger window appropriate.                                                                                                   |
| Future defect events             | Minutes to hours              | Between impairment and advisory in urgency.                                                                                                          |

Sellers MAY declare a shorter coalescence window via `get_agent_capabilities` for receivers that need sub-default latency. Sellers MUST NOT exceed the per-type default without explicit buyer opt-in declared on the receiver side. Delivery report fires (`scheduled`, `final`) follow their own cadence and are not subject to this coalescence rule.

### Replay and recovery

If a buyer's receiver was offline and missed a fire, recovery is **read the snapshot**. Two paths exist for every persistent channel and they're at parity in content:

* Missed `impairment` event → call `get_media_buys` and read `impairments[]` (full state recovery).
* Missed delivery report fire → call `get_media_buy_delivery` for the window in question with `time_granularity` set to the granularity the seller declared in `reporting_capabilities.windowed_pull_granularities` (#4590). The pull returns the same per-window slices the webhook delivered. Sellers that have not yet declared a windowed granularity return date-range aggregates and daily breakdowns only and cannot reconstruct sub-daily fires.
* Missed any other state-shaped event → call the corresponding `get_*` task.

AdCP does not commit to an event-replay primitive at the transport layer. The webhook delivery visibility surface (`webhook_activity[]` on `get_media_buys`, proposed in [#4278](https://github.com/adcontextprotocol/adcp/issues/4278)) exposes recent fires within a retention window for **debugging** — buyers use it to verify that the seller fired and what HTTP status the receiver returned. It is not a data recovery channel; that's what the snapshot's per-window pull (#4590) is for.

### Mutability and rotation

`push_notification_config` and `reporting_webhook` on a media buy MAY be updated via `update_media_buy` without re-creating the buy. Common reasons: rotating the receiver URL, replacing an expired bearer token, swapping signing keys.

Sellers MUST honor the updated config on the next fire after the update is acknowledged. There is no formal handoff window — buyers MAY receive a small number of fires against the prior URL during the propagation window and SHOULD treat both URLs as live until the prior URL has been quiet for a coalescence window.

### Auth renewal

Persistent webhooks outlive bearer tokens. Receivers using bearer auth (legacy HMAC profile or token-based mTLS) SHOULD rotate tokens via `update_media_buy` before expiry. Receivers using the 9421 signing profile do not need token rotation — verification is against the seller's published JWKS, which the seller rotates independently.

If a seller's fire receives a 401 from the receiver, the seller SHOULD treat this as a transient receiver-side configuration error: retry per the standard schedule, surface the failure in `webhook_activity[]` for debugging, and do not auto-disable the webhook.

### Termination

Persistent webhooks fire through the buy's terminal lifecycle moves:

* `final` delivery report fires after the buy reaches `completed`, `canceled`, or `rejected`.
* Any pending `impairment` events fire (or are coalesced and fired) before termination if the seller has them queued.
* After the final fire, no further events fire against the configured URLs. Sellers MAY retain `webhook_activity[]` for the retention window after termination so buyers can audit the closing sequence.

## Next steps

* [Task Lifecycle](/docs/building/by-layer/L3/task-lifecycle) — status values and transitions
* [Async Operations](/docs/building/by-layer/L3/async-operations) — handling long-running tasks
* [Error Handling](/docs/building/by-layer/L3/error-handling) — webhook error patterns
