ads-as-code

Google Search Campaigns

Build Google Search campaigns with keywords, RSA ads, bidding strategies, extensions, and network settings.

Campaigns are plain TypeScript files in your campaigns/ directory. Each file exports one or more campaign objects. The CLI scans all campaigns/**/*.ts files, collects exports with a provider and kind field, and uses them as your desired state.


Basic structure

campaigns/brand.ts
import {
  google,
  daily,
  exact,
  phrase,
  broad,
  headlines,
  descriptions,
  rsa,
  url,
  targeting,
  geo,
  languages,
  negatives,
} from '@upspawn/ads'

export default google.search('Brand - Search', {
  budget: daily(20),
  bidding: 'maximize-clicks',
  targeting: targeting(geo('US', 'CA'), languages('en')),
  negatives: negatives('free', 'download', 'tutorial'),
})
  .group('brand-exact', {
    keywords: [...exact('my product', 'my product app')],
    ad: rsa(
      headlines('My Product', 'Official Site', 'Start Free Today'),
      descriptions('The fastest way to get things done. Try free.'),
      url('https://example.com'),
    ),
  })
  .group('brand-phrase', {
    keywords: [...phrase('my product for teams', 'my product pricing')],
    ad: rsa(
      headlines('My Product for Teams', 'See Pricing', 'Free Trial'),
      descriptions('Plans for every team size. Start free today.'),
      url('https://example.com/pricing'),
    ),
  })

Budget helpers

import { daily, monthly, lifetime, eur, usd } from '@upspawn/ads'

daily(20)          // $20/day
daily(eur(15))     // €15/day
monthly(500)       // $500/month
lifetime(1000)     // $1,000 lifetime budget

All 9 bidding strategies

Google Search supports nine bidding strategies. The bidding field accepts a string shorthand or a full object for strategies that require extra parameters.

import { google, daily } from '@upspawn/ads'

// String shorthands (no extra parameters needed)
google.search('Campaign', { budget: daily(20), bidding: 'maximize-conversions' })
google.search('Campaign', { budget: daily(20), bidding: 'maximize-clicks' })
google.search('Campaign', { budget: daily(20), bidding: 'manual-cpc' })
google.search('Campaign', { budget: daily(20), bidding: 'manual-cpm' })
google.search('Campaign', { budget: daily(20), bidding: 'target-cpm' })
google.search('Campaign', { budget: daily(20), bidding: 'maximize-conversion-value' })

// Object form (required for strategies with parameters)
google.search('Campaign', {
  budget: daily(20),
  bidding: { type: 'target-cpa', targetCpa: 15 },
})

google.search('Campaign', {
  budget: daily(20),
  bidding: { type: 'target-roas', targetRoas: 3.0 },
})

google.search('Campaign', {
  budget: daily(20),
  bidding: {
    type: 'target-impression-share',
    location: 'absolute-top',  // 'anywhere' | 'top' | 'absolute-top'
    targetPercent: 80,
    maxCpc: 5.00,
  },
})

// Optional parameters on object form
google.search('Campaign', {
  budget: daily(20),
  bidding: { type: 'maximize-clicks', maxCpc: 2.50 },
})

google.search('Campaign', {
  budget: daily(20),
  bidding: { type: 'manual-cpc', enhancedCpc: true },
})

google.search('Campaign', {
  budget: daily(20),
  bidding: { type: 'maximize-conversion-value', targetRoas: 4.0 },
})

Strategy summary:

StrategyWhen to use
maximize-conversionsAutomated — maximize conversion count within budget
maximize-clicksAutomated — maximize clicks within budget (alias: TARGET_SPEND)
manual-cpcManual per-keyword bids; add enhancedCpc: true for smart adjustments
manual-cpmManual CPM bids (Display only)
target-cpmAutomated CPM targeting (Display only)
target-cpaAutomated — target a specific cost per conversion
target-roasAutomated — target a return-on-ad-spend ratio
target-impression-shareAutomated — achieve a share of impression at absolute-top, top, or anywhere
maximize-conversion-valueAutomated — maximize conversion value; add targetRoas to constrain it

Keyword match types

import { exact, phrase, broad } from '@upspawn/ads'

exact('keyword one', 'keyword two')    // [keyword one], [keyword two]
phrase('keyword phrase')               // "keyword phrase"
broad('keyword term')                  // keyword term (broad match)

