Skip to main content
Both MCP and A2A use HTTP webhooks for async task updates. Instead of polling, you provide a webhook URL and the server POSTs status changes to you directly.

Protocol Comparison

AspectMCPA2A
Spec StatusAdCP specifies thisNative protocol feature
ConfigurationpushNotificationConfigpushNotificationConfig
Envelopemcp-webhook-payload.jsonTask or TaskStatusUpdateEvent
Data Locationresult fieldstatus.message.parts[].data
Data SchemasIdentical AdCP schemasIdentical AdCP schemas

Configuring Webhooks

MCP Webhooks

MCP doesn’t define push notifications. AdCP fills this gap by specifying the webhook configuration (pushNotificationConfig) and payload format.
const response = await session.call('create_media_buy',
  { /* task params */ },
  {
    pushNotificationConfig: {
      url: "https://buyer.com/webhooks/adcp",
      authentication: {
        schemes: ["HMAC-SHA256"],
        credentials: "shared_secret_32_chars"
      }
    }
  }
);

A2A Webhooks

A2A defines push notifications natively:
await a2a.send({
  message: {
    parts: [{
      kind: "data",
      data: {
        skill: "create_media_buy",
        parameters: { /* task params */ }
      }
    }]
  },
  pushNotificationConfig: {
    url: "https://buyer.com/webhooks/a2a",
    authentication: {
      schemes: ["bearer"],
      credentials: "shared_secret_32_chars"
    }
  }
});

When Webhooks Are Called

Webhooks are triggered when all of the following are true:
  1. Task type supports async execution (e.g., get_products, create_media_buy, sync_creatives)
  2. pushNotificationConfig is provided in the request
  3. Task requires async processing — initial response is working or submitted
If the initial response is already terminal (completed, failed, rejected), no webhook is sent — the client already has the final result. Status changes that trigger webhooks:
  • working → Progress update
  • input-required → Human input needed
  • completed → Final result available
  • failed → Error details
  • canceled → Cancellation confirmed

Webhook Payload Formats

MCP Payload

POST /webhooks/adcp/create_media_buy/agent_123/op_456 HTTP/1.1
Host: buyer.example.com
Authorization: Bearer your-secret-token
Content-Type: application/json

{
  "task_id": "task_456",
  "task_type": "create_media_buy",
  "status": "completed",
  "timestamp": "2025-01-22T10:30:00Z",
  "message": "Media buy created successfully",
  "result": {
    "media_buy_id": "mb_12345",
    "buyer_ref": "nike_q1_campaign_2024",
    "creative_deadline": "2024-01-30T23:59:59Z",
    "packages": [
      { "package_id": "pkg_12345_001", "buyer_ref": "nike_ctv_sports_package" }
    ]
  }
}

A2A Payload

A2A sends Task (for final states) or TaskStatusUpdateEvent (for progress updates):
{
  "id": "task_456",
  "contextId": "ctx_123",
  "status": {
    "state": "completed",
    "message": {
      "role": "agent",
      "parts": [
        { "text": "Media buy created successfully" },
        {
          "data": {
            "media_buy_id": "mb_12345",
            "buyer_ref": "nike_q1_campaign_2024",
            "creative_deadline": "2024-01-30T23:59:59Z",
            "packages": [
              { "package_id": "pkg_12345_001", "buyer_ref": "nike_ctv_sports_package" }
            ]
          }
        }
      ]
    },
    "timestamp": "2025-01-22T10:30:00Z"
  }
}

Status-Specific Data Schemas

StatusData SchemaContents
completed[task]-response.jsonFull task response (success branch)
failed[task]-response.jsonFull task response (error branch)
working[task]-async-response-working.jsonProgress info (percentage, step)
input-required[task]-async-response-input-required.jsonRequirements, approval data
submitted[task]-async-response-submitted.jsonAcknowledgment (usually minimal)

Webhook Authentication

