Skip to content

LinkedIn Ads ​

The LinkedIn Ads source reads daily campaign analytics from the LinkedIn Marketing API, plus the current list of ad accounts, campaign groups, campaigns, and creatives for one ad account. Use it to report LinkedIn spend and results next to your other paid channels in your own warehouse.

Before you begin ​

You need a LinkedIn developer app with Advertising API access, a token for a user who can read the ad account, the ad account ID, and a destination that can rewrite daily partitions.

  1. Create an app. In the LinkedIn Developer Portal, create an app linked to your company page. Under Products, request Advertising API access and wait for approval.
  2. Get a token. Use the portal's OAuth 2.0 tools (or your own OAuth flow) to generate an access token with the r_ads and r_ads_reporting scopes, signed in as a user with at least Viewer access to the ad account in Campaign Manager. Access tokens last about 60 days. If LinkedIn has enabled refresh tokens for your app, also keep the refresh token, client ID, and client secret so Skipprd can renew the token itself.
  3. Find the ad account ID. In Campaign Manager, the account ID is the number in the account list and in the URL. Both 123456789 and urn:li:sponsoredAccount:123456789 work.
  4. Set up a destination. The analytics stream is rewritten one day at a time, so the destination must support partition replacement: Athena, Athena Iceberg, or SkipprLake. The examples below assume a data sink named lake already exists in skippr.yml.
bash
export LINKEDIN_ADS_ACCESS_TOKEN="your-access-token"

Configure ​

python
from skippr import Config, DataSourceLinkedInAds, EnvRef, Pipeline

cfg = Config.discover()
src = cfg.data_source(
    "linkedin_ads",
    DataSourceLinkedInAds(
        ad_account_id="123456789",
        start_date="2026-01-01",
        access_token=EnvRef("LINKEDIN_ADS_ACCESS_TOKEN"),
    ),
)
cfg.pipeline("linkedin_ads", Pipeline(data_source=src, data_sink=cfg.get_data_sink("lake")))
cfg.save()
bash
skipprd connect data-source linked-in-ads \
  --pipeline linkedin_ads \
  --name linkedin_ads \
  --ad-account-id 123456789 \
  --start-date 2026-01-01 \
  --access-token '${LINKEDIN_ADS_ACCESS_TOKEN}'
yaml
pipelines:
  linkedin_ads:
    data_source: data_sources.linkedin_ads
    data_sink: data_sinks.lake

data_sources:
  linkedin_ads:
    LinkedInAds:
      ad_account_id: "123456789"
      start_date: "2026-01-01"
      access_token: ${LINKEDIN_ADS_ACCESS_TOKEN}

The CLI subcommand is linked-in-ads and the YAML kind is LinkedInAds. To let Skipprd renew tokens, replace access_token with oauth_token_url: https://www.linkedin.com/oauth/v2/accessToken, oauth_client_id, oauth_client_secret, and oauth_refresh_token.

With the CLI, wire the lake destination into the same pipeline with skipprd connect data-sink … --pipeline linkedin_ads if it is not there already.

Check it worked ​

bash
skipprd discover --pipeline linkedin_ads
skipprd sync --pipeline linkedin_ads --once

Then query linkedin_ads_ad_analytics_daily in your destination. You should see one row per campaign per day from start_date through yesterday.

Options ​

KeyTypeDefaultDescription
ad_account_idstringRequiredCampaign Manager ad account ID, with or without the urn:li:sponsoredAccount: prefix.
start_datestringRequiredFirst report day, YYYY-MM-DD.
access_tokensecret—Bearer token with r_ads and r_ads_reporting. Used before OAuth refresh.
oauth_token_urlstring—Token endpoint, https://www.linkedin.com/oauth/v2/accessToken.
oauth_client_idstring—App client ID.
oauth_client_secretsecret—App client secret.
oauth_refresh_tokensecret—Refresh token.
rest_versionstring202411LinkedIn API version, sent as the LinkedIn-Version header (YYYYMM).
api_versionstring—Older name for rest_version. Used only when rest_version is not set.
end_datestring—Last report day, YYYY-MM-DD. Without it, Skipprd syncs through yesterday.
lookback_daysinteger3Days before the last completed day to fetch again on every sync, so late conversions land.
processing_lag_daysinteger1Never sync days more recent than this many days ago.
stream_profilestringfullWhich streams to sync: minimal, standard, or full.
streamslist of strings—Exact stream names to sync. Overrides stream_profile.

If you set any oauth_* field you must set all four. Secret fields only accept ${NAME} environment references (EnvRef("NAME") in Python).

What gets synced ​

Each stream lands as its own table named after the stream, with the dot replaced by an underscore.

StreamTableOne row perProfiles
linkedin_ads.ad_analytics_dailylinkedin_ads_ad_analytics_dailycampaign per dayminimal, standard, full
linkedin_ads.ad_accountslinkedin_ads_ad_accountsactive, draft, or paused ad account the token can seestandard, full
linkedin_ads.campaign_groupslinkedin_ads_campaign_groupscampaign group in the ad accountstandard, full
linkedin_ads.campaignslinkedin_ads_campaignscampaign in the ad accountstandard, full
linkedin_ads.creativeslinkedin_ads_creativescreative in the ad accountfull

ad_analytics_daily rows have account_urn, date, pivot_value and campaign_urn (the campaign), and the metrics LinkedIn returns. When LinkedIn returns costInUsd, Skipprd also writes it as spend. Listing rows keep LinkedIn's fields and add account_urn and the entity's URN column (campaign_urn, creative_urn, and so on).

Report window. The first sync requests each day from start_date to yesterday, one request per day. Each later sync starts again at the last completed day minus lookback_days and rewrites those days in full. LinkedIn attributes conversions back to the click or view, so raise lookback_days to your conversion window if you need final numbers.

Listings. The account, campaign group, campaign, and creative streams read the current list on every sync and are written append-only, with no date column. Deduplicate on the URN column in your warehouse if you need one row per entity.

Discover. skipprd discover reads only ad_analytics_daily and does not save progress. It still starts at start_date, so keep start_date recent if you want a fast first discover.

Rate limits. LinkedIn sets daily request limits per app. Skipprd retries rate-limit (429) and server (5xx) responses with exponential backoff, up to 8 attempts per request. It honours Retry-After, waits 30 seconds on a 429 without one, and never waits more than 2 minutes between attempts.

Troubleshooting ​

SymptomCauseFix
LinkedIn Ads requires access_token, LINKEDIN_ADS_ACCESS_TOKEN, or OAuth refresh credentialsNo token is set, or its environment variable is empty.Export the token in the shell or service that runs Skipprd.
LinkedIn Ads OAuth refresh requires oauth_token_url, oauth_client_id, oauth_client_secret, and oauth_refresh_tokenOnly some OAuth fields are set.Set all four, or remove them and use access_token.
HTTP 401The access token expired (after about 60 days) or was revoked.Generate a new token, or configure OAuth refresh.
HTTP 403The app lacks Advertising API access, the token lacks r_ads_reporting, or the user cannot see the ad account.Check the app's products, the token's scopes, and the user's role in Campaign Manager.
HTTP 426 or an error about the versionLinkedIn retires each monthly API version about a year after release.Set rest_version to a current YYYYMM version.
Duplicate campaigns or creatives in listing tablesListings are appended on every sync.Expected. Deduplicate on the URN column.
sink '…' does not support write policy ReplacePartitionThe destination cannot rewrite daily partitions.Use Athena, Athena Iceberg, or SkipprLake as the destination.

Next steps ​