Mix match types in one group by spreading into an array:

keywords: [
  ...exact('file renamer', 'batch rename files'),
  ...phrase('rename files automatically'),
  ...broad('file organization tool'),
]

RSA ads

Responsive Search Ads require 3–15 headlines (max 30 chars each) and 2–4 descriptions (max 90 chars each). Google rotates combinations to find best performers.

import { headlines, descriptions, rsa, url } from '@upspawn/ads'

rsa(
  headlines(
    'Rename Files Fast',          // ≤ 30 chars each
    'AI-Powered File Renaming',
    'Batch Rename Tool',
    'No Credit Card Required',
    'Used by 50,000 Teams',
  ),
  descriptions(
    'Rename thousands of files in seconds. Drag, drop, done.',   // ≤ 90 chars
    'Free plan available. Works with photos, docs, and more.',
  ),
  url('https://example.com/file-renamer'),
  {
    path1: 'rename',    // shown in URL bar: example.com/rename/files
    path2: 'files',
  },
)

RSA identity is content-based. The SDK tracks ads by a hash of their sorted headlines + descriptions + URL. Changing any headline creates a new ad and removes the old one. If you want to update ad copy without losing history, import the existing ad first.

Multiple ads per group

Put multiple RSAs in one group for A/B testing. Google will rotate them and allocate more impressions to better performers:

.group('features', {
  keywords: [...exact('file renamer app')],
  ad: [
    rsa(
      headlines('Rename Files Fast', 'AI File Renamer', 'Batch Rename Tool'),
      descriptions('Drag, drop, rename. Done in seconds.', 'Free plan available.'),
      url('https://example.com'),
    ),
    rsa(
      headlines('Stop Renaming Files Manually', 'AI Does It for You', 'Batch Rename'),
      descriptions('Let AI rename your files automatically.', 'Try free — no card needed.'),
      url('https://example.com/ai'),
    ),
  ],
})

Pinned headlines

Pin a headline to a specific position to guarantee it always appears there:

rsa(
  headlines('Official Site', 'AI File Renamer', 'Batch Rename Tool'),
  descriptions('Try free. No credit card.', 'Rename files in seconds.'),
  url('https://example.com'),
  {
    pinnedHeadlines: [{ text: 'Official Site', position: 1 }],
  },
)

Positions 1–3 correspond to the three headline slots. Pinning reduces Google's flexibility to test combinations, so use it sparingly.


Network settings

By default, Search campaigns serve on Google Search and search partners. Use networkSettings to control this:

import { google, daily, targeting, geo } from '@upspawn/ads'

google.search('Brand - Search Only', {
  budget: daily(20),
  bidding: 'maximize-conversions',
  targeting: targeting(geo('US')),
  networkSettings: {
    searchNetwork: true,
    searchPartners: false,   // disable search partner sites
    displayNetwork: false,   // disable Display Network expansion
  },
})
SettingDefaultWhat it controls
searchNetworktrueServe on google.com and Google Search
searchPartnerstrueServe on Google search partner sites (e.g. Ask.com)
displayNetworkfalseServe on the Display Network as a fallback

Device bid adjustments

Increase or decrease bids for specific devices. A value of 0.2 means +20%, -1.0 means exclude entirely:

import { google, daily, targeting, geo, device } from '@upspawn/ads'

google.search('Search - Mobile Focus', {
  budget: daily(30),
  bidding: 'maximize-conversions',
  targeting: targeting(
    geo('US'),
    device('mobile', 0.3),    // +30% on mobile
    device('desktop', 0.0),   // no adjustment on desktop
    device('tablet', -0.5),   // -50% on tablet
  ),
})

To exclude a device entirely, set its bid adjustment to -1.0.


Extensions

Extensions appear below your ad text. Add them with chained methods after .group().

import { google, daily, link } from '@upspawn/ads'

google.search('Search Campaign', { budget: daily(20), bidding: 'maximize-clicks' })
  .group('keywords', { /* ... */ })
  .sitelinks(
    link('Pricing', 'https://example.com/pricing', {
      description1: 'Plans for every team size',
      description2: 'Free plan available',
    }),
    link('Features', 'https://example.com/features'),
    link('Blog', 'https://example.com/blog'),
    link('Sign Up', 'https://example.com/signup'),
  )

