# Marketing Reports

> Multi-platform advertising analytics - query campaigns and performance metrics from LinkedIn, Google Ads, Meta (Facebook), and Microsoft (Bing) Ads. Use when generating marketing reports, analyzing ad spend, comparing campaign performance across platforms, or retrieving campaign data programmatically.

- Skill: `shakudo-io/marketing-reports` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add shakudo-io/marketing-reports`
- Raw SKILL.md: https://api.skillmd.com/api/skills/shakudo-io/marketing-reports/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Marketing & Growth
- License: MIT
- Author: Shakudo-io (https://skillmd.com/u/shakudo-io)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/shakudo-io/marketing-reports

---


# Marketing Reports Skill

Query advertising campaigns and performance metrics from LinkedIn Ads, Google Ads, Meta Ads (Facebook), and Microsoft Ads (Bing) using their official APIs.

## Why This Skill Exists

Marketing teams need unified access to campaign data across multiple ad platforms. This skill provides:
- Consistent data models across platforms
- Python helper scripts for common queries
- Direct API access patterns for advanced use cases

## Quick Start

```bash
# Run the unified query script (auto-loads credentials, see below)
python scripts/query_campaigns.py --platform all --days 7

# Get LinkedIn campaigns only
python scripts/query_campaigns.py --platform linkedin --status ACTIVE

# Export to JSON
python scripts/query_campaigns.py --platform google --output campaigns.json

# Explicit credential file
python scripts/query_campaigns.py --env-file /path/to/.env --platform linkedin
```

### Credential Loading (Auto-Detection)

The script automatically searches for `.env` files in this priority order:
1. **Source skill directory** (`agent-skills/marketing-reports/.env`)
2. **Installed skill directories** (`~/.claude/skills/marketing-reports/.env` or `~/.opencode/skills/marketing-reports/.env`)
3. **Current working directory** (`.env`)
4. **Explicit `--env-file` argument** (highest priority, overrides all others)

**Recommended**: After installing the skill with `openskills sync`, put credentials in the installed location (e.g., `~/.claude/skills/marketing-reports/.env`). This keeps credentials separate from source code.

---

## Prerequisites & Authentication

### Environment Variables

Create a `.env` file with credentials for each platform you use:

```bash
# LinkedIn Marketing API
LINKEDIN_ACCESS_TOKEN="your-oauth2-bearer-token"
LINKEDIN_ACCOUNT_ID="urn:li:sponsoredAccount:508860617"

# Google Ads API  
GOOGLE_ADS_DEVELOPER_TOKEN="your-developer-token"
GOOGLE_ADS_CLIENT_ID="your-oauth-client-id"
GOOGLE_ADS_CLIENT_SECRET="your-oauth-client-secret"
GOOGLE_ADS_REFRESH_TOKEN="your-refresh-token"

# Meta (Facebook) Ads API
META_APP_ID="your-app-id"
META_APP_SECRET="your-app-secret"
META_ACCESS_TOKEN="your-user-access-token"

# Microsoft (Bing) Ads API
MICROSOFT_ADS_CLIENT_ID="your-client-id"
MICROSOFT_ADS_CLIENT_SECRET="your-client-secret"
MICROSOFT_ADS_DEVELOPER_TOKEN="your-developer-token"
MICROSOFT_ADS_REFRESH_TOKEN="your-refresh-token"
MICROSOFT_ADS_CUSTOMER_ID="your-customer-id"
MICROSOFT_ADS_ACCOUNT_ID="your-account-id"
```

### Required Python Packages

```bash
pip install httpx google-ads facebook_business bingads python-dotenv
```

---

# Part 1: LinkedIn Marketing API

## API Overview

| Field | Value |
|-------|-------|
| Base URL | `https://api.linkedin.com/rest/` |
| Auth | OAuth2 Bearer token |
| Version Header | `LinkedIn-Version: 202511` |
| Rate Limit | 100 requests/day per member |

## List Campaigns

```bash
# Get all campaigns for an ad account
ACCOUNT_ID="508860617"  # Numeric ID, not URN

curl -s -X GET "https://api.linkedin.com/rest/adAccounts/${ACCOUNT_ID}/adCampaigns?q=search&count=100" \
  -H "Authorization: Bearer $LINKEDIN_ACCESS_TOKEN" \
  -H "LinkedIn-Version: 202511" \
  -H "X-Restli-Protocol-Version: 2.0.0" | jq '.'
```

