# Appendix B — Building the ad via the Meta Marketing API

[← Back to README](README.md) · UI version: [05-facebook-ad.md](05-facebook-ad.md)

---

This is the alternative to the [Ads Manager UI path in 05](05-facebook-ad.md). It is the
path to take when an **AI assistant** is doing the setup: it is fully scriptable, every
object is created with explicit parameters, and you can build everything paused for review.

**Read [05](05-facebook-ad.md) first.** The business decisions (one offer per ad set, the
affluent-pin vs broad-metro targeting, the creative rules, price-must-match-the-form) are
identical and are explained there. This appendix only covers the *mechanics* of creating
the same objects through the Graph API instead of clicking.

> All IDs below are **NeatNow's, shown as shape examples only**. Use your own. No tokens or
> secret values appear in this guide, and you should never commit one.

---

## 1. The two-token gotcha (this is the one that wastes an afternoon)

There are two kinds of token, and they are **not** interchangeable for this task:

| Token | Can it create a lead-ad ad set? |
|---|---|
| **System User token** (long-lived, the kind you bake into a server) | **No.** It can read insights and even create the *campaign*, but creating a lead-gen ad set fails. |
| **User token** with `ads_management` + `ads_read` | **Yes.** This is the only one that works for lead ads. |

**Why:** Meta binds lead-generation to a **human identity** that has accepted the Lead-Gen
Terms of Service. A System User is not a human, so the lead-ad ad set create call fails with a
**"Terms of Service Not Accepted"** error (NeatNow observed `error_subcode 1815089`):

```
error_subcode 1815089  "Terms of Service Not Accepted"   (observed in practice)
```

...even when the Page itself has accepted the ToS. (This subcode could not be confirmed on
Meta's official [error reference](https://developers.facebook.com/docs/graph-api/guides/error-handling/),
which doesn't render fully via automated tools — it is reported here as observed, not
doc-cited.) Do not chase the Page's ToS, it is not the problem. You need a human user's token.

### Getting the User token

1. Go to the **Graph API Explorer** (`developers.facebook.com/tools/explorer`).
2. Select your app, add the permissions **`ads_management`** and **`ads_read`**.
3. **Generate Access Token** and approve the dialog.
4. Use it. This token is **short-lived (~1-2 hours)**.

> 🔒 **Discipline:** use this token **in memory only** for the duration of the build. Never
> write it to a file, never commit it, never paste it into a doc. When it expires, generate
> a fresh one. (The long-lived System User token is the right tool for the *running worker*
> that fetches leads, that is a different job, covered in [04](04-the-worker.md).)

---

## 2. The create calls (every field shown is required)

Create the objects in order: campaign → ad set → image upload → creative → ad. If you omit
any of the fields below, the call errors. **Create everything `status=PAUSED`.**