Callouts

Short phrases (max 25 chars each) that highlight your benefits:

.callouts('Free Trial', 'No Credit Card', 'Cancel Anytime', '24/7 Support')

Structured snippets

import { google, daily } from '@upspawn/ads'

google.search('Campaign', { budget: daily(20), bidding: 'maximize-clicks' })
  .group('keywords', { /* ... */ })
  .snippets(
    { header: 'Services', values: ['File Renaming', 'Batch Processing', 'AI Rules'] },
  )

Campaign-level negatives

Negatives prevent your ads from showing on irrelevant searches:

import { google, daily, targeting, geo, negatives, broad, phrase } from '@upspawn/ads'

google.search('File Renamer - Search', {
  budget: daily(20),
  bidding: 'maximize-conversions',
  targeting: targeting(geo('US')),
  negatives: [
    ...broad('free', 'open source', 'freeware'),
    ...phrase('how to rename files manually'),
  ],
})

Ad-group-level negatives are also supported:

.group('premium-features', {
  keywords: [...exact('file renamer pro')],
  negatives: [...exact('free file renamer')],
  ad: rsa(/* ... */),
})

Multi-locale campaigns

Use .locale() to add ad groups with targeting overrides — ideal for multilingual campaigns where each locale needs different geo and language targeting:

campaigns/search-global.ts
import {
  google, daily, phrase, headlines, descriptions, rsa, url,
  targeting, geo, languages,
} from '@upspawn/ads'

export default google.search('File Renamer - Global', {
  budget: daily(40),
  bidding: 'maximize-conversions',
})
  .locale('en-us', targeting(geo('US', 'CA'), languages('en')), {
    keywords: [...phrase('file renamer', 'rename files')],
    ad: rsa(
      headlines('Rename Files Fast', 'AI File Renamer', 'Batch Rename Tool'),
      descriptions('Rename thousands of files in seconds.', 'Free plan available.'),
      url('https://example.com'),
    ),
  })
  .locale('de', targeting(geo('DE', 'AT', 'CH'), languages('de')), {
    keywords: [...phrase('dateien umbenennen', 'datei umbenenner')],
    ad: rsa(
      headlines('Dateien umbenennen', 'KI-gestützt', 'Stapelumbenennung'),
      descriptions('Tausende Dateien in Sekunden umbenennen.', 'Kostenlos testen.'),
      url('https://example.com/de'),
    ),
  })

Or export multiple campaign objects from one file:

campaigns/search-en-de.ts
import { google, daily, phrase, headlines, descriptions, rsa, url } from '@upspawn/ads'

const base = { budget: daily(20), bidding: 'maximize-clicks' as const }

export const campaignEn = google.search('Brand - EN', base)
  .group('brand-en', {
    keywords: [...phrase('my product')],
    ad: rsa(
      headlines('My Product', 'Official Site', 'Start Free'),
      descriptions('The fastest way to get things done.', 'No card required.'),
      url('https://example.com'),
    ),
  })

export const campaignDe = google.search('Brand - DE', base)
  .group('brand-de', {
    keywords: [...phrase('mein produkt')],
    ad: rsa(
      headlines('Mein Produkt', 'Offizielle Seite', 'Kostenlos starten'),
      descriptions('Der schnellste Weg, Dinge zu erledigen.', 'Keine Karte nötig.'),
      url('https://example.com/de'),
    ),
  })

Campaign discovery

The CLI automatically discovers campaigns by scanning campaigns/**/*.ts and looking for exports with provider and kind fields. Both default exports and named exports work:

// Default export
export default google.search('Campaign', { /* ... */ })

// Named exports — both are discovered
export const campaignEn = google.search('Campaign EN', { /* ... */ })
export const campaignDe = google.search('Campaign DE', { /* ... */ })

Run ads validate to check all campaign files for errors without hitting the API.


Resource paths

Every resource gets a stable path used as its identifier:

campaign-name/ad-group-name/kw:keyword-text:EXACT
campaign-name/ad-group-name/rsa:a3f2b1c4...

These paths map to platform IDs in the SQLite cache. Renaming a campaign in code is treated as a delete + create. If you want to rename without recreating, import first and update the cache mapping.


See also

On this page