**Response:**
```json
{
  "elements": [
    {
      "id": 123456789,
      "name": "Brand Awareness Q1",
      "status": "ACTIVE",
      "type": "SPONSORED_UPDATES",
      "dailyBudget": {"amount": "100.00", "currencyCode": "USD"},
      "runSchedule": {"start": 1704067200000, "end": null}
    }
  ],
  "metadata": {"nextPageToken": "..."}
}
```

## Get Campaign Analytics

```bash
# Get performance metrics for the last 7 days
ACCOUNT_URN="urn:li:sponsoredAccount:508860617"
START_DATE="2026-01-24"
END_DATE="2026-01-31"

curl -s -X GET "https://api.linkedin.com/rest/adAnalytics?q=analytics&pivot=CAMPAIGN\
&dateRange=(start:(year:2026,month:1,day:24),end:(year:2026,month:1,day:31))\
&timeGranularity=ALL\
&accounts=List(${ACCOUNT_URN})\
&fields=clicks,impressions,costInLocalCurrency,oneClickLeads,pivotValues,dateRange" \
  -H "Authorization: Bearer $LINKEDIN_ACCESS_TOKEN" \
  -H "LinkedIn-Version: 202511" \
  -H "X-Restli-Protocol-Version: 2.0.0" | jq '.'
```

**Response:**
```json
{
  "elements": [
    {
      "impressions": 15420,
      "clicks": 342,
      "costInLocalCurrency": 856.50,
      "oneClickLeads": 12,
      "pivotValues": ["urn:li:sponsoredCampaign:123456789"]
    }
  ]
}
```

## Update Campaign Status

```bash
CAMPAIGN_URN="urn:li:sponsoredCampaign:123456789"
ENCODED_URN=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$CAMPAIGN_URN', safe=''))")

# Pause a campaign
curl -s -X PATCH "https://api.linkedin.com/rest/adCampaigns/${ENCODED_URN}" \
  -H "Authorization: Bearer $LINKEDIN_ACCESS_TOKEN" \
  -H "LinkedIn-Version: 202511" \
  -H "Content-Type: application/json" \
  -d '{"patch": {"$set": {"status": "PAUSED"}}}' | jq '.'
```

## LinkedIn Status Values

| Status | Description |
|--------|-------------|
| `ACTIVE` | Campaign is running |
| `PAUSED` | Manually paused |
| `DRAFT` | Not yet launched |
| `CANCELED` | Permanently stopped |
| `COMPLETED` | End date reached |
| `ARCHIVED` | Archived for reporting |

## LinkedIn Campaign Types

| Type | Description |
|------|-------------|
| `SPONSORED_UPDATES` | Sponsored content in feed |
| `TEXT_AD` | Text ads in sidebar |
| `SPONSORED_INMAILS` | Message ads |
| `DYNAMIC` | Dynamic ads |

---

# Part 2: Google Ads API

## API Overview

| Field | Value |
|-------|-------|
| SDK | `google-ads` Python library |
| Auth | OAuth2 + Developer token |
| Version | v17 (latest) |
| Rate Limit | 15,000 requests/day |

## Initialize Client

```python
from google.ads.googleads.client import GoogleAdsClient

config = {
    "developer_token": os.environ["GOOGLE_ADS_DEVELOPER_TOKEN"],
    "client_id": os.environ["GOOGLE_ADS_CLIENT_ID"],
    "client_secret": os.environ["GOOGLE_ADS_CLIENT_SECRET"],
    "refresh_token": os.environ["GOOGLE_ADS_REFRESH_TOKEN"],
    "use_proto_plus": True,
}
client = GoogleAdsClient.load_from_dict(config)
```

## List Accessible Customers

```python
customer_service = client.get_service("CustomerService")
response = customer_service.list_accessible_customers()
customer_ids = [r.split("/")[-1] for r in response.resource_names]
print(f"Accessible accounts: {customer_ids}")
```

## Get Campaigns with Metrics

```python
ga_service = client.get_service("GoogleAdsService")
customer_id = "1234567890"  # Your customer ID

query = """
    SELECT 
        campaign.id,
        campaign.name,
        campaign.status,
        campaign.advertising_channel_type,
        campaign_budget.amount_micros,
        metrics.impressions,
        metrics.clicks,
        metrics.cost_micros,
        metrics.conversions
    FROM campaign
    WHERE campaign.status != 'REMOVED'
        AND segments.date DURING LAST_7_DAYS
    ORDER BY metrics.impressions DESC
"""

response = ga_service.search(customer_id=customer_id, query=query)

for row in response:
    campaign = row.campaign
    metrics = row.metrics
    budget = row.campaign_budget.amount_micros / 1_000_000
    spend = metrics.cost_micros / 1_000_000
    
    print(f"{campaign.name}: {metrics.impressions} impressions, ${spend:.2f} spend")
```

