diff --git a/docs/media-buy/media-buys/optimization-reporting.md b/docs/media-buy/media-buys/optimization-reporting.md index df5924f95..e8643e246 100644 --- a/docs/media-buy/media-buys/optimization-reporting.md +++ b/docs/media-buy/media-buys/optimization-reporting.md @@ -55,9 +55,48 @@ Stay informed of important campaign events: ## Webhook-Based Reporting -Publishers can proactively push reporting data to buyers on a scheduled basis through webhook notifications. This eliminates the need for continuous polling and provides timely campaign insights. +Publishers can proactively push reporting data to buyers through webhook notifications or offline file delivery. This eliminates continuous polling and provides timely campaign insights. -### Configuration +### Delivery Methods + +**1. Webhook Push (Real-time)** - HTTP POST to buyer endpoint +- Best for: Most buyer-seller relationships +- Latency: Near real-time (seconds to minutes) +- Cost: Standard webhook infrastructure + +**2. Offline File Delivery (Batch)** - Cloud storage bucket push +- Best for: Large buyer-seller pairs (high volume) +- Latency: Scheduled batch delivery (hourly/daily) +- Cost: Significantly lower ($0.01-0.10 per GB vs. $0.50-2.00 per 1M webhooks) +- Format: JSON Lines, CSV, or Parquet files +- Storage: S3, GCS, Azure Blob Storage + +**Example: Offline Delivery** +Publisher pushes daily report files to buyer's cloud storage: +``` +s3://buyer-reports/publisher_name/2024/02/05/media_buy_delivery.json.gz +``` + +File contains same structure as webhook payload but aggregated across all campaigns. Buyer processes files on their schedule. + +**When to Use Offline Delivery:** +- \>100 active campaigns with same buyer +- Hourly reporting requirements (24x cost reduction) +- High data volume (detailed breakdowns, dimensional data) +- Buyer has batch processing infrastructure + +Publishers declare support for offline delivery in product capabilities: +```json +{ + "reporting_capabilities": { + "supports_webhooks": true, + "supports_offline_delivery": true, + "offline_delivery_protocols": ["s3", "gcs"] + } +} +``` + +### Webhook Configuration Configure reporting webhooks when creating a media buy using the `reporting_webhook` parameter: @@ -67,21 +106,48 @@ Configure reporting webhooks when creating a media buy using the `reporting_webh "packages": [...], "reporting_webhook": { "url": "https://buyer.example.com/webhooks/reporting", - "auth_type": "bearer", - "auth_token": "secret_token", + "authentication": { + "schemes": ["Bearer"], + "credentials": "secret_token_min_32_chars" + }, "reporting_frequency": "daily" } } ``` +**Or with HMAC signature (recommended for production):** +```json +{ + "buyer_ref": "campaign_2024", + "packages": [...], + "reporting_webhook": { + "url": "https://buyer.example.com/webhooks/reporting", + "authentication": { + "schemes": ["HMAC-SHA256"], + "credentials": "shared_secret_min_32_chars" + }, + "reporting_frequency": "daily" + } +} +``` + +**Security is Required:** +- `authentication` configuration is mandatory (minimum 32 characters) +- **Bearer tokens**: Simple, good for development (Authorization header) +- **HMAC-SHA256**: Production-recommended, prevents replay attacks (signature headers) +- Credentials exchanged out-of-band during publisher onboarding +- See [Webhook Security](../../protocols/core-concepts.md#security) for implementation details + ### Supported Frequencies -Publishers declare supported reporting frequencies in the product's `reporting_capabilities`: +Publishers declare supported reporting frequencies in the product's `reporting_capabilities`. Publishers are **not required** to support all frequencies - choose what makes operational sense for your platform. -- **`hourly`**: Receive notifications every hour during campaign flight -- **`daily`**: Receive notifications once per day (timezone specified by publisher) +- **`hourly`**: Receive notifications every hour during campaign flight (optional, consider cost/complexity) +- **`daily`**: Receive notifications once per day (most common, recommended for Phase 1) - **`monthly`**: Receive notifications once per month (timezone specified by publisher) +**Cost Consideration:** Hourly webhooks generate 24x more traffic than daily. Large buyer-seller pairs may prefer offline reporting mechanisms (see below) for cost efficiency. + ### Available Metrics Publishers declare which metrics they can provide in `reporting_capabilities.available_metrics`. Common metrics include: @@ -145,15 +211,33 @@ Reporting webhooks use the same payload structure as [`get_media_buy_delivery`]( - **`next_expected_at`**: ISO 8601 timestamp for next notification (omitted for final notifications) - **`media_buy_deliveries`**: Array of media buy delivery data (may contain multiple media buys aggregated by publisher) -### Timezone Considerations +### Timezone Handling -For daily and monthly frequencies, the publisher's reporting timezone (from `reporting_capabilities.timezone`) determines period boundaries: +**All reporting MUST use UTC.** This eliminates DST complexity, simplifies reconciliation, and ensures consistent 24-hour reporting periods. -- **Daily**: Reporting day starts/ends at midnight in publisher's timezone -- **Monthly**: Reporting month starts on 1st and ends on last day of month in publisher's timezone -- **Hourly**: Uses UTC unless otherwise specified +```json +{ + "reporting_capabilities": { + "timezone": "UTC", + "available_reporting_frequencies": ["daily"] + } +} +``` -**Example**: Publisher with `"timezone": "America/New_York"` and daily frequency sends notifications at ~8:00 UTC (midnight ET + expected delay). +**Reporting periods:** +- Daily: 00:00:00Z to 23:59:59Z (always 24 hours) +- Hourly: Top of hour to 59:59 seconds (always 1 hour) +- Monthly: First to last day of month + +**Example webhook payload:** +```json +{ + "reporting_period": { + "start": "2024-02-05T00:00:00Z", + "end": "2024-02-05T23:59:59Z" + } +} +``` ### Delayed Reporting @@ -202,6 +286,168 @@ The `media_buy_deliveries` array may contain 1 to N media buys per webhook. Buye Buyers should iterate through the array and process each media buy independently. If aggregated totals are needed, calculate them from the individual media buy totals. +#### Partial Failure Handling + +When aggregating multiple media buys into a single webhook, publishers must handle cases where some campaigns have data available while others don't. + +**Approach: Best-Effort Delivery with Status Indicators** + +Publishers SHOULD send aggregated webhooks containing all available data, using status fields to indicate partial availability: + +```json +{ + "notification_type": "scheduled", + "sequence_number": 5, + "reporting_period": { + "start": "2024-02-05T00:00:00Z", + "end": "2024-02-05T23:59:59Z" + }, + "currency": "USD", + "media_buy_deliveries": [ + { + "media_buy_id": "mb_001", + "status": "active", + "totals": { + "impressions": 50000, + "spend": 1750 + } + }, + { + "media_buy_id": "mb_002", + "status": "active", + "totals": { + "impressions": 48500, + "spend": 1695 + } + }, + { + "media_buy_id": "mb_003", + "status": "reporting_delayed", + "message": "Reporting data temporarily unavailable for this campaign", + "expected_availability": "2024-02-06T02:00:00Z" + } + ], + "partial_data": true, + "unavailable_count": 1 +} +``` + +**Key Fields for Partial Failures:** +- `partial_data`: Boolean indicating if any campaigns are missing data +- `unavailable_count`: Number of campaigns with delayed/missing data +- `status`: Per-campaign status (`"active"`, `"reporting_delayed"`, `"failed"`) +- `expected_availability`: When delayed data is expected (if known) + +**When to Use Partial Delivery:** +1. **Upstream delays**: Some data sources are slower than others +2. **System degradation**: Partial system outage affects subset of campaigns +3. **Data quality issues**: Specific campaigns fail validation, others proceed +4. **Rate limiting**: API limits prevent fetching all campaign data + +**When NOT to Use Partial Delivery:** +1. **Complete system outage**: Send `"delayed"` notification instead +2. **All campaigns affected**: Use `notification_type: "delayed"` +3. **Buyer endpoint issues**: Circuit breaker handles this (don't send at all) + +**Buyer Processing Logic:** +```javascript +function processAggregatedWebhook(webhook) { + if (webhook.partial_data) { + console.warn(`Partial data: ${webhook.unavailable_count} campaigns delayed`); + } + + for (const delivery of webhook.media_buy_deliveries) { + if (delivery.status === 'reporting_delayed') { + // Mark campaign as pending, retry via polling or wait for next webhook + markCampaignPending(delivery.media_buy_id, delivery.expected_availability); + } else if (delivery.status === 'active') { + // Process normal delivery data + processCampaignMetrics(delivery); + } else { + console.error(`Unexpected status for ${delivery.media_buy_id}: ${delivery.status}`); + } + } +} +``` + +**Best Practices:** +- Always include all campaigns in array, even if data unavailable (with status indicator) +- Set `partial_data: true` flag when any campaigns are delayed/failed +- Provide `expected_availability` timestamp if known +- Don't retry the entire webhook - buyers can poll individual campaigns if needed +- Track partial delivery rates in monitoring to detect systemic issues + +### Privacy and Compliance + +#### PII Scrubbing for GDPR/CCPA + +Publishers MUST scrub personally identifiable information (PII) from all webhook payloads to ensure GDPR and CCPA compliance. Reporting webhooks should contain only aggregated, anonymized metrics. + +**What to Scrub:** +- User IDs, device IDs, IP addresses +- Email addresses, phone numbers +- Precise geolocation data (latitude/longitude) +- Cookie IDs, advertising IDs (unless aggregated) +- Any custom dimensions containing PII + +**What to Keep:** +- Aggregated metrics (impressions, spend, clicks, etc.) +- Coarse geography (city, state, country - not street address) +- Device type categories (mobile, desktop, tablet) +- Browser/OS categories +- Time-based aggregations + +**Example - Before PII Scrubbing (❌ DO NOT SEND):** +```json +{ + "media_buy_id": "mb_001", + "user_events": [ + { + "user_id": "user_12345", + "ip_address": "192.168.1.100", + "device_id": "abc-def-ghi", + "impressions": 1, + "lat": 40.7128, + "lon": -74.0060 + } + ] +} +``` + +**Example - After PII Scrubbing (✅ CORRECT):** +```json +{ + "media_buy_id": "mb_001", + "totals": { + "impressions": 125000, + "spend": 5625.00, + "clicks": 250 + }, + "by_geography": [ + { + "city": "New York", + "state": "NY", + "country": "US", + "impressions": 45000, + "spend": 2025.00 + } + ] +} +``` + +**Publisher Responsibilities:** +- Implement PII scrubbing at the data collection layer, not at webhook delivery +- Ensure aggregation thresholds prevent re-identification (e.g., minimum 10 users per segment) +- Document what data is collected vs. what is shared in webhooks +- Provide data processing agreements (DPAs) for GDPR compliance +- Support GDPR/CCPA data deletion requests + +**Buyer Responsibilities:** +- Do not request PII in `requested_metrics` or custom dimensions +- Understand that webhook data is aggregated and anonymized +- Implement proper data retention policies +- Include webhook data in privacy policies and user disclosures + ### Implementation Best Practices 1. **Handle Arrays**: Always process `media_buy_deliveries` as an array, even if it contains one element @@ -211,6 +457,167 @@ Buyers should iterate through the array and process each media buy independently 5. **Timezone Awareness**: Store publisher's reporting timezone for accurate period calculation 6. **Validate Frequency**: Ensure requested frequency is in product's `available_reporting_frequencies` 7. **Validate Metrics**: Ensure requested metrics are in product's `available_metrics` +8. **PII Compliance**: Never include user-level data in webhook payloads + +### Webhook Health Monitoring + +Webhook delivery status is tracked through **AdCP's global task management system** (see [Task Management](../../protocols/task-management.md)). + +When a media buy is created with `reporting_webhook` configured, the publisher creates an ongoing task for webhook delivery. Buyers can monitor webhook health using standard task queries. + +**Benefits of using task management:** +- Consistent status tracking across all AdCP operations +- Standard polling/webhook notification patterns +- Existing infrastructure for task status, history, and errors +- No need for media-buy-specific webhook health endpoints + +If webhook delivery fails persistently (circuit breaker opens), publishers update the task status to indicate the issue. Buyers detect this through normal task monitoring. + +## Data Reconciliation + +**The `get_media_buy_delivery` API is the authoritative source of truth for all campaign metrics**, regardless of whether you use webhooks, offline delivery, or polling. + +Reconciliation is important for **any reporting delivery method** because: +- **Webhooks**: May be missed due to network failures or circuit breaker drops +- **Offline files**: May be delayed, corrupted, or fail to process +- **Polling**: May miss data during API outages +- **Late-arriving data**: Impressions can arrive 24-48+ hours after initial reporting (all methods) + +### Reconciliation Process + +Buyers SHOULD periodically reconcile delivered data against API to ensure accuracy: + +**Recommended Reconciliation Schedule:** +- **Hourly delivery**: Reconcile via API daily +- **Daily delivery**: Reconcile via API weekly +- **Monthly delivery**: Reconcile via API at month end + 7 days +- **Campaign close**: Always reconcile after campaign_end + attribution_window + +**Reconciliation Logic:** +```javascript +async function reconcileWebhookData(mediaBuyId, startDate, endDate) { + // Get authoritative data from API + const apiData = await adcp.getMediaBuyDelivery({ + media_buy_id: mediaBuyId, + date_range: { start: startDate, end: endDate } + }); + + // Compare with webhook data in local database + const webhookData = await db.getWebhookTotals(mediaBuyId, startDate, endDate); + + const discrepancy = { + impressions: apiData.totals.impressions - webhookData.impressions, + spend: apiData.totals.spend - webhookData.spend, + clicks: apiData.totals.clicks - webhookData.clicks + }; + + // Acceptable discrepancy thresholds + const impressionVariance = Math.abs(discrepancy.impressions) / apiData.totals.impressions; + const spendVariance = Math.abs(discrepancy.spend) / apiData.totals.spend; + + if (impressionVariance > 0.02 || spendVariance > 0.01) { + // Significant discrepancy (>2% impressions or >1% spend) + console.warn(`Reconciliation discrepancy for ${mediaBuyId}:`, discrepancy); + + // Update local database with authoritative API data + await db.updateCampaignTotals(mediaBuyId, apiData.totals); + + // Alert if discrepancy is unusually large + if (impressionVariance > 0.10 || spendVariance > 0.05) { + await alertOps(`Large reconciliation discrepancy detected`, { + media_buy_id: mediaBuyId, + webhook_totals: webhookData, + api_totals: apiData.totals, + discrepancy + }); + } + } + + return { + status: impressionVariance < 0.02 ? 'reconciled' : 'discrepancy_found', + api_data: apiData.totals, + webhook_data: webhookData, + discrepancy + }; +} +``` + +**Why Discrepancies Occur:** +1. **Delivery failures**: Webhooks missed, offline files corrupted, API timeouts during polling +2. **Late-arriving data**: Impressions attributed after initial reporting (all delivery methods) +3. **Data corrections**: Publisher adjusts metrics after initial reporting +4. **Processing errors**: Buyer-side failures to process delivered data +5. **Timezone differences**: Period boundaries may differ between delivery and API query + +**Source of Truth Rules:** +- **For billing**: Always use `get_media_buy_delivery` API at campaign end + attribution window +- **For real-time decisions**: Use delivered data (webhook/file/poll) for speed, reconcile later +- **For discrepancies**: API data wins, update local records accordingly +- **For audits**: API provides complete historical data, delivered data is ephemeral + +**Best Practices:** +- Store webhook `sequence_number` to detect missed notifications +- Run automated reconciliation daily for active campaigns +- Alert on discrepancies >2% for impressions or >1% for spend +- Use API data for all financial reporting and invoicing +- Document reconciliation process for audit compliance + +### Late-Arriving Impressions + +Ad serving data often arrives with delays due to attribution windows, offline tracking, and pipeline latency. Publishers declare `expected_delay_minutes` in `reporting_capabilities`: +- **Display/Video**: Typically 4-6 hours +- **Audio**: Typically 8-12 hours +- **CTV**: May be 24+ hours + +This represents when **most** data is available, not **all** data. + +#### Handling Late Arrivals + +When late data arrives for a previously reported period, **resend that period** with `is_adjusted: true`: + +```json +{ + "notification_type": "adjusted", + "reporting_period": { + "start": "2024-02-01T00:00:00Z", + "end": "2024-02-01T23:59:59Z" + }, + "media_buy_deliveries": [{ + "media_buy_id": "mb_001", + "is_adjusted": true, + "totals": { + "impressions": 51000, // Updated total (was 50000) + "spend": 1785 // Updated spend (was 1750) + } + }] +} +``` + +**Buyer Processing:** +```javascript +function processWebhook(webhook) { + for (const delivery of webhook.media_buy_deliveries) { + if (delivery.is_adjusted) { + // Replace entire period with updated totals + db.replaceCampaignPeriod( + delivery.media_buy_id, + webhook.reporting_period, + delivery.totals + ); + } else { + // Normal new period data + db.insertCampaignPeriod(delivery.media_buy_id, webhook.reporting_period, delivery.totals); + } + } +} +``` + +**When to send adjusted periods:** +- Significant data changes (>2% impression variance or >1% spend variance) +- Final reconciliation at campaign_end + attribution_window +- Data quality corrections + +With polling-only, buyers detect adjustments through reconciliation by comparing API results over time. ### Webhook Reliability diff --git a/docs/media-buy/task-reference/create_media_buy.md b/docs/media-buy/task-reference/create_media_buy.md index b2ec428bc..aa4813a2d 100644 --- a/docs/media-buy/task-reference/create_media_buy.md +++ b/docs/media-buy/task-reference/create_media_buy.md @@ -821,19 +821,69 @@ For MCP implementations using polling, use this endpoint to check the status of #### Option 2: Webhooks (MCP) -Register a callback URL to receive push notifications: -```json -{ - "tool": "create_media_buy", - "arguments": { - "buyer_ref": "campaign_2024", - "packages": [...], - "webhook_url": "https://buyer.example.com/mcp/webhooks", - "webhook_auth_token": "bearer-token-xyz" +Register a callback URL to receive push notifications for long-running operations. Webhooks are ONLY used when the initial response is `submitted`. + +**Configuration:** +```javascript +const response = await session.call('create_media_buy', + { + buyer_ref: "campaign_2024", + packages: [...] + }, + { + webhook_url: "https://buyer.example.com/webhooks/adcp/create_media_buy/agent_id/op_id", + webhook_auth: { type: "bearer", credentials: "bearer-token-xyz" } } +); +``` + +**Response patterns:** +- **`completed`** - Synchronous success, webhook NOT called (you have the result) +- **`working`** - Will complete within ~120s, webhook NOT called (wait for response) +- **`submitted`** - Long-running operation, webhook WILL be called on status changes + +**Example webhook flow (only for `submitted` operations):** + +Webhook POST for human approval needed: +```http +POST /webhooks/adcp/create_media_buy/agent_id/op_id HTTP/1.1 +Host: buyer.example.com +Authorization: Bearer bearer-token-xyz +Content-Type: application/json + +{ + "adcp_version": "1.6.0", + "status": "input-required", + "task_id": "task_456", + "buyer_ref": "campaign_2024", + "message": "Campaign budget $150K requires approval to proceed" +} +``` + +**Webhook POST when complete (after approval - full create_media_buy response):** +```http +POST /webhooks/adcp/create_media_buy/agent_id/op_id HTTP/1.1 +Host: buyer.example.com +Authorization: Bearer bearer-token-xyz +Content-Type: application/json + +{ + "adcp_version": "1.6.0", + "status": "completed", + "media_buy_id": "mb_12345", + "buyer_ref": "campaign_2024", + "creative_deadline": "2024-01-30T23:59:59Z", + "packages": [ + { + "package_id": "pkg_001", + "buyer_ref": "ctv_package" + } + ] } ``` +Each webhook receives the full response object for that status. See **[Task Management: Webhook Integration](../../protocols/task-management.md#webhook-integration)** for complete details. + ### A2A Status Checking A2A supports both SSE streaming and webhooks as shown in the examples above. Choose based on your needs: diff --git a/docs/media-buy/task-reference/sync_creatives.md b/docs/media-buy/task-reference/sync_creatives.md index eef2af9fb..5c11365e7 100644 --- a/docs/media-buy/task-reference/sync_creatives.md +++ b/docs/media-buy/task-reference/sync_creatives.md @@ -32,6 +32,7 @@ The `sync_creatives` task provides a powerful, efficient approach to creative li | `patch` | boolean | No | Partial update mode (default: false) | | `dry_run` | boolean | No | Preview changes without applying (default: false) | | `validation_mode` | enum | No | Validation strictness: "strict" or "lenient" (default: "strict") | +| `push_notification_config` | PushNotificationConfig | No | Optional webhook for async sync notifications (see Webhook Configuration below) | ### Assignment Management @@ -60,6 +61,34 @@ Each creative in the `creatives` array follows the [Creative Asset schema](/sche - `snippet_type: "html"` - `assets` array with sub-assets for template variables +## Webhook Configuration (Task-Specific) + +For large bulk operations or creative approval workflows, you can provide a task-specific webhook to be notified when the sync completes: + +```json +{ + "creatives": [/* up to 100 creatives */], + "push_notification_config": { + "url": "https://buyer.com/webhooks/creative-sync", + "authentication": { + "schemes": ["HMAC-SHA256"], + "credentials": "shared_secret_32_chars" + } + } +} +``` + +**When webhooks are sent:** +- Bulk sync takes longer than ~120 seconds (status: `working` → `completed`) +- Creative approval required (status: `submitted` → `completed`) +- Large creative uploads processing asynchronously + +**Webhook payload:** +- Complete sync_creatives response with summary and results +- Includes action taken for each creative (created/updated/unchanged/failed) + +See [Webhook Security](../../protocols/core-concepts.md#security) for authentication details. + ## Response Format The response provides comprehensive details about the sync operation: diff --git a/docs/media-buy/task-reference/update_media_buy.md b/docs/media-buy/task-reference/update_media_buy.md index 4957c0cf9..5538e1b21 100644 --- a/docs/media-buy/task-reference/update_media_buy.md +++ b/docs/media-buy/task-reference/update_media_buy.md @@ -24,6 +24,7 @@ Update campaign and package settings. This task supports partial updates and han | `end_time` | string | No | New end date/time in ISO 8601 format (UTC unless timezone specified) | | `budget` | Budget | No | New budget configuration (see Budget Object in create_media_buy) | | `packages` | PackageUpdate[] | No | Package-specific updates (see Package Update Object below) | +| `push_notification_config` | PushNotificationConfig | No | Optional webhook for async update notifications (see Webhook Configuration below) | *Either `media_buy_id` or `buyer_ref` must be provided @@ -40,6 +41,37 @@ Update campaign and package settings. This task supports partial updates and han *Either `package_id` or `buyer_ref` must be provided +## Webhook Configuration (Task-Specific) + +For long-running updates (typically requiring approval workflows), you can provide a task-specific webhook to be notified when the update completes: + +```json +{ + "buyer_ref": "nike_q1_campaign_2024", + "budget": { + "total": 150000, + "currency": "USD" + }, + "push_notification_config": { + "url": "https://buyer.com/webhooks/media-buy-updates", + "authentication": { + "schemes": ["HMAC-SHA256"], + "credentials": "shared_secret_32_chars" + } + } +} +``` + +**When webhooks are sent:** +- Update requires manual approval (status: `submitted` → `completed`) +- Update takes longer than ~120 seconds (status: `working` → `completed`) + +**Webhook payload:** +- Complete update_media_buy response with final status +- Includes media_buy_id, affected_packages, and implementation_date + +See [Webhook Security](../../protocols/core-concepts.md#security) for authentication details. + ## Response (Message) The response includes a human-readable message that: diff --git a/docs/protocols/core-concepts.md b/docs/protocols/core-concepts.md index 491305c64..a3c30a86e 100644 --- a/docs/protocols/core-concepts.md +++ b/docs/protocols/core-concepts.md @@ -280,26 +280,46 @@ class McpAdcpSession { tool: tool, arguments: params }; - + // Protocol-level extensions (like context_id) if (this.contextId) { request.context_id = this.contextId; } - if (options.webhook_url) { - request.webhook_url = options.webhook_url; - request.webhook_auth = options.webhook_auth; + + // Use A2A-compatible push_notification_config + if (options.push_notification_config) { + request.push_notification_config = options.push_notification_config; } - + return await this.mcp.call(request); } } -// Usage -const response = await session.call('create_media_buy', +// Usage (Bearer token) +const response = await session.call('create_media_buy', { /* task params */ }, { - webhook_url: "https://buyer.com/webhooks/adcp", - webhook_auth: { type: "bearer", credentials: "secret" } + push_notification_config: { + url: "https://buyer.com/webhooks/adcp", + authentication: { + schemes: ["Bearer"], + credentials: "secret_token_32_chars" + } + } + } +); + +// Usage (HMAC signature - recommended for production) +const response = await session.call('create_media_buy', + { /* task params */ }, + { + push_notification_config: { + url: "https://buyer.com/webhooks/adcp", + authentication: { + schemes: ["HMAC-SHA256"], + credentials: "shared_secret_32_chars" + } + } } ); ``` @@ -307,6 +327,7 @@ const response = await session.call('create_media_buy', #### A2A Native Support ```javascript // A2A has native webhook support via PushNotificationConfig +// AdCP uses the same structure - no mapping needed! await a2a.send({ message: { parts: [{ @@ -318,20 +339,187 @@ await a2a.send({ }] }, push_notification_config: { - webhook_url: "https://buyer.com/webhooks/adcp", - auth: { type: "bearer", credentials: "secret" } + url: "https://buyer.com/webhooks/adcp", + authentication: { + schemes: ["HMAC-SHA256"], // or ["Bearer"] + credentials: "shared_secret_32_chars" + } } }); ``` ### Server Decision on Webhook Usage -The server always decides whether to use webhooks: +The server decides whether to use webhooks based on the initial response status: -- **Quick operations** (< 120s): Server returns `working`, ignores webhook -- **Long operations** (hours/days): Server returns `submitted`, uses webhook if provided +- **`completed`, `failed`, `rejected`**: Synchronous response - webhook is NOT called (client already has complete response) +- **`working`**: Will respond synchronously within ~120 seconds - webhook is NOT called (just wait for the response) +- **`submitted`**: Long-running async operation - webhook WILL be called on ALL subsequent status changes - **Client choice**: Webhook is optional - clients can always poll with `tasks/get` +**Webhook trigger rule:** Webhooks are ONLY used when the initial response status is `submitted`. + +**When webhooks are called (for `submitted` operations):** +- Status changes to `input-required` → Webhook called (human needs to respond) +- Status changes to `completed` → Webhook called (final result) +- Status changes to `failed` → Webhook called (error details) +- Status changes to `canceled` → Webhook called (cancellation confirmation) + +### Webhook POST Format + +When an async operation changes status, the publisher POSTs the **complete task response object** to your webhook URL. + +#### Webhook Scenarios + +**Scenario 1: Synchronous completion (no webhook)** +```javascript +// Initial request +const response = await session.call('create_media_buy', params, { webhook_url: "..." }); + +// Response is immediate and complete - webhook is NOT called +{ + "status": "completed", + "media_buy_id": "mb_12345", + "packages": [...] +} +``` + +**Scenario 2: Quick async processing (no webhook - use working status)** +```javascript +// Initial response indicates processing will complete soon +const response = await session.call('create_media_buy', params, { webhook_url: "..." }); +{ + "status": "working", + "task_id": "task_789", + "message": "Creating media buy..." +} + +// Wait for synchronous response (within ~120 seconds) +// Webhook is NOT called - client should wait for the response to complete +// The call will return the final result synchronously +``` + +**Scenario 3: Long-running operation (webhook IS called)** +```javascript +// Initial request +const response = await session.call('create_media_buy', params, { + webhook_url: "https://buyer.com/webhooks/adcp/create_media_buy/agent_123/op_456" +}); + +// Response indicates long-running async operation +{ + "adcp_version": "1.6.0", + "status": "submitted", + "task_id": "task_456", + "buyer_ref": "nike_q1_campaign_2024", + "message": "Campaign requires sales approval. Expected time: 2-4 hours." +} + +// Later: Webhook POST when approval is needed +POST /webhooks/adcp/create_media_buy/agent_123/op_456 HTTP/1.1 +{ + "adcp_version": "1.6.0", + "status": "input-required", + "task_id": "task_456", + "buyer_ref": "nike_q1_campaign_2024", + "message": "Please approve $150K campaign to proceed" +} + +// Later: Webhook POST when approved and completed (full create_media_buy response) +POST /webhooks/adcp/create_media_buy/agent_123/op_456 HTTP/1.1 +{ + "adcp_version": "1.6.0", + "status": "completed", + "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" + }, + { + "package_id": "pkg_12345_002", + "buyer_ref": "nike_audio_drive_package" + } + ] +} +``` + +#### For Other Async Operations + +Each async operation posts its specific response schema: + +- **`activate_signal`** → `activate-signal-response.json` +- **`sync_creatives`** → `sync-creatives-response.json` +- **`update_media_buy`** → `update-media-buy-response.json` + +#### Webhook URL Patterns + +Structure your webhook URLs to identify the operation and agent: + +``` +https://buyer.com/webhooks/adcp/{task_name}/{agent_id}/{operation_id} +``` + +**Example URLs:** +- `https://buyer.com/webhooks/adcp/create_media_buy/agent_abc/op_xyz` +- `https://buyer.com/webhooks/adcp/activate_signal/agent_abc/op_123` +- `https://buyer.com/webhooks/adcp/sync_creatives/agent_abc/op_456` + +Your webhook handler can parse the URL path to route to the correct handler based on the task name. + +#### Webhook Payload Structure + +Every webhook POST contains the complete task response for that status, matching the task's response schema. + +**`input-required` webhook (human needs to respond):** +```json +{ + "adcp_version": "1.6.0", + "status": "input-required", + "task_id": "task_456", + "buyer_ref": "nike_q1_campaign_2024", + "message": "Campaign budget requires VP approval to proceed" +} +``` + +**`completed` webhook (operation finished - full create_media_buy response):** +```json +{ + "adcp_version": "1.6.0", + "status": "completed", + "media_buy_id": "mb_12345", + "buyer_ref": "nike_q1_campaign_2024", + "creative_deadline": "2024-01-30T23:59:59Z", + "packages": [ + { + "package_id": "pkg_001", + "buyer_ref": "nike_ctv_package" + } + ] +} +``` + +**`failed` webhook (operation failed):** +```json +{ + "adcp_version": "1.6.0", + "status": "failed", + "task_id": "task_456", + "buyer_ref": "nike_q1_campaign_2024", + "errors": [ + { + "code": "insufficient_inventory", + "message": "Requested targeting yielded 0 available impressions", + "suggestion": "Broaden geographic targeting or increase budget" + } + ] +} +``` + +**Key principle:** Webhooks are ONLY called for `submitted` operations, and each webhook contains the full response object matching the task's response schema. + ### Task State Reconciliation Use `tasks/list` to recover from lost state: @@ -386,6 +574,379 @@ AdCP webhooks use **at-least-once delivery** semantics with the following charac - **May arrive out of order**: Later events could arrive before earlier ones - **Timeout behavior**: Webhook delivery has limited retry attempts and timeouts +### Security + +#### Webhook Authentication (Required) + +**AdCP adopts A2A's PushNotificationConfig structure** for webhook configuration. This provides a standard, flexible authentication model that supports multiple security schemes. + +**Configuration Structure (A2A-Compatible):** +```json +{ + "push_notification_config": { + "url": "https://buyer.example.com/webhooks/adcp", + "authentication": { + "schemes": ["Bearer"], + "credentials": "secret_token_min_32_chars" + } + } +} +``` + +**Supported Authentication Schemes:** + +1. **Bearer Token (Simple, Recommended for Development)** + ```json + { + "authentication": { + "schemes": ["Bearer"], + "credentials": "secret_token_32_chars" + } + } + ``` + +2. **HMAC Signature (Enterprise, Recommended for Production)** + ```json + { + "authentication": { + "schemes": ["HMAC-SHA256"], + "credentials": "shared_secret_32_chars" + } + } + ``` + +**Publisher Implementation (Bearer):** +```javascript +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):** +```javascript +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):** +```javascript +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):** +```javascript +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' }); +}); +``` + +**Authentication Best Practices:** +- **Bearer tokens**: Simple, good for development and testing +- **HMAC signatures**: Prevents replay attacks, recommended for production +- Credentials exchanged out-of-band (during publisher onboarding) +- Minimum 32 characters for all credentials +- Store securely (environment variables, secret management) +- Support credential rotation (accept old and new during transition) + +### Retry and Circuit Breaker Patterns + +Publishers MUST implement retry logic with circuit breakers to handle temporary buyer endpoint failures without overwhelming systems or accumulating unbounded queues. + +#### Retry Strategy + +Publishers SHOULD use exponential backoff with jitter for webhook delivery retries: + +```javascript +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)); + } + + async sendWebhook(url, payload) { + return axios.post(url, payload, { + timeout: 10000, // 10 second timeout + headers: { + 'Content-Type': 'application/json', + 'X-ADCP-Signature': this.signPayload(payload), + 'X-ADCP-Timestamp': new Date().toISOString() + } + }); + } +} +``` + +**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 when buyer endpoints are down: + +```javascript +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}`); + + // Alert monitoring system + this.alertMonitoring(); + } + } + + alertMonitoring() { + // Notify operations team that endpoint is down + console.error(`ALERT: Webhook endpoint ${this.endpoint} is unreachable`); + // Send to monitoring system (e.g., PagerDuty, Datadog) + } + + isOpen() { + return this.state === 'OPEN'; + } +} + +// Usage with webhook delivery +class WebhookManager { + constructor() { + this.circuitBreakers = new Map(); + this.maxQueueSize = 1000; // Per endpoint + this.queues = new Map(); + } + + getCircuitBreaker(endpoint) { + if (!this.circuitBreakers.has(endpoint)) { + this.circuitBreakers.set(endpoint, new CircuitBreaker(endpoint)); + } + return this.circuitBreakers.get(endpoint); + } + + async sendWebhook(endpoint, payload) { + const breaker = this.getCircuitBreaker(endpoint); + + // Check circuit breaker before queuing + if (breaker.isOpen()) { + console.warn(`Dropping webhook for ${endpoint} - circuit breaker OPEN`); + return { success: false, reason: 'circuit_breaker_open' }; + } + + // Check queue size limit + const queue = this.queues.get(endpoint) || []; + if (queue.length >= this.maxQueueSize) { + console.error(`Dropping webhook for ${endpoint} - queue full (${queue.length})`); + return { success: false, reason: 'queue_full' }; + } + + // Attempt delivery through circuit breaker + try { + return await breaker.execute(async () => { + const delivery = new WebhookDelivery(); + return await delivery.deliverWithRetry(endpoint, payload); + }); + } catch (error) { + return { success: false, reason: error.message }; + } + } +} +``` + +**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 + +**Why Circuit Breakers Matter:** +At Yahoo scale with thousands of campaigns, a single buyer endpoint being down could queue millions of webhooks. Circuit breakers prevent this by failing fast and dropping webhooks when endpoints are unreachable. + +#### Queue Management + +Publishers SHOULD implement bounded queues with overflow policies: + +```javascript +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); + } + + dequeue() { + return this.queue.shift(); + } + + size() { + return this.queue.length; + } + + getDroppedCount() { + return this.droppedCount; + } +} +``` + +**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) + ### Implementation Requirements #### Idempotent Webhook Handlers diff --git a/docs/protocols/mcp-guide.md b/docs/protocols/mcp-guide.md index f95c2a781..eb972bb8a 100644 --- a/docs/protocols/mcp-guide.md +++ b/docs/protocols/mcp-guide.md @@ -154,10 +154,9 @@ class McpAdcpSession { request.context_id = this.contextId; } - // Include webhook configuration (protocol-level) - if (options.webhook_url) { - request.webhook_url = options.webhook_url; - request.webhook_auth = options.webhook_auth; + // Include webhook configuration (protocol-level, A2A-compatible) + if (options.push_notification_config) { + request.push_notification_config = options.push_notification_config; } const response = await this.mcp.call(request); @@ -236,15 +235,20 @@ const refined = await session.call('get_products', { #### Async Operations with Webhooks ```javascript // Create media buy with webhook configuration -const response = await session.call('create_media_buy', +const response = await session.call('create_media_buy', { buyer_ref: "nike_q1_2025", packages: [...], budget: { total: 150000, currency: "USD" } }, { - webhook_url: "https://buyer.com/webhooks/adcp", - webhook_auth: { type: "bearer", credentials: "secret_token" } + push_notification_config: { + url: "https://buyer.com/webhooks/adcp", + authentication: { + schemes: ["HMAC-SHA256"], // or ["Bearer"] for simple auth + credentials: "shared_secret_32_chars" + } + } } ); diff --git a/docs/protocols/protocol-comparison.md b/docs/protocols/protocol-comparison.md index 756c699bd..f3b65214a 100644 --- a/docs/protocols/protocol-comparison.md +++ b/docs/protocols/protocol-comparison.md @@ -111,10 +111,15 @@ const updates = await session.call('tasks/get', { include_result: true }); -// Optional: Configure webhook at protocol level +// Optional: Configure webhook at protocol level (A2A-compatible structure) const response = await session.call('create_media_buy', params, { - webhook_url: "https://buyer.com/webhooks", - webhook_auth: { type: "bearer", credentials: "token" } + push_notification_config: { + url: "https://buyer.com/webhooks", + authentication: { + schemes: ["HMAC-SHA256"], // or ["Bearer"] + credentials: "shared_secret_32_chars" + } + } }); ``` @@ -140,7 +145,10 @@ await a2a.send({ message: { /* skill invocation */ }, push_notification_config: { webhook_url: "https://buyer.com/webhooks", - auth: { type: "bearer", credentials: "token" } + authentication: { + schemes: ["Bearer"], + credentials: "secret_token_min_32_chars" + } } }); ``` @@ -153,34 +161,38 @@ Both protocols support webhooks but with different implementation approaches: #### MCP: Protocol Wrapper Extension ```javascript -// Webhook config at protocol level (like context_id) +// AdCP uses A2A-compatible structure for MCP as well class McpAdcpSession { async call(tool, params, options = {}) { const request = { tool, arguments: params }; - - if (options.webhook_url) { - request.webhook_url = options.webhook_url; - request.webhook_auth = options.webhook_auth; + + // Same structure as A2A - no mapping needed + if (options.push_notification_config) { + request.push_notification_config = options.push_notification_config; } - + return await this.mcp.call(request); } } ``` -#### A2A: Native Push Notifications +#### A2A: Native Push Notifications ```javascript -// Built-in PushNotificationConfig +// Built-in PushNotificationConfig - AdCP uses this structure universally await a2a.send({ message: { /* task */ }, push_notification_config: { - webhook_url: "https://buyer.com/webhooks", - auth: { type: "bearer", credentials: "token" }, - events: ["state_change", "completion"] + url: "https://buyer.com/webhooks", + authentication: { + schemes: ["HMAC-SHA256"], // or ["Bearer"] + credentials: "shared_secret_32_chars" + } } }); ``` +**Key Insight:** AdCP adopts A2A's `PushNotificationConfig` structure as the universal webhook configuration format across all protocols. This eliminates protocol-specific mapping and provides a consistent developer experience. + ### Task Management Both protocols now provide equivalent task management capabilities: diff --git a/docs/protocols/task-management.md b/docs/protocols/task-management.md index bb74d9424..fec028492 100644 --- a/docs/protocols/task-management.md +++ b/docs/protocols/task-management.md @@ -446,34 +446,140 @@ await a2a.send({ Task management integrates with protocol-level webhook configuration for push notifications. -### Webhook Events +### Webhook Configuration -AdCP sends webhook notifications for task status changes: +Configure webhooks at the protocol level when making async task calls. See **[Core Concepts: Protocol-Level Webhook Configuration](./core-concepts.md#protocol-level-webhook-configuration)** for complete setup examples. + +**Quick example:** +```javascript +const response = await session.call('create_media_buy', + { /* task params */ }, + { + push_notification_config: { + url: "https://buyer.com/webhooks/adcp/create_media_buy/agent_id/operation_id", + authentication: { + schemes: ["HMAC-SHA256"], // or ["Bearer"] for simple auth + credentials: "shared_secret_32_chars" + } + } + } +); +``` + +### Webhook POST Format + +When a task's status changes, the publisher POSTs the **complete task response object** to your webhook URL. + +**Webhook trigger rule:** Webhooks are ONLY used when the initial response status is `submitted` (long-running operations). + +**When webhooks are NOT triggered:** +- Initial response is `completed`, `failed`, or `rejected` → Synchronous response, client already has result +- Initial response is `working` → Will complete synchronously within ~120 seconds, client should wait for response + +**When webhooks ARE triggered (for `submitted` operations only):** +- Status changes to `input-required` → Webhook called (alerts that human input needed) +- Status changes to `completed` → Webhook called (final result available) +- Status changes to `failed` → Webhook called (error details provided) +- Status changes to `canceled` → Webhook called (cancellation confirmed) + +**Example: `input-required` webhook (human approval needed):** +```http +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 -```json { - "event_type": "task_status_changed", + "adcp_version": "1.6.0", + "status": "input-required", "task_id": "task_456", - "previous_status": "working", - "current_status": "completed", - "timestamp": "2025-01-22T10:25:00Z", - "task_type": "create_media_buy", - "domain": "media-buy", - "context": { - "buyer_ref": "nike_q1_2025" - }, - "result": { - // Included for completed tasks - "media_buy_id": "mb_987654321" - } + "buyer_ref": "nike_q1_campaign_2024", + "message": "Campaign budget $150K requires VP approval to proceed" } ``` +**Example: `completed` webhook (after approval granted - full create_media_buy response):** +```http +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 + +{ + "adcp_version": "1.6.0", + "status": "completed", + "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_package" } + ] +} +``` + +The webhook receives the **full response object** for each status, not just a notification. This means your webhook handler gets all the context and data needed to take appropriate action. + +### Webhook Handling Example + +```javascript +app.post('/webhooks/adcp/:task_type/:agent_id/:operation_id', async (req, res) => { + const { task_type, agent_id, operation_id } = req.params; + const response = req.body; + + // Webhooks are only called for 'submitted' operations + // So we only need to handle status changes that occur after submission + switch (response.status) { + case 'input-required': + // Alert human that input is needed + await notifyHuman({ + operation_id, + message: response.message, + context_id: response.context_id, + approval_data: response.data + }); + break; + + case 'completed': + // Process the completed operation + if (task_type === 'media_buy') { + await handleMediaBuyCreated({ + media_buy_id: response.media_buy_id, + buyer_ref: response.buyer_ref, + packages: response.packages, + creative_deadline: response.creative_deadline + }); + } + break; + + case 'failed': + // Handle failure + await handleOperationFailed({ + operation_id, + error: response.error, + message: response.message + }); + break; + + case 'canceled': + // Handle cancellation + await handleOperationCanceled(operation_id, response.message); + break; + } + + res.status(200).json({ status: 'processed' }); +}); +``` + ### Webhook Reliability **Important**: Webhooks use at-least-once delivery semantics and may be duplicated or arrive out of order. -See **[Core Concepts: Webhook Reliability](./core-concepts.md#webhook-reliability)** for detailed implementation guidance including idempotent handlers, sequence handling, security considerations, and polling as backup. +See **[Core Concepts: Webhook Reliability](./core-concepts.md#webhook-reliability)** for detailed implementation guidance including: +- Idempotent webhook handlers +- Sequence handling and out-of-order detection +- Security considerations (signature verification) +- Polling as backup mechanism +- Replay attack prevention ## Error Handling diff --git a/docs/signals/tasks/activate_signal.md b/docs/signals/tasks/activate_signal.md index 82ddf853e..05c21f7e5 100644 --- a/docs/signals/tasks/activate_signal.md +++ b/docs/signals/tasks/activate_signal.md @@ -185,10 +185,46 @@ data: {"status": {"state": "completed"}, "artifacts": [{ ``` ### Protocol Transport -- **MCP**: Returns task_id for polling-based asynchronous operation tracking +- **MCP**: Returns task_id for polling-based asynchronous operation tracking or webhook-based push notifications - **A2A**: Uses Server-Sent Events for real-time progress updates and completion - **Data Consistency**: Both protocols contain identical AdCP data structures and version information +### Webhook Support + +For long-running activations (when initial response is `submitted`), configure a webhook to receive the complete response when activation completes: + +```javascript +const response = await session.call('activate_signal', + { + signal_agent_segment_id: "luxury_auto_intenders", + platform: "the-trade-desk", + account: "agency-123-ttd" + }, + { + webhook_url: "https://buyer.com/webhooks/adcp/activate_signal/agent_id/op_id", + webhook_auth: { type: "bearer", credentials: "secret-token" } + } +); +``` + +When activation completes, you receive the full `activate_signal` response: + +```http +POST /webhooks/adcp/activate_signal/agent_id/op_id HTTP/1.1 +Content-Type: application/json +Authorization: Bearer secret-token + +{ + "adcp_version": "1.0.0", + "status": "deployed", + "task_id": "activation_789", + "decisioning_platform_segment_id": "ttd_agency123_lux_auto", + "deployed_at": "2025-01-15T14:30:00Z" +} +``` + +See **[Task Management: Webhook Integration](../../protocols/task-management.md#webhook-integration)** for complete details on webhook configuration and reliability. + ## Scenarios ### Initial Response (Pending) diff --git a/static/schemas/v1/core/push-notification-config.json b/static/schemas/v1/core/push-notification-config.json new file mode 100644 index 000000000..82ab52851 --- /dev/null +++ b/static/schemas/v1/core/push-notification-config.json @@ -0,0 +1,44 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/v1/core/push-notification-config.json", + "title": "Push Notification Config", + "description": "Webhook configuration for asynchronous task notifications. Uses A2A-compatible PushNotificationConfig structure. Supports Bearer tokens (simple) or HMAC signatures (production-recommended).", + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "Webhook endpoint URL for task status notifications" + }, + "token": { + "type": "string", + "description": "Optional client-provided token for webhook validation. Echoed back in webhook payload to validate request authenticity.", + "minLength": 16 + }, + "authentication": { + "type": "object", + "description": "Authentication configuration for webhook delivery (A2A-compatible)", + "properties": { + "schemes": { + "type": "array", + "description": "Array of authentication schemes. Supported: ['Bearer'] for simple token auth, ['HMAC-SHA256'] for signature verification (recommended for production)", + "items": { + "type": "string", + "enum": ["Bearer", "HMAC-SHA256"] + }, + "minItems": 1, + "maxItems": 1 + }, + "credentials": { + "type": "string", + "description": "Credentials for authentication. For Bearer: token sent in Authorization header. For HMAC-SHA256: shared secret used to generate signature. Minimum 32 characters. Exchanged out-of-band during onboarding.", + "minLength": 32 + } + }, + "required": ["schemes", "credentials"], + "additionalProperties": false + } + }, + "required": ["url", "authentication"], + "additionalProperties": false +} diff --git a/static/schemas/v1/media-buy/create-media-buy-request.json b/static/schemas/v1/media-buy/create-media-buy-request.json index 41f211cc1..ced19ac26 100644 --- a/static/schemas/v1/media-buy/create-media-buy-request.json +++ b/static/schemas/v1/media-buy/create-media-buy-request.json @@ -76,40 +76,32 @@ "$ref": "/schemas/v1/core/budget.json" }, "reporting_webhook": { - "type": "object", - "description": "Optional webhook configuration for automated reporting delivery", - "properties": { - "url": { - "type": "string", - "format": "uri", - "description": "Webhook endpoint URL for reporting notifications" + "allOf": [ + { + "$ref": "/schemas/v1/core/push-notification-config.json" }, - "auth_type": { - "type": "string", - "enum": ["bearer", "basic", "none"], - "description": "Authentication type for webhook requests" - }, - "auth_token": { - "type": "string", - "description": "Authentication token or credentials (format depends on auth_type)" - }, - "reporting_frequency": { - "type": "string", - "enum": ["hourly", "daily", "monthly"], - "description": "Frequency for automated reporting delivery. Must be supported by all products in the media buy." - }, - "requested_metrics": { - "type": "array", - "description": "Optional list of metrics to include in webhook notifications. If omitted, all available metrics are included. Must be subset of product's available_metrics.", - "items": { - "type": "string", - "enum": ["impressions", "spend", "clicks", "ctr", "video_completions", "completion_rate", "conversions", "viewability", "engagement_rate"] + { + "type": "object", + "description": "Optional webhook configuration for automated reporting delivery. Uses push_notification_config structure with additional reporting-specific fields.", + "properties": { + "reporting_frequency": { + "type": "string", + "enum": ["hourly", "daily", "monthly"], + "description": "Frequency for automated reporting delivery. Must be supported by all products in the media buy." + }, + "requested_metrics": { + "type": "array", + "description": "Optional list of metrics to include in webhook notifications. If omitted, all available metrics are included. Must be subset of product's available_metrics.", + "items": { + "type": "string", + "enum": ["impressions", "spend", "clicks", "ctr", "video_completions", "completion_rate", "conversions", "viewability", "engagement_rate"] + }, + "uniqueItems": true + } }, - "uniqueItems": true + "required": ["reporting_frequency"] } - }, - "required": ["url", "auth_type", "reporting_frequency"], - "additionalProperties": false + ] } }, "required": ["buyer_ref", "packages", "promoted_offering", "start_time", "end_time", "budget"], diff --git a/static/schemas/v1/media-buy/get-media-buy-delivery-response.json b/static/schemas/v1/media-buy/get-media-buy-delivery-response.json index 15f444a8a..99c3cd513 100644 --- a/static/schemas/v1/media-buy/get-media-buy-delivery-response.json +++ b/static/schemas/v1/media-buy/get-media-buy-delivery-response.json @@ -12,8 +12,17 @@ }, "notification_type": { "type": "string", - "enum": ["scheduled", "final", "delayed"], - "description": "Type of webhook notification (only present in webhook deliveries): scheduled = regular periodic update, final = campaign completed, delayed = data not yet available" + "enum": ["scheduled", "final", "delayed", "adjusted"], + "description": "Type of webhook notification (only present in webhook deliveries): scheduled = regular periodic update, final = campaign completed, delayed = data not yet available, adjusted = resending period with updated data" + }, + "partial_data": { + "type": "boolean", + "description": "Indicates if any media buys in this webhook have missing/delayed data (only present in webhook deliveries)" + }, + "unavailable_count": { + "type": "integer", + "minimum": 0, + "description": "Number of media buys with reporting_delayed or failed status (only present in webhook deliveries when partial_data is true)" }, "sequence_number": { "type": "integer", @@ -27,17 +36,17 @@ }, "reporting_period": { "type": "object", - "description": "Date range for the report", + "description": "Date range for the report. All periods use UTC timezone.", "properties": { "start": { "type": "string", "format": "date-time", - "description": "ISO 8601 start timestamp" + "description": "ISO 8601 start timestamp in UTC (e.g., 2024-02-05T00:00:00Z)" }, "end": { "type": "string", "format": "date-time", - "description": "ISO 8601 end timestamp" + "description": "ISO 8601 end timestamp in UTC (e.g., 2024-02-05T23:59:59Z)" } }, "required": ["start", "end"], @@ -97,8 +106,21 @@ }, "status": { "type": "string", - "description": "Current media buy status", - "enum": ["pending", "active", "paused", "completed", "failed"] + "description": "Current media buy status. In webhook context, reporting_delayed indicates data temporarily unavailable.", + "enum": ["pending", "active", "paused", "completed", "failed", "reporting_delayed"] + }, + "message": { + "type": "string", + "description": "Human-readable message (typically present when status is reporting_delayed or failed)" + }, + "expected_availability": { + "type": "string", + "format": "date-time", + "description": "When delayed data is expected to be available (only present when status is reporting_delayed)" + }, + "is_adjusted": { + "type": "boolean", + "description": "Indicates this delivery contains updated data for a previously reported period. Buyer should replace previous period data with these totals." }, "totals": { "type": "object", diff --git a/static/schemas/v1/media-buy/sync-creatives-request.json b/static/schemas/v1/media-buy/sync-creatives-request.json index 3b7f4f819..a6bfc20dd 100644 --- a/static/schemas/v1/media-buy/sync-creatives-request.json +++ b/static/schemas/v1/media-buy/sync-creatives-request.json @@ -53,6 +53,10 @@ "enum": ["strict", "lenient"], "default": "strict", "description": "Validation strictness. 'strict' fails entire sync on any validation error. 'lenient' processes valid creatives and reports errors." + }, + "push_notification_config": { + "$ref": "/schemas/v1/core/push-notification-config.json", + "description": "Optional webhook configuration for async sync notifications. Publisher will send webhook when sync completes if operation takes longer than immediate response time (typically for large bulk operations or manual approval/HITL)." } }, "required": ["creatives"], diff --git a/static/schemas/v1/media-buy/update-media-buy-request.json b/static/schemas/v1/media-buy/update-media-buy-request.json index 955e35340..c0db0a30e 100644 --- a/static/schemas/v1/media-buy/update-media-buy-request.json +++ b/static/schemas/v1/media-buy/update-media-buy-request.json @@ -78,6 +78,10 @@ ], "additionalProperties": false } + }, + "push_notification_config": { + "$ref": "/schemas/v1/core/push-notification-config.json", + "description": "Optional webhook configuration for async update notifications. Publisher will send webhook when update completes if operation takes longer than immediate response time." } }, "oneOf": [