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

How it works

  1. A unique operation ID is generated per task invocation
  2. A webhook URL is built by substituting that ID (and other routing params) into a URL template
  3. push_notification_config is injected into the task request body with that URL and HMAC credentials
  4. The publisher POSTs webhook notifications to your URL as the task status changes
  5. Each notification echoes 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/cd51e063-2b79-4a6d-afac-ed7789c3a443"
        └── authentication: { schemes: ["HMAC-SHA256"], credentials: "..." }

              ↓ publisher processes task ↓

POST https://you.com/adcp/webhook/create_media_buy/agent_123/cd51e063-2b79-4a6d-afac-ed7789c3a443
  {
    "task_id": "task_456",
    "operation_id": "cd51e063-2b79-4a6d-afac-ed7789c3a443",   ← echoed from your URL
    "status": "completed",
    "result": { ... }
  }
If you’re using the @adcp/client library, this entire flow is handled automatically — configure webhookUrlTemplate and webhookSecret once on the client and push_notification_config is injected into every outgoing task call.

Naming: snake_case vs camelCase

This trips people up. There are two naming conventions in play:
ContextField nameExample
MCP task arguments (AdCP JSON)push_notification_config{ push_notification_config: { url: ... } }
A2A configuration objectpushNotificationConfigconfiguration: { pushNotificationConfig: { url: ... } }
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:
{
  "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/op_abc123",
    "authentication": {
      "schemes": ["HMAC-SHA256"],
      "credentials": "your_shared_secret_min_32_chars"
    }
  }
}

A2A

For A2A, skill parameters stay in message.parts[].data.parameters. The push notification config goes in the top-level configuration object:
{
  "message": {
    "parts": [{
      "kind": "data",
      "data": {
        "skill": "create_media_buy",
        "parameters": {
          "packages": [...]
        }
      }
    }]
  },
  "configuration": {
    "pushNotificationConfig": {
      "url": "https://you.com/webhooks/adcp/create_media_buy/op_abc123",
      "authentication": {
        "schemes": ["HMAC-SHA256"],
        "credentials": "your_shared_secret_min_32_chars"
      }
    }
  }
}

Operation IDs and URL templates

Operation IDs let you route incoming webhooks to the right handler. The typical pattern:
  1. Generate a unique ID per task call
  2. Embed it in the webhook URL path
  3. The publisher echoes operation_id in the payload — no URL parsing needed
URL template pattern:
https://you.com/webhooks/{task_type}/{agent_id}/{operation_id}
Example (client library handles this automatically):
import { randomUUID } from 'crypto';

const operationId = randomUUID(); // e.g. "cd51e063-2b79-4a6d-afac-ed7789c3a443"
const webhookUrl = `https://you.com/adcp/webhook/create_media_buy/${agentId}/${operationId}`;

// pass webhookUrl in push_notification_config.url
The publisher’s webhook payload will include "operation_id": "cd51e063-2b79-4a6d-afac-ed7789c3a443", so your handler can route to the right pending operation without parsing the URL.

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 completed or failed), no webhook is sent — you already have the result. Status changes that trigger webhooks:
StatusMeaning
workingTask is processing — may include progress info
input-requiredWaiting for human approval or clarification
completedFinal result available
failedTask failed with error details
canceledTask was canceled

Webhook payload formats

MCP