## Google Ads Status Values

| Status | Description |
|--------|-------------|
| `ENABLED` | Campaign is active |
| `PAUSED` | Manually paused |
| `REMOVED` | Deleted |

## Google Ads Channel Types

| Channel | Description |
|---------|-------------|
| `SEARCH` | Search network ads |
| `DISPLAY` | Display network ads |
| `SHOPPING` | Shopping campaigns |
| `VIDEO` | YouTube ads |
| `PERFORMANCE_MAX` | Automated cross-channel |

## Filter by Date Range

```python
# Custom date range
from_date = "2026-01-01"
to_date = "2026-01-31"

query = f"""
    SELECT campaign.id, campaign.name, metrics.impressions, metrics.clicks
    FROM campaign
    WHERE segments.date BETWEEN '{from_date}' AND '{to_date}'
"""
```

---

# Part 3: Meta (Facebook) Ads API

## API Overview

| Field | Value |
|-------|-------|
| SDK | `facebook_business` Python library |
| Auth | OAuth2 App token |
| Version | v24.0 |
| Rate Limit | Varies by endpoint (see docs) |

## Initialize API

```python
from facebook_business.api import FacebookAdsApi
from facebook_business.adobjects.user import User
from facebook_business.adobjects.adaccount import AdAccount
from facebook_business.adobjects.campaign import Campaign as FBCampaign

FacebookAdsApi.init(
    app_id=os.environ["META_APP_ID"],
    app_secret=os.environ["META_APP_SECRET"],
    access_token=os.environ["META_ACCESS_TOKEN"]
)
```

## List Ad Accounts

```python
me = User(fbid='me')
accounts = list(me.get_ad_accounts(fields=['account_id', 'name', 'account_status']))

for account in accounts:
    print(f"{account['name']}: act_{account['account_id']}")
```

## Get Campaigns

```python
account_id = "act_1234567890"  # Must include 'act_' prefix
account = AdAccount(account_id)

campaigns = account.get_campaigns(
    fields=[
        FBCampaign.Field.id,
        FBCampaign.Field.name,
        FBCampaign.Field.status,
        FBCampaign.Field.effective_status,
        FBCampaign.Field.objective,
        FBCampaign.Field.daily_budget,
        FBCampaign.Field.lifetime_budget,
    ]
)

for campaign in campaigns:
    daily_budget = float(campaign.get('daily_budget', 0) or 0) / 100  # Cents to dollars
    print(f"{campaign['name']}: {campaign['effective_status']}, ${daily_budget}/day")
```

## Get Campaign Insights (Analytics)

```python
from facebook_business.adobjects.adsinsights import AdsInsights

campaign = FBCampaign("123456789")  # Campaign ID

insights = campaign.get_insights(
    fields=[
        AdsInsights.Field.impressions,
        AdsInsights.Field.clicks,
        AdsInsights.Field.spend,
        AdsInsights.Field.ctr,
        AdsInsights.Field.cpc,
        AdsInsights.Field.actions,
    ],
    params={
        'date_preset': 'last_7d',  # Or 'last_30d', 'today', 'lifetime'
        'level': 'campaign',
    }
)

for insight in insights:
    print(f"Impressions: {insight['impressions']}")
    print(f"Clicks: {insight['clicks']}")
    print(f"Spend: ${insight['spend']}")
```

## Meta Status Values

| Status | Description |
|--------|-------------|
| `ACTIVE` | Campaign is running |
| `PAUSED` | Manually paused |
| `DELETED` | Removed |
| `ARCHIVED` | Archived |

## Meta Campaign Objectives

| Objective | Description |
|-----------|-------------|
| `OUTCOME_AWARENESS` | Brand awareness |
| `OUTCOME_ENGAGEMENT` | Engagement |
| `OUTCOME_LEADS` | Lead generation |
| `OUTCOME_SALES` | Conversions |
| `OUTCOME_TRAFFIC` | Website traffic |

## Date Presets for Insights

| Preset | Description |
|--------|-------------|
| `today` | Today only |
| `yesterday` | Yesterday only |
| `last_7d` | Last 7 days |
| `last_14d` | Last 14 days |
| `last_30d` | Last 30 days |
| `last_90d` | Last 90 days |
| `lifetime` | All time |

---

# Part 4: Microsoft (Bing) Ads API

