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

# Ad.create

> Add one Ad to a Campaign; it shows once its review passes.

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

Add one [Ad](https://startupbuilder.dev/business/nouns/ad) to a [Campaign](https://startupbuilder.dev/business/nouns/campaign); it shows once its review passes.

- **Action class:** `publish`
- **Approval:** Publishes on its own
- **Emits:** `Ad.created`
- **Sites:** [9 sites](#on-each-venue)
- **Price:** Free

```text title="Run it · Ask"
Add the third ad to the LinkedIn test: the renewal-notice angle, with the checklist image.
```

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

const result = await $.try.Ad.create({ campaign: 'campaign_Li4tX9pQ2w', format: 'image', headline: 'Every renewal notice, 60 days out, for every owner', body: 'Leaseline tracks rent and lease renewals for property managers with 20 to 400 units. $149 a month per portfolio.', media: ['file_Rn6cK2vT8m'], landingUrl: 'https://leaseline.app/renewals', cta: 'learn-more', variant: 'renewal-notice' })
```

```json title="Run it · Response"
{
  "api": "https://api.sb/leaseline.app",
  "ad": {
    "$type": "Ad",
    "campaign": "https://api.sb/leaseline.app/campaigns/campaign_Li4tX9pQ2w",
    "format": "image",
    "variant": "renewal-notice",
    "state": "inReview",
    "landingUrl": "https://leaseline.app/renewals?utm_source=linkedin&utm_medium=paid&utm_campaign=campaign_Li4tX9pQ2w&utm_content=renewal-notice",
    "spend": null,
    "outside": null
  },
  "action": {
    "operation": "Ad.create",
    "mode": "try",
    "status": "done"
  }
}
```

Builds one ad in a Campaign on your connected ad account; it runs once the site’s review passes. This Verb is a sketch on the hub’s [Ad](https://startupbuilder.dev/business/nouns/ad) Noun.</HubDefinition>

## Inputs
| Name | Type | Default | Meaning |
| --- | --- | --- | --- |
| `campaign` | `Campaign` | Required | The Campaign it runs in, sharing its budget and window, as on the hub’s Ad. |
| `format` | `'search' \| 'display' \| 'image' \| 'video'` | Required | One of the hub’s Ad formats. The per-account table under Effects lists the formats each account takes. |
| `headline` | `string` | Required | The headline. The per-account table under Effects lists each account’s character limit. |
| `body` | `string` | `null` | The text above or beside the media. Unset: the headline stands alone. |
| `media` | `File[]` | `[]` | The image or video Files. An `image` or `video` Ad needs one. |
| `landingUrl` | `string` | Required | The page a click lands on. The platform adds `utm_` tags, so each signup credits this Ad. |
| `cta` | `string` | `'learn-more'` | The button, such as `learn-more`, `sign-up` or `book-now`, mapped to the nearest button each ad account offers. |
| `offer` | `Offer` | `null` | The offer it sells, as on the hub’s Ad. |
| `variant` | `string` | `null` | The angle it tests, so the readout compares Ads by angle. |

## Outputs
| Field | Type | What `null` means |
| --- | --- | --- |
| `ad` | `Ad` | Never null. |
| `ad.state` | `string` | Never null: `inReview` until the ad network’s review ends, then `live` or `rejected`. |
| `ad.format` | `string` | Never null: the format sent. |
| `ad.spend` | `money`, in USD | Null until the ad account reports a first day of spend. |
| `ad.outside` | `json` | Null in try mode and until the ad account returns its own id for the Ad. |

## Effects
**Action class**

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

**Modes**

- `try`: Checks the words and media against the ad specs of the account and the landing page’s status, and returns the Ad as it would be submitted; nothing is submitted.
- `send`: Returns a pending Action; `Ad.created` fires when the ad account holds the Ad, and `Ad.updated` when its review ends.
- `do`: Waits until the ad account holds the Ad and returns it `inReview`.

**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 submits a second Ad.

**Saved or shared**

The Ad is saved with its id on the ad account and, as the account reports it, its spend. A rejected Ad keeps the review’s reason.

**Emits**

`Ad.created` with `{ ad, campaign, venue, format, state }`

**Performed by**

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

**Price**

Free on your own account: the platform charges nothing for the call.

**Standing approval**

It goes out on its own, inside the Limits of your standing approval: a [`Mandate`](https://startupbuilder.dev/business/nouns/mandate) granted with [`Mandate.authorize`](/code/integrations/calls/authorize). One approval rule makes it wait for your OK.

## On each Venue
| Site | Fields | Formats |
| --- | --- | --- |
| [Google Search and Google Display and YouTube](#google-ads) | `paign.objective`, `paign.budget`, `headline`, `body`, `landingUrl` | 2 |
| [LinkedIn](#linkedin-ads) | `paign.objective`, `paign.budget`, `body`, `headline`, `landingUrl` | 3 |
| [Facebook and Instagram](#meta-ads) | `paign.objective`, `paign.budget`, `body`, `headline`, `landingUrl` | 5 |
| [Bing](#microsoft-ads) | `paign.objective`, `paign.budget`, `headline`, `body`, `landingUrl`, `path` | 1 |
| [Reddit](#reddit-ads) | `paign.objective`, `paign.budget`, `headline`, `landingUrl`, `callToAction`, `media` | 3 |
| [TikTok](#tiktok-ads) | `paign.objective`, `paign.budget`, `media`, `callToAction`, `landingUrl` | 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) |
| `Ad.headline` | `ad_group_ad.​ad.​responsive_search_ad.​headlines` (3 to 15, each 30 characters) |
| `Ad.body` | `ad_group_ad.​ad.​responsive_search_ad.​descriptions` (2 to 4, each 90 characters) |
| `Ad.landingUrl` | `ad_group_ad.ad.final_urls` |

- **Ad review.** Google reviews every ad; a disapproval comes back as `venue-rejected` with the policy it names.

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) |
| `Ad.body` | `post commentary` (The creative references a post; 3,000 characters, 150 show before it truncates) |
| `Ad.headline` | `post content.article.title` (200 characters; 70 show before it truncates) |
| `Ad.landingUrl` | `post content.article.source, or the lead form` |

- **Ad review.** LinkedIn reviews every ad; a rejection comes back as `venue-rejected` with the reason.

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) |
| `Ad.body` | `object_story_spec.link_data.message` (The primary text) |
| `Ad.headline` | `object_story_spec.link_data.name` |
| `Ad.landingUrl` | `object_story_spec.link_data.link` |

- **Ad review.** Meta reviews every new ad before it runs; a disapproval comes back as `venue-rejected` with Meta’s reason.

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` |
| `Ad.headline` | `responsive search ad headlines` (Up to 15, each 30 characters) |
| `Ad.body` | `responsive search ad descriptions` (Up to 4, each 90 characters) |
| `Ad.landingUrl` | `final URL` |
| `Ad.path` | `display URL path 1 and path 2` (15 characters each, shown after your domain) |

- **Ads per ad group.** Three enabled responsive search ads; a fourth waits until one is paused.
- **Ad review.** Microsoft reviews every ad; a disapproval comes back as `venue-rejected` with the 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.budget` | `ad group budget` |
| `Ad.headline` | `post title` (A promoted post keeps the title it earned replies under) |
| `Ad.landingUrl` | `destination URL` |
| `Ad.callToAction` | `call to action button` (Such as Learn More or Sign Up) |
| `Ad.media` | `the promoted post’s image or video` (As posted; a new image means a new post) |

- **Ad review.** Reddit reviews every ad; a rejection comes back as `venue-rejected` with the reason.
- **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) |
| `Ad.media` | `ad tiktok_item_id` (A Spark Ad: your own post, authorized for the ad, keeping its likes and comments) |
| `Ad.callToAction` | `ad call_to_action` (Such as LEARN_MORE or SIGN_UP) |
| `Ad.landingUrl` | `ad landing_page_url` |

- **Spark Ads.** Promote a post already on your TikTok account, with its likes and comments kept.
- **Ad review.** TikTok reviews every ad; a rejection comes back as `venue-rejected` with the reason.

Video Spark Ad

## 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. |

## 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) |

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 until the ad account holds the Ad; the review outcome arrives later as `Ad.updated`. |
| Delivered by | `Ad.created` |

## Example

**Ask**

```text
Add the third ad to the LinkedIn test: the renewal-notice angle, with
the checklist image.
```

**API**

```bash
curl -X POST "https://api.sb/leaseline.app/ads/create?mode=try" \
  -H "Authorization: Bearer $SB_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"campaign":"campaign_Li4tX9pQ2w","format":"image","headline":"Every renewal notice, 60 days out, for every owner","body":"Leaseline tracks rent and lease renewals for property managers with 20 to 400 units. $149 a month per portfolio.","media":["file_Rn6cK2vT8m"],"landingUrl":"https://leaseline.app/renewals","cta":"learn-more","variant":"renewal-notice"}'
```

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

**CLI**

```bash
npx startup-builder do Ad.create --try --data '{"campaign":"campaign_Li4tX9pQ2w","format":"image","headline":"Every renewal notice, 60 days out, for every owner","body":"Leaseline tracks rent and lease renewals for property managers with 20 to 400 units. $149 a month per portfolio.","media":["file_Rn6cK2vT8m"],"landingUrl":"https://leaseline.app/renewals","cta":"learn-more","variant":"renewal-notice"}'
```

**MCP**

```json
{
  "name": "do",
  "arguments": {
    "mode": "try",
    "code": "return await $.Ad.create({ campaign: 'campaign_Li4tX9pQ2w', format: 'image', headline: 'Every renewal notice, 60 days out, for every owner', body: 'Leaseline tracks rent and lease renewals for property managers with 20 to 400 units. $149 a month per portfolio.', media: ['file_Rn6cK2vT8m'], landingUrl: 'https://leaseline.app/renewals', cta: 'learn-more', variant: 'renewal-notice' })"
  }
}
```

**SDK**

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

const result = await $.try.Ad.create({
  campaign: 'campaign_Li4tX9pQ2w',
  format: 'image',
  headline: 'Every renewal notice, 60 days out, for every owner',
  body: 'Leaseline tracks rent and lease renewals for property managers with 20 to 400 units. $149 a month per portfolio.',
  media: ['file_Rn6cK2vT8m'],
  landingUrl: 'https://leaseline.app/renewals',
  cta: 'learn-more',
  variant: 'renewal-notice',
})
```

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 |
| --- | --- | --- |
| `headline: 'test: disapprove'` | Refuses with [`venue-rejected`](/code/refusals#venue-rejected) | The test ad account rejects it at review with the reason `Misleading claims`, passed through in `detail`, so your agent’s rewrite path runs. |
| `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
- **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)

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