AdCP adopts A2A’s PushNotificationConfig structure for webhook configuration:
{
  "push_notification_config": {
    "url": "https://buyer.example.com/webhooks/adcp",
    "authentication": {
      "schemes": ["Bearer"],
      "credentials": "secret_token_min_32_chars"
    }
  }
}

Supported Authentication Schemes

Bearer Token (Simple, Recommended for Development)
{
  "authentication": {
    "schemes": ["Bearer"],
    "credentials": "secret_token_32_chars"
  }
}
HMAC Signature (Enterprise, Recommended for Production)
{
  "authentication": {
    "schemes": ["HMAC-SHA256"],
    "credentials": "shared_secret_32_chars"
  }
}

Publisher Implementation (Bearer)

const config = pushNotificationConfig;
const scheme = config.authentication.schemes[0];

if (scheme === 'Bearer') {
  await axios.post(config.url, payload, {
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${config.authentication.credentials}`
    }
  });
}

Publisher Implementation (HMAC-SHA256)

if (scheme === 'HMAC-SHA256') {
  const timestamp = new Date().toISOString();
  const signature = crypto
    .createHmac('sha256', config.authentication.credentials)
    .update(timestamp + JSON.stringify(payload))
    .digest('hex');

  await axios.post(config.url, payload, {
    headers: {
      'Content-Type': 'application/json',
      'X-ADCP-Signature': `sha256=${signature}`,
      'X-ADCP-Timestamp': timestamp
    }
  });
}

Buyer Implementation (Bearer)

app.post('/webhooks/adcp', async (req, res) => {
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing Authorization header' });
  }

  const token = authHeader.substring(7);
  if (token !== process.env.ADCP_WEBHOOK_TOKEN) {
    return res.status(401).json({ error: 'Invalid token' });
  }

  await processWebhook(req.body);
  res.status(200).json({ status: 'processed' });
});

Buyer Implementation (HMAC-SHA256)

app.post('/webhooks/adcp', async (req, res) => {
  const signature = req.headers['x-adcp-signature'];
  const timestamp = req.headers['x-adcp-timestamp'];

  if (!signature || !timestamp) {
    return res.status(401).json({ error: 'Missing signature headers' });
  }

  // Reject old webhooks (prevent replay attacks)
  const eventTime = new Date(timestamp);
  if (Date.now() - eventTime > 5 * 60 * 1000) {
    return res.status(401).json({ error: 'Webhook too old' });
  }

  // Verify signature
  const expectedSig = crypto
    .createHmac('sha256', process.env.ADCP_WEBHOOK_SECRET)
    .update(timestamp + JSON.stringify(req.body))
    .digest('hex');

  if (signature !== `sha256=${expectedSig}`) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  await processWebhook(req.body);
  res.status(200).json({ status: 'processed' });
});

Webhook Reliability

Delivery Semantics

AdCP webhooks use at-least-once delivery semantics:
  • Not guaranteed: Webhooks may fail due to network issues, server downtime, or configuration problems
  • May be duplicated: The same event might be delivered multiple times
  • May arrive out of order: Later events could arrive before earlier ones
  • Timeout behavior: Webhook delivery has limited retry attempts and timeouts

Retry Strategy

Publishers should use exponential backoff with jitter:
class WebhookDelivery {
  constructor() {
    this.maxRetries = 3;
    this.baseDelay = 1000; // 1 second
    this.maxDelay = 60000; // 1 minute
  }

  async deliverWithRetry(url, payload, attempt = 0) {
    try {
      const response = await this.sendWebhook(url, payload);

      if (response.status >= 200 && response.status < 300) {
        return { success: true, attempts: attempt + 1 };
      }

      // Retry on 5xx errors and timeouts
      if (response.status >= 500 && attempt < this.maxRetries) {
        await this.delayWithJitter(attempt);
        return this.deliverWithRetry(url, payload, attempt + 1);
      }

      // Don't retry 4xx errors (client errors)
      return { success: false, error: 'Client error', attempts: attempt + 1 };

    } catch (error) {
      if (attempt < this.maxRetries) {
        await this.delayWithJitter(attempt);
        return this.deliverWithRetry(url, payload, attempt + 1);
      }
      return { success: false, error: error.message, attempts: attempt + 1 };
    }
  }

  async delayWithJitter(attempt) {
    const exponentialDelay = Math.min(
      this.baseDelay * Math.pow(2, attempt),
      this.maxDelay
    );
    // Add ±25% jitter to prevent thundering herd
    const jitter = exponentialDelay * (0.75 + Math.random() * 0.5);
    await new Promise(resolve => setTimeout(resolve, jitter));
  }
}
Retry Schedule:
  • Attempt 1: Immediate
  • Attempt 2: After ~1 second (with jitter)
  • Attempt 3: After ~2 seconds (with jitter)
  • Attempt 4: After ~4 seconds (with jitter)
  • Give up after 4 total attempts

Circuit Breaker Pattern

Publishers must implement circuit breakers to prevent webhook queues from growing unbounded:
class CircuitBreaker {
  constructor(endpoint) {
    this.endpoint = endpoint;
    this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN
    this.failureCount = 0;
    this.failureThreshold = 5;
    this.successThreshold = 2;
    this.timeout = 60000; // 1 minute
    this.halfOpenTime = null;
    this.successCount = 0;
  }

  async execute(fn) {
    if (this.state === 'OPEN') {
      // Check if circuit should move to HALF_OPEN
      if (Date.now() - this.halfOpenTime > this.timeout) {
        this.state = 'HALF_OPEN';
        this.successCount = 0;
      } else {
        throw new Error('Circuit breaker is OPEN');
      }
    }

    try {
      const result = await fn();
      this.onSuccess();
      return result;
    } catch (error) {
      this.onFailure();
      throw error;
    }
  }

  onSuccess() {
    this.failureCount = 0;

    if (this.state === 'HALF_OPEN') {
      this.successCount++;
      if (this.successCount >= this.successThreshold) {
        this.state = 'CLOSED';
        console.log(`Circuit breaker CLOSED for ${this.endpoint}`);
      }
    }
  }

  onFailure() {
    this.failureCount++;

    if (this.failureCount >= this.failureThreshold) {
      this.state = 'OPEN';
      this.halfOpenTime = Date.now();
      console.error(`Circuit breaker OPEN for ${this.endpoint}`);
    }
  }
}
Circuit Breaker States:
  • CLOSED: Normal operation, webhooks delivered
  • OPEN: Endpoint is down, webhooks are dropped (not queued)
  • HALF_OPEN: Testing if endpoint recovered, limited webhooks sent

Queue Management

Publishers should implement bounded queues with overflow policies:
class BoundedWebhookQueue {
  constructor(maxSize = 1000) {
    this.maxSize = maxSize;
    this.queue = [];
    this.droppedCount = 0;
  }

  enqueue(webhook) {
    if (this.queue.length >= this.maxSize) {
      // Overflow policy: drop oldest webhooks
      const dropped = this.queue.shift();
      this.droppedCount++;
      console.warn(`Dropped webhook ${dropped.id} due to queue overflow`);
    }
    this.queue.push(webhook);
  }
}
Best Practices:
  • Set max queue size based on available memory and recovery time
  • Monitor queue depth and dropped webhook counts
  • Alert operations when queues are consistently full
  • Use dead letter queues for manual investigation of persistent failures
  • Implement queue per buyer endpoint (not global queue)

Idempotent Webhook Handlers

Always implement idempotent handlers that can safely process the same event multiple times:
app.post('/webhooks/adcp', async (req, res) => {
  const { task_id, current_status, timestamp, event_id } = req.body;

  // Idempotent check - avoid duplicate processing
  const existing = await db.getWebhookEvent(event_id);
  if (existing) {
    console.log(`Webhook ${event_id} already processed`);
    return res.status(200).json({ status: 'already_processed' });
  }

  // Record this webhook event
  await db.recordWebhookEvent(event_id, timestamp);

  // Process the status change
  await processTaskStatusChange(task_id, current_status, timestamp);

  // Always return 200 for successful processing
  res.status(200).json({ status: 'processed' });
});

Sequence Handling

Use timestamps to ensure proper event ordering:
async function processTaskStatusChange(taskId, newStatus, timestamp) {
  const currentTask = await db.getTask(taskId);

  // Ignore out-of-order events
  if (currentTask?.updated_at >= timestamp) {
    console.log(`Ignoring out-of-order webhook for task ${taskId}`);
    return;
  }

  // Update task with new status
  await db.updateTask(taskId, {
    status: newStatus,
    updated_at: timestamp
  });

  // Trigger any business logic
  await handleStatusChange(taskId, newStatus);
}

Polling as Backup

Never rely solely on webhooks. Use polling as a reliable backup:
class TaskTracker {
  constructor() {
    this.pendingTasks = new Map();
    this.pollInterval = 30000; // 30 seconds
  }

  async trackTask(taskId, webhookConfigured = false) {
    this.pendingTasks.set(taskId, {
      lastPolled: Date.now(),
      webhookConfigured,
      pollAttempts: 0
    });

    // Start polling backup even if webhook is configured
    this.schedulePolling(taskId);
  }

  async schedulePolling(taskId) {
    const task = this.pendingTasks.get(taskId);
    if (!task) return;

    // Increase polling interval if webhook is configured
    const interval = task.webhookConfigured ?
      this.pollInterval * 4 : // 2 minutes with webhook
      this.pollInterval;      // 30 seconds without webhook

    setTimeout(async () => {
      if (this.pendingTasks.has(taskId)) {
        await this.pollTask(taskId);
        this.schedulePolling(taskId); // Continue polling
      }
    }, interval);
  }

  async pollTask(taskId) {
    try {
      const response = await adcp.call('tasks/get', {
        task_id: taskId,
        include_result: true
      });

      await this.updateTaskState(taskId, response);

      // Stop tracking if complete
      if (['completed', 'failed', 'canceled'].includes(response.status)) {
        this.pendingTasks.delete(taskId);
      }

    } catch (error) {
      console.error(`Polling failed for task ${taskId}:`, error);
    }
  }
}

Reporting Webhooks

In addition to task status webhooks, AdCP supports reporting webhooks for automated delivery performance notifications.

Configuration

{
  "buyer_ref": "campaign_2024",
  "reporting_webhook": {
    "url": "https://buyer.example.com/webhooks/reporting",
    "auth_type": "bearer",
    "auth_token": "secret_token",
    "reporting_frequency": "daily"
  }
}

Payload Structure

{
  "notification_type": "scheduled",
  "sequence_number": 5,
  "next_expected_at": "2024-02-06T08:00:00Z",
  "reporting_period": {
    "start": "2024-02-05T00:00:00Z",
    "end": "2024-02-05T23:59:59Z"
  },
  "currency": "USD",
  "media_buy_deliveries": [
    {
      "media_buy_id": "mb_001",
      "buyer_ref": "campaign_a",
      "status": "active",
      "totals": {...},
      "by_package": [...]
    }
  ]
}

Implementation Requirements

  1. Array Handling: Always process media_buy_deliveries as an array (may contain 1 to N media buys)
  2. Idempotent Processing: Same as task webhooks - handle duplicates safely
  3. Sequence Tracking: Use sequence_number to detect gaps or out-of-order delivery
  4. Fallback Strategy: Continue polling get_media_buy_delivery as backup
  5. Delay Handling: Treat "delayed" notifications as normal, not errors

Best Practices Summary

  1. Always implement polling backup - Don’t rely solely on webhooks
  2. Handle duplicates gracefully - Use idempotent processing with event IDs
  3. Check timestamps - Ignore out-of-order events based on timestamps
  4. Return 200 quickly - Acknowledge webhook receipt immediately
  5. Verify authenticity - Always validate webhook signatures
  6. Log webhook events - Keep audit trail for debugging
  7. Set reasonable timeouts - Don’t wait forever for webhook delivery
  8. Graceful degradation - Fall back to polling if webhooks consistently fail

Next Steps