## API Overview

| Field | Value |
|-------|-------|
| SDK | `bingads` Python library |
| Auth | OAuth2 + Developer token |
| Version | v13 |
| Rate Limit | Varies by operation |

## Initialize Client

```python
from bingads import AuthorizationData, OAuthWebAuthCodeGrant, ServiceClient

authentication = OAuthWebAuthCodeGrant(
    client_id=os.environ["MICROSOFT_ADS_CLIENT_ID"],
    client_secret=os.environ["MICROSOFT_ADS_CLIENT_SECRET"],
    redirection_uri="https://localhost:8080/callback",
)
authentication.request_oauth_tokens_by_refresh_token(
    os.environ["MICROSOFT_ADS_REFRESH_TOKEN"]
)

authorization_data = AuthorizationData(
    account_id=os.environ.get("MICROSOFT_ADS_ACCOUNT_ID"),
    customer_id=os.environ.get("MICROSOFT_ADS_CUSTOMER_ID"),
    developer_token=os.environ["MICROSOFT_ADS_DEVELOPER_TOKEN"],
    authentication=authentication,
)
```

## List Ad Accounts

```python
customer_service = ServiceClient(
    service='CustomerManagementService',
    version=13,
    authorization_data=authorization_data,
    environment='production',
)

accounts_response = customer_service.SearchAccounts(
    PageInfo={'Index': 0, 'Size': 100},
    Predicates=None,
)

for account in accounts_response.AdvertiserAccount:
    print(f"{account.Name}: ID={account.Id}, Status={account.AccountLifeCycleStatus}")
```

## Get Campaigns

```python
campaign_service = ServiceClient(
    service='CampaignManagementService',
    version=13,
    authorization_data=authorization_data,
    environment='production',
)

account_id = 123456789

response = campaign_service.GetCampaignsByAccountId(
    AccountId=account_id,
    CampaignType='Search Shopping Audience',
)

for campaign in response.Campaign:
    print(f"{campaign.Name}: {campaign.Status}, ${campaign.DailyBudget}/day")
```

## Microsoft Ads Status Values

| Status | Description |
|--------|-------------|
| `Active` | Campaign is running |
| `Paused` | Manually paused |
| `Deleted` | Removed |

## Microsoft Ads Campaign Types

| Type | Description |
|------|-------------|
| `Search` | Search network |
| `Shopping` | Shopping campaigns |
| `Audience` | Audience campaigns |

---

# Part 5: Unified Data Models

All platforms are normalized to these data structures:

## Campaign

```python
@dataclass
class Campaign:
    id: str                          # Platform-specific ID
    name: str                        # Campaign name
    status: str                      # ACTIVE, PAUSED, etc. (normalized)
    campaign_type: str               # Platform-specific type
    daily_budget: float              # Daily spend limit
    total_budget: Optional[float]    # Total/lifetime budget
    currency: str                    # USD, EUR, etc.
    start_date: datetime             # Campaign start
    end_date: Optional[datetime]     # Campaign end (if set)
    account_id: str                  # Parent ad account
    platform: str                    # linkedin, google, meta, microsoft
    
    # Analytics (populated from metrics)
    spend: float = 0.0
    impressions: int = 0
    clicks: int = 0
    conversions: int = 0
    
    @property
    def ctr(self) -> float:
        """Click-through rate percentage."""
        return (self.clicks / self.impressions * 100) if self.impressions > 0 else 0.0
    
    @property
    def cpc(self) -> float:
        """Cost per click."""
        return (self.spend / self.clicks) if self.clicks > 0 else 0.0
```

## CampaignAnalytics

```python
@dataclass
class CampaignAnalytics:
    campaign_id: str
    date_start: datetime
    date_end: datetime
    impressions: int = 0
    clicks: int = 0
    cost: float = 0.0
    conversions: int = 0
    conversion_value: float = 0.0
    
    @property
    def ctr(self) -> float:
        return (self.clicks / self.impressions * 100) if self.impressions > 0 else 0.0
    
    @property
    def cpc(self) -> float:
        return (self.cost / self.clicks) if self.clicks > 0 else 0.0
    
    @property
    def cpm(self) -> float:
        return (self.cost / self.impressions * 1000) if self.impressions > 0 else 0.0
    
    @property
    def conversion_rate(self) -> float:
        return (self.conversions / self.clicks * 100) if self.clicks > 0 else 0.0
    
    @property
    def cost_per_conversion(self) -> float:
        return (self.cost / self.conversions) if self.conversions > 0 else 0.0
```

