Ads
Create Search, Display, Performance Max and Demand Gen campaigns, manage keywords and assets, apply recommendations and query GAQL from the API.
A googleads connection lets you build Google Search, Display, Performance Max and Demand Gen campaigns, run Keyword Planner, upload Customer Match lists, choose conversion goals, act on Google's recommendations and query anything Google reporting can answer. Campaigns of every type, including Shopping and Video ones made in Google Ads, sync into the campaign tree with metrics.
#Connect
Call GET /v1/connect/googleads/ads with the profileId. Google Ads is its own OAuth connection, separate from YouTube and Google Business Profile. The first call returns an authUrl; once the user consents, the same call returns alreadyConnected: true and the accountId.
You do not need a Google Ads developer token or an API access application of your own. Accounts under a manager (MCC) account work too.
GET /v1/ads/accounts?accountId=... lists the customer ids the connection reaches. Use the id without dashes as adAccountId. GET /v1/ads/accounts/hierarchy shows how the accounts nest under managers.
| Id | What it is on Google |
|---|---|
adAccountId |
Customer id, digits only |
platformCampaignId / campaignId |
Campaign id |
platformAdSetId / adSetId |
Ad group id |
platformAdId |
Ad id. On Performance Max, Pozy lists the asset group as the ad. |
#Create a Search campaign
campaignType picks the campaign: search, display, pmax or demand_gen. A Search create needs the ad copy and keywords.
curl -X POST https://api.pozy.ai/v1/ads/create \
-H "Authorization: Bearer $POZY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: search-plumbing-austin" \
-d '{
"accountId": "66b2e19d8c3f5a7e9d0b1c3e",
"adAccountId": "1234567890",
"campaignType": "search",
"name": "Emergency plumbing - Austin",
"goal": "traffic",
"budgetAmount": 50,
"budgetType": "daily",
"headline": "24/7 Emergency Plumber",
"body": "Licensed plumbers at your door in 60 minutes. Upfront prices.",
"additionalHeadlines": [
"Same-Day Repairs",
{ "text": "Austin Plumbing Pros", "pinnedField": "HEADLINE_1" }
],
"additionalDescriptions": ["No call-out fee before 8pm. Book online in two minutes."],
"linkUrl": "https://plumbing.example.com/austin",
"keywords": [
"emergency plumber austin",
{ "text": "burst pipe repair", "matchType": "phrase" }
],
"countries": ["US"]
}'const res = await fetch("https://api.pozy.ai/v1/ads/create", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.POZY_API_KEY}`,
"Idempotency-Key": "search-plumbing-austin",
"Content-Type": "application/json",
},
body: JSON.stringify({
accountId: "66b2e19d8c3f5a7e9d0b1c3e",
adAccountId: "1234567890",
campaignType: "search",
name: "Emergency plumbing - Austin",
goal: "traffic",
budgetAmount: 50,
budgetType: "daily",
headline: "24/7 Emergency Plumber",
body: "Licensed plumbers at your door in 60 minutes. Upfront prices.",
additionalHeadlines: ["Same-Day Repairs", { text: "Austin Plumbing Pros", pinnedField: "HEADLINE_1" }],
additionalDescriptions: ["No call-out fee before 8pm. Book online in two minutes."],
linkUrl: "https://plumbing.example.com/austin",
keywords: ["emergency plumber austin", { text: "burst pipe repair", matchType: "phrase" }],
countries: ["US"],
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
console.log(data.ad.platformAdSetId); // the ad group idimport os, requests
resp = requests.post(
"https://api.pozy.ai/v1/ads/create",
headers={
"Authorization": f"Bearer {os.environ['POZY_API_KEY']}",
"Idempotency-Key": "search-plumbing-austin",
},
json={
"accountId": "66b2e19d8c3f5a7e9d0b1c3e",
"adAccountId": "1234567890",
"campaignType": "search",
"name": "Emergency plumbing - Austin",
"goal": "traffic",
"budgetAmount": 50,
"budgetType": "daily",
"headline": "24/7 Emergency Plumber",
"body": "Licensed plumbers at your door in 60 minutes. Upfront prices.",
"additionalHeadlines": ["Same-Day Repairs", {"text": "Austin Plumbing Pros", "pinnedField": "HEADLINE_1"}],
"additionalDescriptions": ["No call-out fee before 8pm. Book online in two minutes."],
"linkUrl": "https://plumbing.example.com/austin",
"keywords": ["emergency plumber austin", {"text": "burst pipe repair", "matchType": "phrase"}],
"countries": ["US"],
},
timeout=60,
)
resp.raise_for_status()
print(resp.json()["ad"]["_id"])Amounts are in the account's currency units: on a USD account, budgetAmount: 50 with budgetType: "daily" means $50.00 per day. Google's API and GAQL reports count in micros instead, so a figure pasted from a report is off by a factor of one million.
Rules for the Search create:
goalisengagement,trafficorawareness.video_viewsand conversion goals return422.keywordsare broad match unless you send{ text, matchType }withexact,phraseorbroad. Up to 1,000 per call, 80 characters each.negativeKeywordsandcampaignNegativeKeywordstake the same shape.- The responsive search ad ends up with 3 to 15 headlines (30 characters each) and 2 to 4 descriptions (90 characters each) after
headline,body,additionalHeadlinesandadditionalDescriptionsare combined.pinnedFieldfixes one toHEADLINE_1,HEADLINE_2,HEADLINE_3,DESCRIPTION_1orDESCRIPTION_2. sitelinks,calloutsandstructuredSnippetsattach assets to the new campaign in the same call.- Every Google create starts as
pending_review. Later syncs move it toactive,rejected,paused,completedorcancelled. validateOnly: truechecks the whole request against Google without creating anything.
#Display campaigns
Send campaignType: "display", businessName (25 characters) and both images:
{
"campaignType": "display",
"businessName": "Austin Plumbing",
"headline": "Fast, fair plumbing",
"longHeadline": "Licensed Austin plumbers, same-day repairs, upfront prices",
"body": "Book online in two minutes.",
"images": {
"landscape": "https://cdn.example.com/plumbing-1200x628.jpg",
"square": "https://cdn.example.com/plumbing-1080.jpg"
}
}Google requires a landscape (1.91:1) and a square (1:1) image, JPEG or PNG up to 5,120 KB. Sending only one fails with NOT_ENOUGH_SQUARE_MARKETING_IMAGE_ASSET. longHeadline defaults to headline.
#Performance Max
Send campaignType: "pmax" with an assetGroup. The campaign, its budget and the asset group with every asset are created in one atomic request.
{
"campaignType": "pmax",
"budgetAmount": 80,
"budgetType": "daily",
"assetGroup": {
"name": "All services",
"finalUrl": "https://plumbing.example.com",
"businessName": "Austin Plumbing",
"headlines": ["Emergency plumber", "Same-day repairs", "Upfront prices"],
"longHeadline": "Licensed Austin plumbers at your door in 60 minutes",
"descriptions": ["Book online in two minutes.", "No call-out fee before 8pm."],
"images": {
"landscape": ["https://cdn.example.com/pm-1200x628.jpg"],
"square": ["https://cdn.example.com/pm-1080.jpg"],
"logo": ["https://cdn.example.com/logo-512.png"]
},
"youtubeVideoId": "dQw4w9WgXcQ"
}
}| Rule | Detail |
|---|---|
| Starts paused | Google will not create a Performance Max campaign that is already running. Once it passes review, switch it on with PUT /v1/ads/campaigns/{campaignId}/status. |
| Daily budget only | Send budgetType: "daily"; nothing else is accepted. |
| Bidding | Either maximize conversions or maximize conversion value. Bid caps and portfolio strategies get an error. |
| Targeting | Locations, excluded locations and languages only. No geo means everywhere. |
| Asset minimums | At least 3 headlines, 2 descriptions, and landscape, square and logo images. |
After launch, manage asset groups under /v1/ads/campaigns/{campaignId}/asset-groups: list them with Google's ad strength (POOR to EXCELLENT), add groups, edit URLs and status, link and unlink assets in one atomic request, and replace the retail listing-group tree for Merchant Center campaigns. An asset group's status is separate from its campaign's: an enabled group in a paused campaign spends nothing.
#Demand Gen
Demand Gen serves on YouTube (in-stream, in-feed, Shorts), Discover, Gmail and display partners. Send campaignType: "demand_gen" with a demandGen object. The format follows from what you send:
| Format | Selected by | Creative |
|---|---|---|
| Multi-asset image | Default | 1 to 5 headlines (40 characters), 1 to 5 descriptions (90), businessName, landscape or square images plus a logo |
| Video | youtubeVideoIds (1 to 5) |
headlines, longHeadlines (required here), descriptions, businessName, one logo |
| Carousel | carouselCards (2 to 10) |
One headline, description and logo; each card has its own headline and image |
To restrict where a Demand Gen campaign shows, list channels in demandGen.channels, choosing from youtube_in_stream, youtube_in_feed, youtube_shorts, discover, gmail and display. Describe the audience in demandGen.audience (user lists, interests, custom audiences, age ranges, genders), or point demandGen.audienceId at one you already have. These campaigns are created paused, and their locations and languages are set per ad group. The same create endpoint adds more: an ad group with existingCampaignId, an ad with adSetId.
#Bidding and budgets
bidStrategy uses the same names as the other networks and maps to Google's strategies:
bidStrategy |
With | Google strategy |
|---|---|---|
LOWEST_COST_WITHOUT_CAP |
Maximize conversions | |
COST_CAP |
bidAmount |
Target CPA |
LOWEST_COST_WITH_MIN_ROAS |
roasAverageFloor |
Target ROAS |
LOWEST_COST_WITH_BID_CAP |
bidAmount |
Maximize clicks with a CPC ceiling |
Other ways to bid, in place of bidStrategy: a shared portfolio strategy through portfolioBidStrategyId (ids come from GET /v1/ads/bid-strategies), targetImpressionShare for Search, and manualCpc: { maxCpc } for Search or Display. Use networkSettings to include or exclude search partners and the display network.
Google budgets are daily. PUT /v1/ads/campaigns/{campaignId} with budget changes one. A shared budget changes every campaign on it, so the call returns 409 unless you send allowSharedBudgetUpdate: true. sharedBudgetId moves a campaign onto a budget from GET /v1/ads/shared-budgets.
#Keywords
| Job | Call |
|---|---|
| Read the keywords your ad groups run, with quality score and 30-day metrics | GET /v1/ads/keywords (filters: campaignId, adSetId, status, matchType, negative, search) |
| Add keywords to an ad group | POST /v1/ads/keywords with adSetId and keywords (negative: true for negatives) |
| Pause or enable one keyword | PATCH /v1/ads/keywords/{keywordId} |
| Remove one | DELETE /v1/ads/keywords/{keywordId} |
| Replace a campaign's negatives | PUT /v1/ads/campaigns/{campaignId}/negative-keywords (up to 1,000) |
| Shared negative lists | /v1/ads/accounts/negative-keyword-lists (up to 5,000 keywords each, 20 lists per campaign) |
| Keyword ideas | POST /v1/ads/keywords/ideas with seedKeywords, seedUrl or both |
| Historical volume and bids | POST /v1/ads/keywords/historical-metrics (up to 1,000 keywords) |
keywordId takes Pozy's id or Google's {adSetId}~{criterionId}. A bare criterion id is rejected because it is only unique inside its ad group. The synced keyword list refreshes about once a day, so keywords added in Google Ads can take a day to appear. Keyword Planner rows come back in Google's raw shape: counters are strings and bids are micros.
#Assets, labels and accounts
- Sitelinks, callouts and structured snippets exist at account, campaign and ad group level. Create and attach them with the
/v1/ads/accounts/sitelinksfamily,POST /v1/ads/campaigns/{campaignId}/assetsorPOST /v1/ads/ad-sets/{adSetId}/assets. Updating an asset edits it everywhere it is attached. Removing only detaches it, because Google assets cannot be deleted. - Labels organize campaigns, ad groups, ads and keywords:
POST /v1/ads/labels, then attach withPOST /v1/ads/labels/{labelId}/assignments. - Manager accounts: create a client account under a manager with
POST /v1/ads/accounts, bill it to the manager's monthly invoicing withPOST /v1/ads/accounts/billing, invite a client account to a manager withPOST /v1/ads/accounts/manager-links, and accept, decline or end links withPATCH. Ads do not serve until the account has billing. - User access: invite people to an ad account by email with
POST /v1/ads/accounts/user-invitations. - Conversion goals: read and change which conversion actions a campaign bids on with
/v1/ads/campaigns/{campaignId}/conversion-goals. Sending conversions is covered in Conversions.
#Recommendations
GET /v1/ads/recommendations lists Google's suggestions (budgets, bidding targets, keywords, assets) with impact.base and impact.potential estimates. Filter by campaignId or types. POST /v1/ads/recommendations/apply applies up to 100, with Google's suggested values or your own in parameters. POST /v1/ads/recommendations/dismiss stops Google suggesting them.
Applying changes budgets, bids, keywords or assets, has no dry run and is not idempotent. Each item reports applied or failed on its own, and the call returns 200 even when some fail. Check every result.
#Targeting
Locations, languages, age, gender, the top_10 income tier, interests and Customer Match lists. Google has no city radius: use a customLocations pin instead. locationTargetingType decides whether locations reach people who are there (presence) or also people interested in them (presence_or_interest, Google's default). PUT /v1/ads/campaigns/{campaignId}/targeting changes a live campaign's devices, locations, exclusions and languages. Full rules are in Targeting.
#Reporting
| You want | Call |
|---|---|
| Spend, clicks, CPC, CPM and conversions per campaign, ad group and ad | GET /v1/ads/tree?accountId=...&platform=google |
| Budget metadata and Search impression share for a campaign | GET /v1/ads/campaigns/{campaignId}/analytics |
| The search terms that triggered your ads, by cost | GET /v1/ads/search-terms |
| Anything else Google reporting answers | GET /v1/ads/insights?accountId=...&query=SELECT ... |
| Local Services leads and their conversations | GET /v1/ads/local-services/leads |
With GET /v1/ads/insights you can run your own read-only GAQL SELECT. Rows come back exactly as Google returns them, up to 10,000 a page; pass paging.nextPageToken as pageToken for the next one. Selecting segments.date requires a bounded date range in the query, for example DURING LAST_30_DAYS.
curl -G https://api.pozy.ai/v1/ads/insights \
-H "Authorization: Bearer $POZY_API_KEY" \
--data-urlencode "accountId=66b2e19d8c3f5a7e9d0b1c3e" \
--data-urlencode "query=SELECT campaign.name, metrics.cost_micros, metrics.conversions FROM campaign WHERE segments.date DURING LAST_30_DAYS ORDER BY metrics.cost_micros DESC"Impression share fields are ratios: 0.42 is 42%. Pozy imports the last 90 days of metrics when an account connects. For older days, query GAQL directly.
#Limits and errors
Live calls to Google (GAQL, search terms, Keyword Planner, keyword and asset writes, recommendations) share a burst limit of 15 requests a minute per user and 120 across your accounts, on top of your Pozy rate limit. Cached reads (asset groups, recommendations, shared lists) carry cachedAt and stale: stale: true means Google throttled the request and you are reading the last good copy.
| Error | Cause | Fix |
|---|---|---|
422 feature_not_available |
goal: "video_views" or a conversion goal on create |
Use engagement, traffic or awareness. |
400 NOT_ENOUGH_SQUARE_MARKETING_IMAGE_ASSET |
Display create with one image | Send both images.landscape and images.square. |
400 on headlines or descriptions |
More than 15 headlines or 4 descriptions after deduplication | Trim the lists. |
409 on a budget change |
Several campaigns share this budget | Add allowSharedBudgetUpdate: true when you do want all of them changed. |
429 with details.resetsAt |
The burst limit or Google's quota | Retry after the time given. |
400 invalid_field_value on query |
Google rejected the GAQL | Google's message names the field. |
Redirects after consent can carry error=google_ads_auth_failed (the user declined) or error=google_ads_quota_exhausted (retry later).
#Endpoints
Google-only endpoints:
Assets and labels:
The shared campaign, ad and reporting endpoints are listed on the ads overview.