> **Provenance:** these create-call field names and values are from NeatNow's actual working
> API calls (June 2026). Meta's API reference pages don't render fully via automated tools, so
> treat the field names as **verified-by-use** and cross-check against the live
> [Graph API reference](https://developers.facebook.com/docs/graph-api/changelog) before relying
> on them; Meta occasionally renames fields. Several values are additionally
> **confirmed by docs**: the `OUTCOME_LEADS` objective, `destination_type=ON_AD` (valid for
> `OUTCOME_LEADS`), the `GET_QUOTE` CTA, the `MOBILE_FEED_STANDARD` preview format,
> `custom_locations` with `distance_unit: kilometer`, and the `cities` + numeric-`key`
> mechanism paired with the `adgeolocation` search endpoint. Use a **current Graph API
> version** in your request paths (v25.0 was current as of June 2026).

> 🔧 **Customize:** every ID, budget, and targeting value. NeatNow's are placeholders.
> Example IDs (use your own):
> - Ad account: `act_1901764640543722` *(example, use your own)*
> - Page: `976943655503330` *(example, use your own)*
> - Lead form: `1251163070175753` *(example, use your own)*

### 2.1 Campaign

`POST act_<ACCT>/campaigns`

| Field | Value | Why |
|---|---|---|
| `objective` | `OUTCOME_LEADS` | The lead objective. |
| `special_ad_categories` | `[]` | Cleaning is **not** special. Required field, send empty array. |
| `is_adset_budget_sharing_enabled` | `false` | Required when you are **not** using campaign budget optimization (you want per-ad-set budgets, per [05 §2](05-facebook-ad.md)). |
| `status` | `PAUSED` | |

### 2.2 Ad set (one per offer)

`POST act_<ACCT>/adsets`

| Field | Value | Why |
|---|---|---|
| `optimization_goal` | `LEAD_GENERATION` | Optimize for form fills. |
| `billing_event` | `IMPRESSIONS` | |
| `bid_strategy` | `LOWEST_COST_WITHOUT_CAP` | Omit this and the call fails with "Bid amount required." |
| `destination_type` | `ON_AD` | The lead form opens **on** the ad (the instant form). |
| `promoted_object` | `{ "page_id": "<PAGE>" }` | Binds the ad set to your Page. |
| `daily_budget` | integer in **CENTS** | e.g. `2000` = $20.00/day (recurring), `1000` = $10.00/day (move-out). |
| `targeting` | see §3 | Pins vs broad metro. |
| `status` | `PAUSED` | |

Create **two ad sets** for NeatNow's example, one recurring (~$20/day) and one move-out
(~$10/day), so neither offer starves the other ([05 §2](05-facebook-ad.md)).

### 2.3 Upload the creative image

`POST act_<ACCT>/adimages` as **multipart** form-data with the field `source` set to the
image file. This costs nothing (no spend). The response returns a **`hash`** you use in the
next step.

### 2.4 Ad creative (lead ad)

`POST act_<ACCT>/adcreatives` with an `object_story_spec`:

```json
{
  "object_story_spec": {
    "page_id": "<PAGE>",
    "link_data": {
      "link": "http://fb.me/",
      "image_hash": "<HASH from 2.3>",
      "message": "<your primary text / caption>",
      "call_to_action": {
        "type": "GET_QUOTE",
        "value": { "lead_gen_form_id": "<FORM>" }
      }
    }
  }
}
```

- `link` is the literal placeholder **`"http://fb.me/"`** for an on-ad lead form, the form
  opens in-app, so there is no real destination URL. Meta requires the field anyway.
- `call_to_action.type` is **`GET_QUOTE`**, matching the form's CTA, with the
  `lead_gen_form_id` pointing at the form you built in [02](02-lead-form.md).

### 2.5 The ad

`POST act_<ACCT>/ads` referencing the ad set ID and the creative ID, `status=PAUSED`.

### 2.6 Preview before you spend a cent

`GET <AD_ID>/previews?ad_format=MOBILE_FEED_STANDARD`

This returns an iframe URL rendering exactly what the ad will look like in a mobile feed.
Open it, confirm the image, copy, price band, and CTA are right, **then** hand it to the
owner to activate.

---

## 3. Targeting payloads

### Affluent custom-location pins (recurring)

Each pin is a `custom_location` with a lat/long and a small radius. In the ad set's
`targeting.geo_locations.custom_locations`:

```json
"custom_locations": [
  { "latitude": 51.02, "longitude": -114.10, "radius": 2, "distance_unit": "kilometer" }
]
```

Add one entry per affluent neighborhood (NeatNow used four). Keep the radius small so you
do not bleed into cheaper areas. Set `age_min: 22`, `age_max: 65`.

### Broad metro (move-out)

Use **city keys** instead of pins. In `targeting.geo_locations.cities`:

```json
"cities": [
  { "key": "292501" }, { "key": "291869" },
  { "key": "292813" }, { "key": "2675068" }
]
```

Calgary geo city keys, as an example:

| City | Key |
|---|---|
| Calgary | `292501` |
| Airdrie | `291869` |
| Cochrane | `292813` |
| Chestermere | `2675068` |

> 🔧 **Customize:** look up your own city keys. Query
> `GET search?type=adgeolocation&location_types=["city"]&q=<your city>` with your token,
> the response gives the `key` for each match. Never reuse Calgary's keys for another metro.

For both ad sets, set **feed placements** and `age_min: 22 / age_max: 65` to match
[05 §3-4](05-facebook-ad.md).

---

## 4. Activation discipline

Create **all** objects `PAUSED`. The AI builds and previews; the **owner reviews the
preview and activates manually**. This mirrors the spend-gate principle used everywhere in
this system: nothing that can spend money turns itself on. Activate by flipping the ad and
the ad set to `ACTIVE` once the preview checks out, recurring ad set first if you want to
control which offer starts spending.

---

## Next

- The UI version of all of this → [05 — The Facebook ad](05-facebook-ad.md)
- The running worker's token (different job, System User token there) → [04 — The worker](04-the-worker.md)
- The lead form referenced by `lead_gen_form_id` → [02 — The lead form](02-lead-form.md)
- "Certification Required" / ad won't deliver → [Appendix C — Troubleshooting](appendix-C-troubleshooting.md)
- Back to the [README](README.md)