---

# Part 6: Common Queries

## Get All Active Campaigns Across Platforms

```python
from scripts.query_campaigns import get_all_campaigns

# Returns unified list of Campaign objects
campaigns = get_all_campaigns(
    platforms=["linkedin", "google", "meta", "microsoft"],
    status_filter="ACTIVE",
    days=7
)

for c in campaigns:
    print(f"[{c.platform}] {c.name}: ${c.spend:.2f} spend, {c.clicks} clicks")
```

## Compare Platform Performance

```python
from collections import defaultdict

by_platform = defaultdict(lambda: {"spend": 0, "clicks": 0, "impressions": 0})

for c in campaigns:
    by_platform[c.platform]["spend"] += c.spend
    by_platform[c.platform]["clicks"] += c.clicks
    by_platform[c.platform]["impressions"] += c.impressions

for platform, metrics in by_platform.items():
    ctr = (metrics["clicks"] / metrics["impressions"] * 100) if metrics["impressions"] > 0 else 0
    print(f"{platform}: ${metrics['spend']:.2f} spend, {ctr:.2f}% CTR")
```

## Export to CSV

```python
import csv

with open("campaigns_report.csv", "w", newline="") as f:
    writer = csv.writer(f)
    writer.writerow(["Platform", "Campaign", "Status", "Spend", "Impressions", "Clicks", "CTR"])
    
    for c in campaigns:
        writer.writerow([c.platform, c.name, c.status, c.spend, c.impressions, c.clicks, f"{c.ctr:.2f}%"])
```

---

# Part 7: Error Handling

## Common Errors and Solutions

| Error | Platform | Solution |
|-------|----------|----------|
| 401 Unauthorized | All | Refresh OAuth token or check credentials |
| 429 Rate Limited | All | Wait for retry-after header duration |
| 403 Forbidden | LinkedIn | Check account permissions |
| Token expired | All | Re-authenticate via OAuth flow |
| Account disabled | Google | Contact Google support |

## Retry Logic

```python
import time
from functools import wraps

def retry_on_rate_limit(max_retries=3, base_delay=60):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(max_retries):
                try:
                    return func(*args, **kwargs)
                except RateLimitError as e:
                    if attempt < max_retries - 1:
                        delay = getattr(e, 'retry_after', base_delay)
                        print(f"Rate limited. Waiting {delay}s...")
                        time.sleep(delay)
                    else:
                        raise
            return None
        return wrapper
    return decorator
```

---

# Quick Reference

## API Endpoints Summary

| Platform | Campaigns Endpoint | Analytics Endpoint |
|----------|-------------------|-------------------|
| LinkedIn | `/rest/adAccounts/{id}/adCampaigns` | `/rest/adAnalytics?pivot=CAMPAIGN` |
| Google | GAQL: `SELECT FROM campaign` | GAQL: `SELECT metrics.* FROM campaign` |
| Meta | `AdAccount.get_campaigns()` | `Campaign.get_insights()` |
| Microsoft | `CampaignManagementService.GetCampaignsByAccountId` | ReportingService |

## Status Normalization

| Normalized | LinkedIn | Google | Meta | Microsoft |
|------------|----------|--------|------|-----------|
| ACTIVE | ACTIVE | ENABLED | ACTIVE | Active |
| PAUSED | PAUSED | PAUSED | PAUSED | Paused |
| DELETED | CANCELED | REMOVED | DELETED | Deleted |

## Metrics Field Mapping

| Metric | LinkedIn | Google | Meta | Microsoft |
|--------|----------|--------|------|-----------|
| Impressions | `impressions` | `metrics.impressions` | `impressions` | `Impressions` |
| Clicks | `clicks` | `metrics.clicks` | `clicks` | `Clicks` |
| Spend | `costInLocalCurrency` | `metrics.cost_micros / 1M` | `spend` | `Spend` |
| Conversions | `externalWebsiteConversions` | `metrics.conversions` | `actions[].value` | `Conversions` |

---

## Related Resources

- [LinkedIn Marketing API Docs](https://learn.microsoft.com/en-us/linkedin/marketing/)
- [Google Ads API Docs](https://developers.google.com/google-ads/api/docs/start)
- [Meta Marketing API Docs](https://developers.facebook.com/docs/marketing-apis/)
- [Microsoft Advertising API Docs](https://learn.microsoft.com/en-us/advertising/guides/)
- Marketing TUI source: `/root/gitrepos/business-automation-012-marketing-tui/marketing-tui/marketing-tui.py`