{
  "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" } }
    ]
  }
}

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[].
{
  "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

MCPA2A
Config fieldpush_notification_config (in task args)configuration.pushNotificationConfig (separate from skill params)
Envelopemcp-webhook-payload.jsonNative Task / TaskStatusUpdateEvent
Result locationresult field.artifacts[0].parts[].data (final) / status.message.parts[].data (interim)
Data schemasIdentical AdCP schemasIdentical AdCP schemas

Status-specific result data

Statusresult / data contains
completed / failedFull task response
workingProgress: percentage, current_step, total_steps
input-requiredReason and any validation errors
submittedMinimal acknowledgment

Authentication

Bearer token

Simple token auth — the publisher sends your token in the Authorization header. Configuration:
{
  "authentication": {
    "schemes": ["Bearer"],
    "credentials": "your_bearer_token_min_32_chars"
  }
}
Verifying on your server:
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();
});
The publisher signs each request with a shared secret and includes a timestamp for replay protection. You verify both the signature and the timestamp on receipt. Configuration:
{
  "authentication": {
    "schemes": ["HMAC-SHA256"],
    "credentials": "your_shared_secret_min_32_chars"
  }
}
Publisher sends two headers:
X-ADCP-Signature: sha256=<hex digest>
X-ADCP-Timestamp: <unix timestamp in seconds>
Signature algorithm: 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. Implementations must sign the same serialization that goes in the HTTP body — signing a re-serialized or reformatted version of the payload will cause verification failures. Publisher implementation (signing):
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 (verification):
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;

  // Reject requests older than 5 minutes (replay attack prevention)
  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));
}

app.post('/webhooks/adcp', (req, res) => {
  const sig = req.headers['x-adcp-signature'] as string;
  const timestamp = req.headers['x-adcp-timestamp'] as string;
  if (!sig || !timestamp || !verifyWebhook(req.rawBody, sig, timestamp, process.env.ADCP_WEBHOOK_SECRET)) {
    return res.status(401).end();
  }
  processWebhook(req.body);
  res.status(200).end();
});
:::caution Important: raw body verification Always verify the signature against the raw HTTP body bytes, not a re-serialized version of the parsed JSON. Different languages and libraries may produce different JSON serializations (key ordering, whitespace, number formatting). Capture the raw body before JSON parsing and use those exact bytes for HMAC verification. In Express, use the verify callback on express.json() to capture the raw body:
app.use(express.json({
  verify: (req, _res, buf) => {
    (req as any).rawBody = buf.toString('utf-8');
  },
}));
::: :::note Replay protection The 5-minute timestamp window prevents replay attacks. Publishers must use Unix timestamps in seconds (not ISO 8601) in the X-ADCP-Timestamp header. Receivers should reject requests where |current_time - timestamp| > 300 seconds. :::

Reliability

Webhooks use at-least-once delivery — you may receive the same event more than once, and events may arrive out of order. Use task_id + status + timestamp to handle this:
async function processWebhook(payload) {
  const { task_id, status, timestamp, result } = payload;

  const task = await db.getTask(task_id);

  // Skip if we've already processed a newer status
  if (task?.updated_at >= timestamp) return;

  await db.updateTask(task_id, { status, updated_at: timestamp, result });
  await triggerBusinessLogic(task_id, status);
}
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.

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. Handle duplicates — use task_id + timestamp to skip already-processed or out-of-order events
  3. Verify signatures — always validate X-ADCP-Signature before processing
  4. Acknowledge immediately — return 200 before doing any heavy processing to avoid publisher timeouts and unnecessary retries
  5. Don’t rely on URL structure — use operation_id from the payload for routing, not URL parsing
  6. Use HMAC-SHA256 in production — Bearer tokens are simpler but don’t protect against payload tampering

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

SignalFormat
status is a string, task_id presentMCP
status is an object with .stateA2A

Extraction

MCP webhooks: Extract data from the result field directly. A2A webhooks: Use the A2A response extraction algorithm — final states extract from .artifacts[0].parts[] (last DataPart), interim states from status.message.parts[] (first DataPart).
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: Publishers MUST send application/json. Receivers MUST reject other types before HMAC verification.
  • Payload size limit: Receivers SHOULD enforce a 1MB limit. Reject before HMAC computation — computing HMAC over large payloads is a DoS vector. Return 413 Payload Too Large.
  • Deduplication: task_id + timestamp dedup provides defense-in-depth against replay attacks within the timestamp window.
  • 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. 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 for details on reporting_webhook.

Next steps