URL: /code/apps/ads/create-campaign · Id: `page:/code/apps/ads/create-campaign`

# Campaign.create

> Open a Campaign on your ad account, such as $100 on LinkedIn for a week.

`POST https://api.sb/{your-domain}/campaigns/create`

Open a [Campaign](https://startupbuilder.dev/business/nouns/campaign) on your ad account, such as $100 on LinkedIn for a week.

- **Action class:** `spend`
- **Approval:** Asks above your rule
- **Emits:** `Campaign.created`
- **Sites:** [10 sites](#on-each-venue)
- **Price:** Free

```text title="Run it · Ask"
Set up a $100 LinkedIn test over one week for owners and property managers at US firms with 20 to 200 staff, aimed at leads.
```

```ts title="Run it · SDK"
import { $ } from 'startup-builder'

const result = await $.try.Campaign.create({ venue: 'linkedin', objective: 'leads', budget: { amount: 100, currency: 'USD' }, startsAt: '2026-10-28T13:00:00Z', endsAt: '2026-11-04T13:00:00Z', targeting: { titles: ['Owner', 'Property Manager', 'Director of Property Management'], industries: ['property management'], headcount: { min: 20, max: 200 }, countries: ['US'] }, stopRule: 'cost per lead above $40 after $60 spent', play: 'paid-social' })
```

```json title="Run it · Response"
{
  "api": "https://api.sb/leaseline.app",
  "campaign": {
    "$type": "Campaign",
    "venue": "linkedin",
    "objective": "leads",
    "state": "draft",
    "budget": {
      "amount": 100,
      "currency": "USD"
    },
    "startsAt": "2026-10-28T13:00:00Z",
    "endsAt": "2026-11-04T13:00:00Z",
    "stopRule": "cost per lead above $40 after $60 spent",
    "play": "paid-social",
    "outside": null
  },
  "bill": null,
  "action": {
    "operation": "Campaign.create",
    "mode": "try",
    "status": "done"
  }
}
```

Opens a Campaign on your connected ad account, with its budget, window and stop rule, and books the budget as a Bill. This Verb is a sketch on the hub’s [Campaign](https://startupbuilder.dev/business/nouns/campaign) Noun.</HubDefinition>

## Inputs
| Name | Type | Default | Meaning |
| --- | --- | --- | --- |
| `venue` | `string` | Required | Where it runs, by id: `linkedin`, `facebook`, `instagram`, `google-search`, `bing`, `reddit`, `x` or `tiktok`. |
| `objective` | `'awareness' \| 'traffic' \| 'leads' \| 'conversions'` | Required | What the ad network delivers for: `leads` fills its lead form, `conversions` counts a signup on your site, `traffic` buys clicks. |
| `budget` | `money` | Required | What it may spend, as on the hub’s Campaign: the most it spends over its whole window, never per day. |
| `startsAt` | `datetime` | `now` | When it starts. It serves once its first Ad passes the ad network’s review. |
| `endsAt` | `datetime` | `startsAt + 7d` | When it ends. Unset: seven days after it starts. |
| `goal` | `Goal` | `null` | The Goal it spends toward, as on the hub’s Campaign. |
| `targeting` | `Targeting` | `{ countries: ['US'] }` | Who sees it, in account-neutral fields: `titles`, `industries`, `headcount`, `countries`, `interests`, `communities`. The per-account table under Effects lists the fields each account honors. |
| `keywords` | `string[]` | `[]` | For a search ad account: the searches to show on, each as an exact match unless it says otherwise. |
| `negatives` | `string[]` | `[]` | For a search ad account: searches it never shows on, such as `free template` or `jobs`. |
| `audiences` | `Audience[]` | `[]` | Saved Audiences to show it to, as `Audience.create` made them, on top of `targeting`. |
| `bid` | `'lowest-cost' \| { costPer: money }` | `'lowest-cost'` | `lowest-cost` lets the ad network bid to spend the budget; a `costPer` cap holds each result under it and may underspend. |
| `stopRule` | `string` | `null` | The reading that pauses it, such as cost per lead above $40 after $60 spent. It is read each day from your own results. |
| `play` | `string` | `null` | The Play it runs under, so its spend and results count in that Play’s readout. |

## Outputs
| Field | Type | What `null` means |
| --- | --- | --- |
| `campaign` | `Campaign` | Never null. |
| `campaign.state` | `string` | Never null: `draft` until its first Ad passes review, then `live`, `paused` or `ended`. |
| `campaign.budget` | `money`, in USD | Never null: the budget sent. |
| `campaign.startsAt` | `datetime` | Never null: the start sent, or the time of the call. |
| `campaign.endsAt` | `datetime` | Never null: the end sent, or seven days after the start. |
| `campaign.outside` | `json` | Null in try mode and until the ad account returns its own id for the Campaign. |
| `bill` | `Bill` | Null in try mode. Otherwise the Bill for the budget, owed for the ad spend and coded to `expense.advertising`. |

## Effects
**Action class**

`spend`: an approval rule on `spend` can make it wait for your OK

**Modes**

- `try`: Validates the Campaign against the ad account and returns it as it would be created; nothing is created and no Bill is booked.
- `send`: Returns a pending Action at once; `Campaign.created` fires when the ad account holds the Campaign.
- `do`: Waits until the ad account holds the Campaign and returns it with its id on the ad account.

**Idempotency**

Only with a key: the same `Idempotency-Key` and input return the first Action, and the same key with other input returns `409`. A repeat with the same key never opens a second Campaign or books a second Bill.

**Saved or shared**

The Campaign, its Bill and its id on the ad account are saved. Spend posts to your books as the account reports it, and nothing is shared with other startups.

**Emits**

`Campaign.created` with `{ campaign, venue, objective, budget, bill }`

**Performed by**

[Paid Media Manager](https://startupbuilder.dev/agents/marketing/paid-media-manager)

**Price**

Free on your own account. The spend is billed by the site directly: Google, LinkedIn, Meta, Microsoft, Reddit, TikTok, X.

**Standing approval**

It asks you first above its seat rule, unless a standing approval covers it: a [`Mandate`](https://startupbuilder.dev/business/nouns/mandate) you grant once with [`Mandate.authorize`](/code/integrations/calls/authorize).

## On each Venue
| Site | Fields | Formats |
| --- | --- | --- |
| [Google Search and Google Display and YouTube](#google-ads) | `objective`, `budget`, `startsAt`, `endsAt`, `keywords`, `negatives` | 2 |
| [LinkedIn](#linkedin-ads) | `objective`, `budget`, `startsAt`, `endsAt`, `targeting`, `audiences`, `bid` | 3 |
| [Facebook and Instagram](#meta-ads) | `objective`, `budget`, `startsAt`, `endsAt` | 5 |
| [Bing](#microsoft-ads) | `objective`, `budget`, `keywords`, `negatives`, `bid`, `targeting`, `startsAt` | 1 |
| [Reddit](#reddit-ads) | `objective`, `targeting`, `budget`, `startsAt`, `endsAt`, `bid`, `keywords` | 3 |
| [TikTok](#tiktok-ads) | `objective`, `budget`, `startsAt`, `endsAt`, `targeting`, `bid` | 2 |
| [X](#x-ads) | `objective`, `targeting`, `budget`, `startsAt`, `endsAt`, `bid`, `keywords`, `gUrl` | 2 |

### Google Search and Google Display and YouTube
Through [Google Ads](/code/integrations/google-ads).

| Our field | Google Ads’s field |
| --- | --- |
| `Campaign.objective` | `campaign.advertising_channel_type` (With a conversion goal; `leads` runs a SEARCH campaign bidding toward your signup conversion) |
| `Campaign.budget` | `campaign_budget.amount_micros` (A daily amount in millionths: $40 is 40000000) |
| `Campaign.startsAt` | `campaign.start_date` |
| `Campaign.endsAt` | `campaign.end_date` |
| `Campaign.keywords` | `ad_group_criterion.keyword` (match_type EXACT for a test) |
| `Campaign.negatives` | `campaign_criterion.negative` (With the wasted search term as its keyword) |

- **Smart Bidding.** Learns from your conversion volume, and a new account has none, so a first test runs on manual CPC or maximize clicks on exact terms.

Google Search: Responsive search ad YouTube: Video

### LinkedIn
Through [LinkedIn Ads](/code/integrations/linkedin-ads).

| Our field | LinkedIn Ads’s field |
| --- | --- |
| `Campaign.objective` | `adCampaign.objectiveType` (`leads` is LEAD_GENERATION with LinkedIn’s lead form, `traffic` WEBSITE_VISIT, `conversions` WEBSITE_CONVERSION) |
| `Campaign.budget` | `adCampaign.dailyBudget or adCampaign.totalBudget` (`{ amount, currencyCode }`, the amount as a decimal string; a lifetime budget runs on lifetime pacing, which LinkedIn recommends) |
| `Campaign.startsAt` | `adCampaign.runSchedule.start` (Epoch milliseconds) |
| `Campaign.endsAt` | `adCampaign.runSchedule.end` |
| `Campaign.targeting` | `adCampaign.targetingCriteria` (Job titles, industries, staff count ranges and locations, each a LinkedIn targeting facet) |
| `Campaign.audiences` | `targetingCriteria: audienceMatchingSegments` |
| `Campaign.bid` | `adCampaign.costType and adCampaign.unitCost` (`lowest-cost` lets LinkedIn bid to spend the budget; a `costPer` cap sets `unitCost`) |

- **Budget minimums.** $10 a day for any ad format, and a new campaign’s lifetime budget is at least $100.

Feed post Single image ad, 1200 × 628, 1200 × 1200 or 720 × 900 Document ad

### Facebook and Instagram
Through [Meta Ads](/code/integrations/meta-ads).

| Our field | Meta Ads’s field |
| --- | --- |
| `Campaign.objective` | `campaign.objective` (`leads` is OUTCOME_LEADS, `traffic` OUTCOME_TRAFFIC, `conversions` OUTCOME_SALES) |
| `Campaign.budget` | `adset.daily_budget or adset.lifetime_budget` (In the account currency’s smallest unit: $40.00 is 4000) |
| `Campaign.startsAt` | `adset.start_time` |
| `Campaign.endsAt` | `adset.end_time` |

- **Minimum daily budget.** Meta sets it per ad account, currency and what the ad set pays for; the agent reads it from your ad account before it builds, and a budget under it refuses with `venue-rejected`.
- **One ad set.** A test under $1,000 runs in one ad set, never split, so every result feeds the same learning.

Facebook: Feed image ad, 1440 × 1800, 4:5 Instagram: Feed image, 320 to 1,440 pixels wide, 4:5 to 1.91:1 Instagram: Carousel Instagram: Reel, 9:16 Instagram: Story, 9:16

### Bing
Through [Microsoft Ads](/code/integrations/microsoft-ads).

| Our field | Microsoft Ads’s field |
| --- | --- |
| `Campaign.objective` | `campaign goal` (Set in Microsoft Ads’ own screens; `leads` bids toward the signup conversion the import brings over) |
| `Campaign.budget` | `campaign daily budget` |
| `Campaign.keywords` | `keyword text and match type` (Exact unless the term says otherwise; phrase and broad on request) |
| `Campaign.negatives` | `campaign negative keywords` |
| `Campaign.bid` | `ad group default bid, or the bid strategy` (`lowest-cost` is maximize clicks; a `costPer` cap is a manual CPC bid) |
| `Campaign.targeting` | `location targeting` (Countries and regions; Microsoft also shows ads to people searching about a place unless you set people in it) |
| `Campaign.startsAt` | `ad group start date` |

- **What the import copies.** Campaigns, ad groups, keywords, ads and negatives as they stand in Google Ads; the agent then sets the budget your standing approval allows.
- **A day’s spend.** Microsoft may spend up to twice the daily budget on a busy day, and never more than the daily budget times 30.4 in a month; your standing approval’s cap is set on the month for that reason.

Responsive search ad

### Reddit
Through [Reddit Ads](/code/integrations/reddit-ads).

| Our field | Reddit Ads’s field |
| --- | --- |
| `Campaign.objective` | `campaign objective` (Picked in Ads Manager: `traffic` sends readers to your page; `leads` bids toward your signup event) |
| `Campaign.targeting` | `ad group community targeting` (The subreddits, by name) |
| `Campaign.budget` | `ad group budget` |
| `Campaign.startsAt` | `ad group start date and time` |
| `Campaign.endsAt` | `ad group end date and time` |
| `Campaign.bid` | `ad group bid strategy` (`lowest-cost` is automatic bidding; a `costPer` cap is a cost cap on clicks or conversions) |
| `Campaign.keywords` | `ad group keyword targeting` (Shows the ad beside conversations that use the words, across subreddits) |

- **Budget floor.** The agent reads the minimum Ads Manager shows for your account before it builds, and a budget under it refuses with `venue-rejected`.
- **Comments on a promoted post.** Stay open; each one reaches you as a Message, and your agent drafts the answer.

Text or link post Comment Promoted post

### TikTok
Through [TikTok Ads](/code/integrations/tiktok-ads).

| Our field | TikTok Ads’s field |
| --- | --- |
| `Campaign.objective` | `campaign objective_type` (`traffic` is TRAFFIC; `leads` is LEAD_GENERATION with TikTok’s instant form; `conversions` is WEB_CONVERSIONS on your pixel) |
| `Campaign.budget` | `adgroup budget with budget_mode BUDGET_MODE_DAY` (Over $20; the campaign budget stays unset unless you ask, and then it is over $50) |
| `Campaign.startsAt` | `adgroup schedule_start_time` |
| `Campaign.endsAt` | `adgroup schedule_end_time` (Set with schedule_type SCHEDULE_START_END) |
| `Campaign.targeting` | `adgroup location_ids, age_groups and interest_category_ids` (TikTok targets people, not job titles: an interest stands in for a role) |
| `Campaign.bid` | `adgroup bid_type` (`lowest-cost` is BID_TYPE_NO_BID; a `costPer` cap is BID_TYPE_CUSTOM with bid_price) |

- **Budget minimums.** An ad group’s daily budget must exceed $20, and a campaign’s daily budget, when you set one, must exceed $50.

Video Spark Ad

### X
Through [X Ads](/code/integrations/x-ads).

| Our field | X Ads’s field |
| --- | --- |
| `Campaign.objective` | `campaign objective` (Picked in X Ads: `traffic` is website traffic; `engagement` is post engagement) |
| `Campaign.targeting` | `follower look-alikes` (Of the accounts you name) |
| `Campaign.budget` | `campaign total and daily budget` |
| `Campaign.startsAt` | `ad group start time` |
| `Campaign.endsAt` | `ad group end time` |
| `Campaign.bid` | `ad group bid strategy` (`lowest-cost` is autobid; a `costPer` cap is a target cost) |
| `Campaign.keywords` | `keyword targeting` (Reaches people who searched or posted the words lately, beside the follower look-alikes) |
| `Ad.landingUrl` | `website card URL` (Added in the first reply when the post carries no link) |

- **Ad review.** X reviews every promoted post; a rejection comes back as `venue-rejected` with the reason.
- **What gets promoted.** A post already on your account, as it is; the agent never writes a post only for the ad.
- **Post length.** 280 weighted characters on a standard account, a link counting as 23 wherever it points.

Standard post Promoted post

## What try does
| Account | What try does |
| --- | --- |
| [Google Ads](/code/integrations/google-ads#try) | Sends every change with `validate_only` set: Google Ads checks the whole build and returns any error, and creates nothing. |
| [LinkedIn Ads](/code/integrations/linkedin-ads#try) | Reads your ad account, the matched count of each audience and the targeting facets, and checks the campaign against LinkedIn’s budget minimums; LinkedIn has no validate-only call, so nothing is sent to it and nothing is created. |
| [Meta Ads](/code/integrations/meta-ads#try) | Sends the whole build with Meta’s `validate_only` execution option: Meta runs every check and creates nothing, and the response names any field it would reject. |
| [Microsoft Ads](/code/integrations/microsoft-ads#try) | Returns what the import would bring over, term by term with the budget it would set, and the steps the browser agent would take; no session opens and nothing changes. |
| [Reddit Ads](/code/integrations/reddit-ads#try) | Returns the campaign and the promoted post as Ads Manager will hold them, with the steps the browser agent would take; no session opens and nothing is created. |
| [TikTok Ads](/code/integrations/tiktok-ads#try) | Returns the Spark Ad and its ad group as Ads Manager will hold them, checked against TikTok’s budget floors, with the steps the browser agent would take; nothing is created. |
| [X Ads](/code/integrations/x-ads#try) | Returns the promotion as X Ads will hold it, with the steps the browser agent would take; no session opens and nothing is promoted. |

## Refuses when
| Check | Error | Fix | Then call |
| --- | --- | --- | --- |
| A landing page fails its status or Price check | [`landing`](/code/refusals#stop-landing) | Fix what the reason names, then turn the Play back on. | [`Campaign.update`](/code/apps/ads/update-campaign) |
| An ads Play on a site that has not received one test qualified lead from your records | [`conversion-send`](/code/refusals#check-conversion-send) | Connect the ad account and send one test qualified lead; the site stays off until it lands. | [`Integration.connect`](/code/integrations/calls/connect) |

Every call through an account can also meet [the shared account refusals](/code/refusals#accounts): [`grant-missing`](/code/refusals#grant-missing), [`needs-reauth`](/code/refusals#needs-reauth), [`account-not-connected`](/code/refusals#account-not-connected), [`over-mandate`](/code/refusals#over-mandate), [`quota-reached`](/code/refusals#quota-reached), [`venue-rejected`](/code/refusals#venue-rejected).

```json
{
  "type": "https://startupbuilder.dev/errors/landing",
  "title": "Landing",
  "status": 422,
  "detail": "A landing Page fails its status or Price check.",
  "fix": "Fix what the reason names, then turn the Play back on.",
  "next": {
    "verb": "Campaign.update",
    "input": {}
  },
  "instance": "https://api.sb/leaseline.app/actions/action_1"
}
```

## When it waits
| Fact | Value |
| --- | --- |
| `send` returns | A pending Action: it waits for an Approval when the spend is over your approval rule and no standing approval covers it, then runs until the ad account holds the Campaign. |
| Delivered by | `Campaign.created` |

## Example

**Ask**

```text
Set up a $100 LinkedIn test over one week for owners and property
managers at US firms with 20 to 200 staff, aimed at leads.
```

**API**

```bash
curl -X POST "https://api.sb/leaseline.app/campaigns/create?mode=try" \
  -H "Authorization: Bearer $SB_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"venue":"linkedin","objective":"leads","budget":{"amount":100,"currency":"USD"},"startsAt":"2026-10-28T13:00:00Z","endsAt":"2026-11-04T13:00:00Z","targeting":{"titles":["Owner","Property Manager","Director of Property Management"],"industries":["property management"],"headcount":{"min":20,"max":200},"countries":["US"]},"stopRule":"cost per lead above $40 after $60 spent","play":"paid-social"}'
```

The response is the one in the Run it window above.

**CLI**

```bash
npx startup-builder do Campaign.create --try --data '{"venue":"linkedin","objective":"leads","budget":{"amount":100,"currency":"USD"},"startsAt":"2026-10-28T13:00:00Z","endsAt":"2026-11-04T13:00:00Z","targeting":{"titles":["Owner","Property Manager","Director of Property Management"],"industries":["property management"],"headcount":{"min":20,"max":200},"countries":["US"]},"stopRule":"cost per lead above $40 after $60 spent","play":"paid-social"}'
```

**MCP**

```json
{
  "name": "do",
  "arguments": {
    "mode": "try",
    "code": "return await $.Campaign.create({ venue: 'linkedin', objective: 'leads', budget: { amount: 100, currency: 'USD' }, startsAt: '2026-10-28T13:00:00Z', endsAt: '2026-11-04T13:00:00Z', targeting: { titles: ['Owner', 'Property Manager', 'Director of Property Management'], industries: ['property management'], headcount: { min: 20, max: 200 }, countries: ['US'] }, stopRule: 'cost per lead above $40 after $60 spent', play: 'paid-social' })"
  }
}
```

**SDK**

```ts
import { $ } from 'startup-builder'

const result = await $.try.Campaign.create({
  venue: 'linkedin',
  objective: 'leads',
  budget: { amount: 100, currency: 'USD' },
  startsAt: '2026-10-28T13:00:00Z',
  endsAt: '2026-11-04T13:00:00Z',
  targeting: { titles: ['Owner', 'Property Manager', 'Director of Property Management'], industries: ['property management'], headcount: { min: 20, max: 200 }, countries: ['US'] },
  stopRule: 'cost per lead above $40 after $60 spent',
  play: 'paid-social',
})
```

To retry, send the same `Idempotency-Key`: the retry returns the first answer for $0. A new key reads again.

A `sk_test_` key answers these the same way every time, at $0 ([every test value](/code/data#test-values)).

| Test value | Always returns | What it is for |
| --- | --- | --- |
| `venue: 'linkedin' under sk_test_` | `{"state":"draft","outside":{"linkedin":"test-campaign-1"}}` | Opens a Campaign on a test ad account that never serves or spends, and returns the same campaign id every time. |
| `budget: { amount: 999999, currency: 'USD' }` | Refuses with [`over-mandate`](/code/refusals#over-mandate) | A $999,999 budget is over the Limit of every test standing approval, so the call refuses before a Campaign or a Bill exists. |
| `maxCost: 0.0001` | Refuses with [`max-cost`](/code/refusals#max-cost) | Any priced call with a maxCost of 0.0001 is refused before it spends: the price is above the cap. |
| `rate-limited.test` | Refuses with [`rate-limited`](/code/refusals#rate-limited) | Any call naming this hostname answers 429 with Retry-After, so your retry logic runs. |
| `sources-busy.test` | Refuses with [`sources-busy`](/code/refusals#sources-busy) | Any call naming this hostname answers 503 as if every route were busy, and nothing is spent. |

## Used by
- **Workflows:** [Ad test setup](/capabilities/run-ads/ad-test-setup)
- **Jobs:** [Run ads](/capabilities/run-ads)
- **Integrations:** [Google Ads](/code/integrations/google-ads) · [LinkedIn Ads](/code/integrations/linkedin-ads) · [Meta Ads](/code/integrations/meta-ads) · [Microsoft Ads](/code/integrations/microsoft-ads) · [Reddit Ads](/code/integrations/reddit-ads) · [TikTok Ads](/code/integrations/tiktok-ads) · [X Ads](/code/integrations/x-ads)

## Related
[`Audience.create`](/code/apps/ads/create-audience) · [`Campaign.update`](/code/apps/ads/update-campaign) · [`Ad.create`](/code/apps/ads/create-ad)