# Projects

A project is one idea: its site, its forms, and the numbers that show whether people want it.

## Projects

A project has `id`, `name`, `slug`, `way`, `sender`, `mail_from`, `mailing_address` and `email_by_hand` (see Email), `hosts` (every host that serves the site), `domains`, `embed_key`, `funnel_steps` (each a `kind` and a `value`), `blocked_requests`, `live`, `preview`, `preview_url`, `created_at` and `updated_at`.

- `live`: whether the public can see it. False until the founder confirms their email, and while the project is in `preview`.
- `preview`: true while the project waits to go live because the plan's limit of live projects is reached. You can set it up in full. Its pages open only at `preview_url` (`null` otherwise). There, nothing is recorded and the form doesn't take sign-ups. Going live is a release gate (see Plans and gates): `POST /projects/:id/go_live`.
- `way`: how the project uses Hunchwire. The founder picks it on New project and can switch it in **Build › Where it lives**.
  - `hosted_pages`: Hunchwire hosts the site at `<slug>.hunchwire.site`. You write its pages (see Pages).
  - `own_site_forms`: the founder's own site or app embeds the form (`embed_snippet`) on the sites in `domains`. Any hosted pages are kept but hidden: every host answers "This site has moved" (404) until the project switches back.
  - `own_site_api`: the founder's own code signs people up through the API (see API). Hosted pages are off, as with `own_site_forms`. The API works with every way. This one puts it first in Build.
- `blocked_requests`: visits, clicks and form sign-ups refused because they came from a site not in `hosts`. If it's rising, a page with the form is on a domain the project doesn't list.

**The period.** On a project's Analytics and People, `days` is 7, 14, 30 or `all`. `all` starts at the project's first visit or sign-up. Any other value means 30. The founder's screens share one period between Analytics and People, so both count the same people.

### Filters

Analytics takes the same filters the founder uses on screen. A filter picks visitors. Every number then counts only those visitors' events in the period. Add filters as query parameters:

```
GET /projects/:id?days=30&filter[channel][]=Paid search&filter[channel][]=Paid social&exclude[landed_on][]=/pricing
```

- `filter[<dimension>][]=<value>` keeps visitors who match. `exclude[<dimension>][]=<value>` keeps visitors who don't.
- Several values in one dimension match any of them. Several dimensions must all match.
- An unknown dimension or a malformed value is a 422. Its `error` names it.

Dimensions:
- `channel`: where the visitor first came from. One of `Paid search`, `Paid social`, `Paid other` (a paid medium: `cpc`, `ppc`, `paid`, `paid_social`), `Organic search`, `Organic social`, `Email`, `AI assistants`, `Referral` or `Direct`.
  - `AI assistants`: from or tagged with chatgpt.com, chat.openai.com, perplexity.ai, claude.ai, gemini.google.com or copilot.microsoft.com.
  - `Referral`: any other site or tag.
  - `Direct`: no site and no tag.
- `source`: their first visit's `utm_source`, else the referrer's host, else `direct`. Tagged visits keep their `utm_source`.
  - A social link's host shows as the platform. `t.co`, `x.com` and `twitter.com` are `X`. `redd.it` and `old.reddit.com` are `Reddit`. LinkedIn, Facebook, Instagram, YouTube, Pinterest, TikTok, Threads and Bluesky work the same way.
  - A `source` filter takes either spelling: `filter[source][]=t.co` matches `X`.
- `campaign`: their first visit's campaign link, such as `reddit · paid · launch`, or `(untagged)`.
- `landed_on`: a page that began one of their visits. A visit is page views with no gap over 30 minutes.
- `viewed`: any page they viewed.
- `action`: `click:<name>` or `form:<name>`, done in the period.
- `step`: a funnel step they reached. `1` is "Visited site".
- `device`: `Desktop`, `Mobile` or `Tablet`.
- `browser`: `Chrome`, `Safari`, `Firefox`, `Edge`, `Opera`, `Samsung Internet` or `Other`.
- `country`: a two-letter code, such as `CA`.
- `returning`: `new` or `returning`. A returning visitor visited on two different days.
- `day`: a date, such as `2026-09-24`. Picks visitors who viewed a page that day.
- `version`: the version of the hosted site they saw first in the period (`A`, `B`…), when the project tests versions (see Versions). `Unknown` for views from before it did.
- `list`: a list a person joined, such as `whatsapp`. It comes from `data-list` on the form, or `list` in the API. It picks the browsers they signed up from.

`device`, `browser` and `country` come from the visitor's first page view in the period. Each is `Unknown` when it wasn't recorded.

### Read a project's numbers

- `GET /projects?days=30`: list every project, with its numbers for the period. *analytics*
  - `last_<days>_days` (e.g. `last_30_days`): the period's `visitors`, `people`, `confirmed` and `conversion`. It also has `spend`: everything spent in the period, typed in or from ads, or `null` without spend. `cost_per_email` is that spend divided by `people`, or `null`.
  - `webhook_failing_since`: when its webhook endpoint started failing, or `null`.
  - `setup`: where its setup stands, from the checklist the founder sees in **Build › Go live**. `done` counts the checks done, out of `total`. `next` is the first check not done, or `null` when all are done. It has:
    - `key`: one of the `checklist` keys (see Read a project's setup), such as `details`, `site` or `mail_from`.
    - `title`, and `detail`: why, in a line.
    - `section`: where the founder fixes it, as in `checklist`.
  - `last_visit_at` and `last_signup_at`: the newest page view and sign-up, all time, or `null`.
  - `insights`: what agents concluded before, to start from. That's the pinned ones, newest pinned first, then the newest one not pinned or dismissed (at most 3). Seen ones count too: this is your memory, not the founder's news. Each has `id`, `text`, `by`, `link`, `pinned`, `seen`, `reply` and `created_at` (see Insights).
- `GET /projects/:id?days=30`: get one project, with full `analytics`. *analytics*
  - Takes filters (see Filters). Every number follows them.
  - `days`: the number of days in the period. With `days=all`, it counts from the first visit or sign-up.
  - `from` and `to`: the period's first and last day, as UTC dates.
  - `filters`: the filters applied, each a `dimension`, an `op` (`is` or `is_not`) and its `values`. Empty without filters.
  - `visitors`: visitors in the period.
  - `people`: emails collected.
  - `confirmed`: how many of those people confirmed their email by the period's end. `null` while the project doesn't send confirmations (see Email).
  - `conversion` (0 to 1): the share of visitors who submitted any form.
  - `engaged_ms`: the median engaged time per visitor, in milliseconds. A visitor's engaged time is the total time pages were visible in their browser, at most 30 minutes a page. Page views that didn't report it (from before it was recorded) are left out. `null` when none did.
  - `bounce_rate` (0 to 1): the share of visits that were one page with under 10 seconds engaged. A visit is a visitor's page views with no gap over 30 minutes.
  - `returning_rate` (0 to 1): the share of visitors who came on at least two different days, earlier days included.
  - `previous`: the same numbers for the period before, plus its `referrals` as `people` and `share`. `null` with `days=all`, since there's no period before.
  - `daily`: one entry per day, oldest first, with `date`, `visitors`, `people`, `conversion`, `engaged_ms`, `bounce_rate`, `returning_rate` and `referrals` (sign-ups from friends that day).
  - `previous_daily`: the same for the period before, day by day, to compare the shape. `null` with `days=all`.
  - `funnel`: the steps, each with `label`, `visitors`, `share` (of all visitors) and `drop_off` (from the step before; `null` on the first).
    - A step whose value ends in `*` also has `matches`. That's each page, click or form it matched, as `value`, with the `visitors` who reached that step. A visitor can count under several matches.
  - `notes`: chart notes dated inside the period, oldest first, each with `id`, `on` (the day) and `text`. Use them to tell a jump in the numbers from a campaign start or another change you or the founder noted.
  - `funnel_breakdown`: the funnel split by `by`. Pick it with `?by=`: `channel` (default), `source`, `campaign`, `landed_on`, `device`, `country` or `version`.
    - `rows`: the top 5 values by visitors, then `Other`, so each step's rows add up to that step.
    - Each row has `name`, `visitors`, `conversion`, `confirmed` and `steps`. `steps` has one entry per funnel step after "Visited site", each with `visitors` and `share` (of the row's visitors).
    - `landed_on` credits each visitor to the page of their first visit in the period.
  - Breakdown rows all have `name`, `visitors`, `people`, `confirmed` (`null` while the project doesn't confirm), `conversion` (of the row's visitors) and `engaged_ms` (the row's visitors' median). Most visitors come first.
  - `acquisition`: where visitors first came from. Each visitor is in one row of each list, so each list adds up to `visitors`.
    - `channels`: rows named `Paid search`, `Paid social`, `Paid other`, `Organic search`, `Organic social`, `Email`, `AI assistants`, `Referral` or `Direct`. Filters has the rules.
    - `sources`: rows by `utm_source`, else the referrer's host, else `direct`.
    - `campaigns`: one row per campaign link, named by every tag, such as `reddit · paid · launch · ad-1`. A missing tag shows as `(none)`. Untagged visitors come last, as `(untagged)`. A link with spend but no visitors still gets a row. Each row also has:
      - `tags`: the link's `source`, `medium`, `campaign` and `content`, each `null` when not set. `tags` is `null` for untagged.
      - `clicked`: the row's visitors who made any tracked click.
      - `low_sample`: true under 100 visitors. Don't call such a row better or worse.
      - `spend` and `cost_per_email`, in the project's currency. `spend` is the spend entered for the link on days inside the period. `cost_per_email` is that spend divided by the row's `people`. Both are `null` without spend, and `cost_per_email` is also `null` with no people. Filters don't narrow spend.
      - `impressions` and `clicks`: what the link's ad delivered in the period, from the ad platform. `clicks` are link clicks. Both are `null` for a link no ad uses.
      - `ad`: the ad whose link it is, or `null`. It has `id`, `name` (the ad's headline, quoted), `campaign_id`, `campaign` (the campaign's name) and `platform`, such as `Meta`.
  - `pages`: the lists `landed_on` (the first page of a visit), `viewed` (any page seen) and `left_from` (the last page of a visit). Rows are pages, and a visitor can be under several. Each row also has `bounce_rate`, of visits that landed on the page (`null` when unknown). Its `engaged_ms` is time on that page.
  - `key_actions`: forms submitted and tracked clicks. For one page, see `GET /projects/:id/analytics/pages`. Use it to answer "which page sells Pro?". Each has:
    - `kind` (`form` or `click`), `name` and `visitors`.
    - `conversion`: the share of all visitors who did it.
    - `top_channel`: where most of them came from.
    - `pages`: the pages it happened on, each a `path` with `visitors`. `(unknown)` means the page wasn't recorded.
  - `devices`: `device`, `browser` and `country` rows, from each visitor's first page view in the period. Countries are two-letter codes. `Unknown` when it wasn't recorded.
  - `referrals`: sign-ups that came through a friend's beta-list link, confirmed or not.
    - `people`: how many. `share`: the share of all `people` they make up (0 to 1).
    - `sharers`: people who brought at least one of those friends. `sharer_share`: sharers out of `people`.
    - `median_friends` and `top_friends`: friends per sharer, the median and the most.
    - `friends_per_referrer`: sharers grouped by friends brought, each a `label` (`1`, `2` or `3+`) with `referrers`.
    - `top_referrers`: up to 10 sharers, most friends first, each with `friends`, `place` (their place in line now), `email` and `phone` (one is `null`). `email` and `phone` are there only with the *emails* permission.
    - `milestones_reached`: milestones (see Referrals) people reached in the period.
    - `waiting_for_review`: friends held for review now (see Flagged referrals). Friends held or rejected never count in `people`, `sharers` or the line.
    - `visitors`: visitors who arrived through a friend's link. `conversion`: the share of them who submitted a form. Compare it with the overall `conversion`.
  - The founder's screen shows the same numbers. A card filtered on its own dimension (Sources, when filtering by source) also shows the other rows, dimmed, for context. The API lists only the filtered rows.
- `GET /projects/:id/analytics/pages?page=/pricing`: get the Pages view. *analytics*
  - Takes `days` and filters, like the Overview.
  - `days`, `from`, `to` and `filters`, as above.
  - `pages`: the `landed_on`, `viewed` and `left_from` lists, as in the Overview.
  - `page`: one page's detail when you send `page`, else `null`. It has:
    - `path` and `visitors`.
    - `engaged_ms`: their median time on this page.
    - `landed`: visitors whose visit began here.
    - `left_share` (0 to 1): the share of this page's views that ended the visit.
    - `scroll`: for each `depth` (25, 50, 75 and 100 percent), the `share` of its viewers who got at least that far. It counts only viewers whose browser reported scroll. `reached_bottom` is the 100 one.
    - `came_from`: the page before it in the same visit (`From /`). When the visit began here, it's where the visit came from: source and campaign, or the source (`Landed from google · brand`).
    - `went_next`: the page after it (`To /signup`), `Left the site`, and `Did a key action here`.
    - Each `came_from` and `went_next` row is a `name` with `visitors`.
    - `key_actions`: forms and tracked clicks done on this page, each with `kind`, `name`, `visitors` and `share` (of the page's visitors).
- `GET /projects/:id/analytics/compare?by=campaign`: compare campaign links, versions or ads side by side (the Compare view). *analytics*
  - Takes `days` and filters, like the Overview. Every column follows them.
  - `by`: `campaign`, `version` or `ad`. Without it, it's `version` when the project tests versions, else `campaign`. `version` on a project that doesn't test versions gives `campaign`, and so does `ad` on a project with no campaigns.
  - `days`, `from`, `to` and `filters`, as above.
  - `columns`: one per campaign link, or one per version visitors saw. Each column is the period's numbers for its visitors alone, as if you'd added that campaign or version to the filter.
    - Campaign links: the 6 with the most visitors. Untagged visitors are left out. A link with spend but no visitors still gets a column.
    - Versions: in key order, named like `A · Original`.
    - Ads: the campaign links that ads use, the 6 with the most visitors, named like `Founders · broad · “Habits your team keeps”`.
  - Each column has:
    - `name`, `visitors`, `people`, `confirmed`, `conversion` and `engaged_ms`.
    - `tags`: a campaign link's `source`, `medium`, `campaign` and `content`. `null` for versions.
    - `low_sample`: true under 100 visitors. Don't call such a column better or worse.
    - `spend` and `cost_per_email`, for campaign links with spend in the period. `null` otherwise, and always for versions.
    - `impressions`, `clicks` and `click_rate` (clicks out of impressions, 0 to 1), for links an ad uses. `null` otherwise.
    - `ad`: the ad whose link it is, as in the Overview's campaign rows, or `null`.
    - `daily`: one entry per day, oldest first, with `date`, `visitors`, `people` and `conversion`.
  - Hunchwire never picks a winner, and the founder's screen says so. Neither should you on a low sample.
- `GET /projects/:id/analytics/visitors?page=1`: list the visitors in the period, under the filters. *analytics*
  - Most recently seen first, 50 a page.
  - `days`, `from`, `to`, `filters`, `total` (all of them), `page` and `per_page`.
  - `visitors`, each with:
    - `id`: the browser's id. Use it to get the journey.
    - `label`: a short name, such as `Visitor 7f3a`.
    - `came_from`: their first visit's source and campaign, such as `reddit · launch`, or the source.
    - `landed_on`: the page their first visit in the period began on.
    - `engaged_ms`: their total in the period.
    - `key_actions`: forms and clicks they did, in order.
    - `last_seen`.
    - `email`, with the *emails* permission, when they signed up.
- `GET /projects/:id/analytics/visitors/:visitor_id`: get one visitor's journey over all time: every visit, and every page, click and form in each. *analytics*
  - `404` if the browser never visited.
  - `id`, `label`, `first_came`, `channel`, `campaign` (`reddit / cpc / launch`; `null` untagged), `source`, `device`, `browser`, `country`, `pages` (page views) and `engaged_ms` (their total).
  - `visits`: oldest first, each with `started_at`, `source` (where that visit came from), `returning` (an earlier visit was on another day) and `events`.
  - `events`: each a `kind` (`view`, `click` or `form`), `name` (the page, click or form) and `at`. Views also have `engaged_ms` and `scroll_depth` (percent), `null` when not reported.
  - `emails`: the emails this browser signed up with, with the *emails* permission.
  - Use it before an interview: see what they read, for how long, and what they clicked before signing up.
- `GET /projects/:id/live`: see who's on the site now. *analytics*
  - `count`: visitors with a page view in the last 5 minutes.
  - `visitors`: each with `id`, `label`, `path` (their latest page), `seen_at`, `source` and `device`.
  - `last_signup_at`: the newest sign-up, or `null`.

### Create and change a project

- `POST /projects`: create a project. *manage*
  ```
  {"project": {"name": "Loop: habit tracker for teams", "slug": "loop", "way": "hosted_pages", "sender": "Loop Labs Inc.", "domains": ["getloop.app"]}}
  ```
  - `way` is required: `hosted_pages` or `own_site_forms`. Ask the founder whether they already have a site. If they do, it's usually `own_site_forms`.
  - `sender`: the person or business that asks visitors for permission to email them. The waitlist form names them, as Canada's anti-spam law requires. At most 100 characters. Ask the founder for it. Don't guess.
  - `slug`: the site's address, `loop.hunchwire.site`. 2–40 lowercase letters, numbers or hyphens. `www`, `app`, `admin`, `api`, `mail` and `status` are reserved.
  - `domains` (optional): listed domains serve the site if they point at Hunchwire. They're the only other places the form works.
  - Past the plan's project limit, the new project starts in `preview`. Tell the founder, then build it as usual.
  - `domains` can't include `hunchwire.site` or anything under it or under `hunchwire.com`. Those are Hunchwire's own. Hunchwire's own landing page is the one project that lists `hunchwire.com` itself.
- `PATCH /projects/:id`: change `name`, `slug`, `way`, `sender`, `mail_from`, `mailing_address`, `email_by_hand` or `domains`. *manage*
  - Changing `way` away from `hosted_pages` takes the hosted site offline. Its pages are kept. Only do it when the founder asks, and tell them first. Hunchwire's own landing page stays `hosted_pages`.
  - It also pauses the live campaigns whose ads lead to the project's connected domain, first. The response then adds `paused_campaigns` (see Campaigns).
- `POST /projects/:id/go_live`: put a project in `preview` live. *manage*
  - Only do this when the founder asks.
  - It's a release gate (see Plans and gates). If the plan has no room for another live project, it returns `402` `plan_required`. The project then goes live by itself once the founder upgrades.
- `POST /feature_requests` with `feature` `payments` or `reddit_ads`: ask to hear when payments or Reddit ads ship. *manage*
  - Neither is built yet. This tells Hunchwire the founder wants it, so they hear when it ships.
  - Asking twice is fine.
  - Returns `feature` and `requested_at`.
- `DELETE /projects/:id?confirm=<slug>`: delete a project, with its pages, analytics and people. *manage*
  - This can't be undone. Only do it when the founder asks.
  - Returns `204`. If the project had live campaigns, Hunchwire pauses them on Meta first and returns `200` with `paused_campaigns`.

### Read a project's setup

- `GET /projects/:id/setup`: get the project's setup. *analytics*
  - Returns the project, plus `pages` (without content), the `embed_snippet` and `spends` (each with `id`, `tags`, `amount` and `spent_on`).
  - `checklist`: what the project needs before going live, as in the founder's **Build › Go live**. Each check has `key`, `title`, `done` (true or false), `detail` (why, in one line), `section` and `action` (the step, as the founder's button names it). `section` is where the founder fixes it: a Build step as `build/<step>` (`build/where`, `build/page`, `build/after`, `build/place`), or a Setup view (`settings`, `tracking`, `share`), `versions`, `let-in`, `api` or `webhooks`. The checks follow `way`, always in this order:
    - `hosted_pages`: `details`, `site`, `form`, `funnel`, `clicks`, `links`.
    - `own_site_forms`: `details`, `domains` (at least one listed), `connect` (a sign-up arrived from one of them), `links`.
    - `own_site_api`: `details`, `api_key` (the secret key exists), `api_signup` (a sign-up arrived through the API), `links`.
    - `links` is done when under 15% of the last 30 days' visitors arrived as direct.
    - `mail_from` (Send from) comes last, for every way, while the project can email its people but has no Send from address. It's never done: it leaves the list once the address is set. While it shows, `optional` leaves out `email`.
  - `optional`: checks the project may add but needn't. They never count. They have the same fields as the checks.
    - For `hosted_pages`: `versions` (Test a second pitch: done once it has two versions or more), `question` (Ask a question after sign-up: done once it's on) and `domain` (Use your own domain: done once it serves).
    - For `own_site_api`: `webhook` (Webhooks: done once it has a URL and isn't failing).
    - For every way: `let_in` (Let people in: done once invites have an address) and `email` (Email your people: done once the project can email its people, or the founder sends by hand; `detail` names the next step).
  - `last_visit_at` and `last_signup_at`: the newest page view and sign-up, or `null`. Use them to answer "is it working?".
  - `live_check`: the founder's Live check. `state` is `waiting` (nothing yet), `received` (the newest event is a sign-up) or `refused` (the newest event is a refused site).
    - `last_signup`: the newest sign-up, or `null`. It has `at`, `via` (`embed` or `api`), `place` (while the line moves, else `null`) and `email` (with *emails*, else `null`).
    - `last_refused`: the newest site refused because it isn't one of the project's hosts, or `null`. It has `host` and `at`.
  - `tracked_clicks`: the tracked clicks seen in the last 30 days, most visitors first. Each has `name`, `page` (where it's clicked most), `visitors` and `last_click_at`.
  - `ad_limit`: the project's monthly limit for ads, with the fields in `limits` (see Ad accounts), or `null`. `ad_hosts`: the project's own domains, where its Meta ads may lead. Empty means it can't launch ads yet.
  - `internal_traffic`: what's kept out of the numbers (see Internal traffic). `ignore_links` has one "ignore this browser" link per host. `ignored_bot_count` and `ignored_internal_count` are the page views dropped so far, from bots and from the founder's ignored IP addresses. `current_ip_ignored` says whether your own IP is one of them.
- `GET /projects/:id/setup/:section`: get the same response. `section` is `connect`, `tracking`, `share`, `payments` or `settings`, the founder's Setup views. *analytics*
- `GET /projects/:id/setup/site/pages`: get the same response. The founder's pages and files are in **Build › Page**. *analytics*
  - To put the form on a site Hunchwire doesn't host, see The embed.

### Build a waitlist

**Build** is where the founder builds a waitlist, step by step, beside a live preview of the real page. Each step's settings are the calls you already make: the page (Pages), the form (Save the form), questions after sign-up, referrals and the domain. The preview shows the real page in preview mode, so it counts nothing.

- `GET /projects/:id/build`: get the steps and the link to send the founder. *analytics*
  ```json
  {"steps": [{"key": "where", "title": "Where it lives", "done": true, "summary": "loop.hunchwire.site"}, {"key": "page", "title": "Page", "done": false, "summary": "No pages yet"}], "done": 4, "total": 6, "open": "page", "preview_url": "https://app.hunchwire.com/projects/1/build"}
  ```
  - `steps`: in the founder's order, each with `key`, `title`, `done` and `summary` (one line, as the founder sees it). The steps follow `way`:
    - `hosted_pages`: `where`, `page`, `form`, `after`, `referrals`, `live`.
    - `own_site_forms` and `own_site_api`: `where`, `form`, `after`, `referrals`, `place`, `live`.
  - `done` counts the steps done, out of `total`. `open` is the first step not done: Build opens there.
  - `preview_url`: Build, for the founder to open after you change the page or the form.
  - `where` is done for hosted pages, for an own site once `domains` has one, and for the API once the secret key exists. `page` is done once there's a home page at `/`. `referrals` is done while they're on. `place` is done once a visit or sign-up came from one of the project's `domains`. `live` is done while the project is live.

### Set the funnel

- `PATCH /projects/:id/funnel`: set the funnel. *manage*
  ```
  {"funnel": {"steps": [{"kind": "page", "value": "/pricing"}, {"kind": "click", "value": "plan-*"}, {"kind": "form", "value": "waitlist"}]}}
  ```
  - Up to 5 steps after "Visited site".
  - `kind` is `page`, `click` or `form`.
  - A value ending in `*` matches a prefix.
  - Send `[]` for the default funnel: visited, then any form.

## Launching a smoke test

1. `POST /projects` with a name, slug and `way`. Use `hosted_pages` to build the test site yourself.
2. Add pages: home, pricing (with tracked plan buttons), sign-up (a waitlist) and log-in (the fake login).
3. `PATCH /projects/:id/funnel` with the path you expect: page → click → form.
4. Bring visitors. Run a small Meta ad from Hunchwire (see Create a campaign). It needs the `ads` permission and a monthly limit the founder sets. Each ad gets its own campaign link. For posts and ads elsewhere, save a campaign link for each with `POST /projects/:id/campaign_links`, a different `content` per ad. Give the founder each `url`. Without them, traffic from apps that hide their referrer shows as "direct".
5. Later, `GET /projects/:id?days=7` and analyze it with the founder (below).

## Analyze with the founder

You do the analysis. The founder decides. Hunchwire never picks a winner, and neither should you on thin numbers.

1. **Start from what's known.** `GET /projects` returns each project's `insights`: what was concluded before, and the founder's `reply`. Pick up from there.
2. **Read the numbers.** Compare `GET /projects/:id?days=30` with `previous`. Then put campaigns side by side with `GET /projects/:id/analytics/compare` (or versions, when the project tests versions), and use the filters below.
3. **Say it plainly.** Two or three lines: what you found, its number, and what you'd try. Then ask what they make of it.
4. **Save what you agree on.** When you both land on something worth keeping, `POST /projects/:id/insights` (see Insights). Write one or two sentences, the number, and what to try, with `link` to the view it came from, and `seen: true`, since you agreed on it together. Never include an email address. Never name a winner on fewer than about 100 visitors.

What to look for:

- **Conversion** is the share of visitors who submitted any form. Compare projects and campaigns on conversion, not raw sign-ups.
- **Paid first:** judge paid tests on paid traffic alone: `filter[channel][]=Paid search&filter[channel][]=Paid social`. Mixing in free traffic hides the paid rate.
- **Read or not read:** low `engaged_ms` and a high `bounce_rate` on a page mean visitors don't read it. Long engagement with low conversion means they read it, but it doesn't convince.
- **Sample size:** don't call a campaign or version better or worse on fewer than about 100 visitors (`low_sample`).
- **Where it breaks:** the funnel step with the biggest `drop_off` is where the project or the page loses visitors.
- **Junk is filtered out:** **People** excludes bots, throwaway addresses and domains that can't receive mail. Check `GET /projects/:id/blocked` after an ad run to see what was turned away.
- **Login attempts** (the `click:login-attempt` key action) show strong intent: visitors expected a product to exist. They aren't sign-ups. They never count toward conversion or **People**. To make them a funnel step, use `{"kind": "click", "value": "login-attempt"}`.
- **Trend:** compare with `previous` to say whether things are improving, not only where they are. Check `notes` before you blame or credit a change.
- **Where each audience drops:** `funnel_breakdown` with `by=campaign` shows which ad's visitors stop at pricing.
- **Word of mouth:** `referrals.share` is how much of the list came from friends. A high `sharer_share` means most people share when asked. A few `top_referrers` with most of the friends means growth rests on a handful of fans.

## Campaign links

Save a link for each ad or post before it goes out. The founder sees saved links in **Setup › Share** as soon as you save them. Each shows its link to copy and what it has brought so far.

- `GET /projects/:id/campaign_links`: list the saved links. *analytics*
  - Returns `campaign_links`: active links first, then by when they were saved. Add `?archived=true` to include archived links.
  - Each link has `id`, `name`, `tags`, `destination`, `url`, `archived`, `created_at` and its numbers for all time.
    - `tags`: `source`, `medium`, `campaign` and `content`. A tag that isn't set is `null`.
    - `destination`: the page the link leads to.
    - `url`: the link to put in the ad. It's `destination` plus one `utm_` parameter per tag.
    - `visitors`: visitors the link brought.
    - `people`: unique emails.
    - `clicked`: visitors who made any tracked click.
    - `spend`: every spend entry with the same tags, on any day. `null` without spend.
    - `cost_per_email`: `spend` divided by `people`. `null` without spend or people.
    - `low_sample`: whether the link has under 100 visitors.
  - A visitor counts for a link when the four tags of their first tagged visit equal the link's exactly. A missing tag matches only a missing tag. Case matters: `Ad-1` isn't `ad-1`.
  - `unsaved`: tagged visits whose tags match no saved link, archived links included. Each has `tags`, `visitors` and `people`. A typo in an ad's link shows up here. To track it, save it as its own link.
- `POST /projects/:id/campaign_links`: save a link. *manage*
  ```
  {"campaign_link": {"name": "Reddit · ad 1", "source": "reddit", "medium": "paid", "campaign": "t1", "content": "ad-1", "destination": "https://hunchwire.com/pricing"}}
  ```
  - `source` and `destination` are required.
  - `destination` must be on one of the project's hosts. Hunchwire drops its query.
  - `name` is up to 80 characters. It defaults to the tags joined with ` · `.
  - Hunchwire trims the tags. No two links in a project can share the same four tags, archived links included. A duplicate is a 422: "A saved link already has these tags".
  - Use a different `content` for each ad, even when two ads lead to different pages.
  - Returns the link, with the `url` to give the founder.
- `PATCH /projects/:id/campaign_links/:campaign_link_id`: change `name` or `destination`, or archive the link. *manage*
  - Send `archived: true` to archive it. `false` restores it.
  - Tags can't change once saved, because the link is already in ads. Trying is a 422 that says so. Save a new link instead.
  - Archiving only hides the link from **Setup › Share**. Its visits and spend still count in analytics. There is no delete.

## Spend

Log what each campaign link cost, so analytics can show cost per email. Add one entry per day or per invoice. Analytics adds up the entries dated inside the period.

- `POST /projects/:id/spends`: add an entry. *manage*
  ```
  {"spend": {"source": "reddit", "medium": "paid", "campaign": "t1", "content": "juicer", "amount": 42.50, "spent_on": "2026-09-28"}}
  ```
  - The tags must match the link's UTM tags exactly. `source` is required.
  - For a saved link, send `"campaign_link_id": <id>` instead of the tags.
  - `amount` is in the project's currency, 0 or more.
  - An ad's link takes no entries: its spend comes from the ad platform every hour (see Campaigns). Hunchwire returns `422` with the reason.
- `PATCH /projects/:id/spends/:spend_id`: change an entry's tags, `amount` or `spent_on`. *manage*
- `DELETE /projects/:id/spends/:spend_id`: remove an entry. *manage*
- Only entries typed in are listed, changed or deleted here. Spend from ads returns 404.

## Ad accounts

Read which ad accounts the founder connected. An *ad account* is the founder's account on an ad platform, such as Meta. Only the founder connects one, in **Ad accounts** in the account menu.

- `GET /ad_accounts`: list the connected ad accounts. *analytics*
  ```json
  {"ad_accounts": [{"id": 3, "platform": "meta", "external_id": "act_2184", "name": "Kairic Studio", "currency": "USD", "time_zone": "America/Toronto", "page_id": "104882", "page_name": "Kairic Studio", "state": "connected", "reconnect_needed_at": null, "last_synced_at": "2026-10-18T14:20:03Z", "projects": [{"id": 12, "name": "Loop: team habit tracker"}], "campaigns_imported": 3, "spent_this_month": 148.0}]}
  ```
  - Returns `ad_accounts`, oldest first. Disconnected accounts aren't listed.
  - `id`: Hunchwire's id for the account.
  - `platform`: `meta`. Reddit is coming.
  - `external_id`: the platform's id for the account.
  - `name`: the account's name on the platform.
  - `currency`: the account's currency, as an ISO code. Every amount on the account is in it, never converted.
  - `time_zone`: the account's time zone on the platform, like `America/Toronto`. Campaign dates are days in it. It's `null` while Hunchwire doesn't know it.
  - Hunchwire reads the zone when the founder connects the account, and again every hour until it has it. While it's `null`, campaigns with dates can't launch.
  - `page_id` and `page_name`: the Facebook Page its ads run as.
  - `state`: `connected` or `reconnect_needed`. `reconnect_needed` means Meta stopped accepting the token. Ask the founder to reconnect it.
  - `reconnect_needed_at`: when Meta stopped accepting it, or `null`.
  - `last_synced_at`: when Hunchwire last read the account's results, or `null`.
  - `projects`: the projects its campaigns lead to, each with `id` and `name`.
  - `campaigns_imported`: how many of its campaigns were made in Meta and imported.
  - `spent_this_month`: what its ads spent this calendar month, in its `currency`.
  - `limits`: the projects that run ads on it, each with its monthly limit (see Campaigns). Each has `project_id`, `ad_account_id`, `currency`, `monthly_limit`, `spent_this_month`, `room_left` and `resets_on` (the day the limit starts again).
  - `warning` is `true` from 80% of the limit. `paused_at_limit` is `true` while the limit has the project's ads paused this month.
  - `room_left` is the limit less this month's spend, less what the project's live and launching campaigns may still spend this month (each one's daily budget times its days left, at most what its spend cap allows).
  - Hunchwire never returns the token.
- `GET /ad_accounts/:id/targeting?q=trainer`: search Meta for countries, regions, languages and interests to aim an ad set at. *ads*
  ```json
  {"targets": [{"kind": "interest", "id": "108240792538224", "name": "Personal trainer", "type": "work_position"}, {"kind": "interest", "id": "6003139266461", "name": "Entrepreneurship", "type": "interest"}]}
  ```
  - Takes `q` and `kind`: `country`, `region`, `language` or `interest`. Without `kind`, it returns countries, regions, then interests.
  - Returns `targets`. Each has `kind`, `id`, `name`, `type` and `country`.
  - An *interest* is anything Ads Manager's Detailed targeting finds: interests, behaviors, job titles, industries and life events. `type` names which, like `interest`, `behavior` or `work_position`. It's `null` for a country or a language.
  - A country's `id` is its two-letter code. A language's `id` is Meta's: `1001` is English (All).
  - A *region* is a state or province. Its `id` is Meta's, and `country` is its country's two-letter code. `country` is `null` for anything else.
  - Put an interest's `id`, `name` and `type` in an ad set's `interests` or `narrow_interests`, a language's `id` and `name` in its `languages`, a country's `id` in its `countries`, and a region's `id`, `name` and `country` in its `excluded_regions`.

### Help the founder connect Meta

Connecting decides what agents may spend from, so it stays with the founder. Give them these steps:

1. Open **Ad accounts** in the account menu, then **Connect Meta**.
2. In Meta's **Business Settings › Users › System users**, add a system user named Hunchwire.
3. Assign it the ad account and the Facebook Page, with full control.
4. Generate a token with `ads_management` and `ads_read`. Paste it, then pick the ad account and the Page.

Reconnecting is the same steps with a new token. Disconnecting forgets the token. The account's campaigns and spend stay.

Once it's connected, Hunchwire imports the account's campaigns, with 30 days of results. Then it reads them every hour. See Campaigns.

## Campaigns

Run Meta ads to a project and read what they brought: each campaign's spend beside the visitors and sign-ups it sent. The founder sees it in **Campaigns**, a project tab.

A campaign holds one or more *ad sets*. Each ad set has its own audience and daily budget, and its own ads, as in Meta. The campaign holds the dates, the state and the spend cap. On Meta, ad sets never share budget with each other: each spends its own.

Hunchwire reads a connected ad account every hour. It takes each campaign's state, each ad set's state and budget, each ad's review, and the last 3 days of results. Visitors and sign-ups come from the project's own pages, matched by each ad's campaign link. A campaign made in Meta is *imported*: it shows here read-only until you edit it.

With the `ads` permission, you create campaigns and launch, pause, resume and end them. You spend only inside the project's *monthly limit*: the most its ads may spend each calendar month, on one ad account. Only the founder sets it, in **Setup › Share › Ads**. Hunchwire takes no cut of ad spend. Meta bills the founder's own ad account.

### What a campaign holds

A *campaign* has:
- `id`, `name`, `project_id`, `ad_account_id` and `platform` (`meta`).
- `state`: where it is, in the table below. `paused_by` says who paused it, in the second table. `paused_at`: when it was last paused, or `null`.
- `imported`: `true` for a campaign made in Meta and not yet edited here.
- `optimization_goal`: what Meta optimizes its ad sets for, in the table below. `null` on an imported campaign whose goal is something else in Meta.
- `currency`: its ad account's. Every amount on it is in this currency, never converted.
- `daily_budget`: what its ad sets may spend a day together, leaving out ended ones. `null` with lifetime budgets. You set budgets on each ad set.
- `starts_on` and `ends_on`: its dates, or `null`. Every ad set runs on them.
- `time_zone`: its ad account's time zone, or `null` while Hunchwire doesn't know it. Start and end times are in this zone.
- `starts_at` and `ends_at`: when it starts and stops, with the zone's offset, like `2026-10-21T19:00:00.000-04:00`. They're what Meta gets. A date without a time starts at 00:00 and stops at 23:59:59 on its last day. Both are `null` without the date or while `time_zone` is `null`.
- `starts_at_utc` and `ends_at_utc`: the same moments in UTC, like `2026-10-21T23:00:00.000Z`.
- Check `time_zone`, `starts_at` and `ends_at` before you launch. A time you send with an offset is kept as the zone's clock time.
- `ad_sets`: its ad sets, in order. See below.
- `spend_cap`: the most Meta lets it spend in all, set at launch, or `null`. Meta stops it there even if Hunchwire's hourly read is late.
- `last_error`: Meta's reason for a failed launch, else `null`. `launched_at`: when it first went live, or `null`.
- `external_id`: the platform's id for it.

| `state` | Meaning |
|---|---|
| `draft` | Made in Hunchwire, not launched. |
| `held` | Launched on a plan without ads. It launches by itself once the founder upgrades. |
| `launching` | Being made on Meta, paused, then switched on. Usually under a minute, or up to 15 while Meta processes a video. |
| `active` | Running on Meta. |
| `paused` | Paused by `paused_by`. |
| `ended` | Stopped for good. Its history and results stay. |
| `failed` | Meta refused the launch. `last_error` is Meta's reason. Nothing runs. Launch again to retry. |

| `optimization_goal` | Meta shows the ads to |
|---|---|
| `landing_page_views` | People likely to click and wait for the page to load. The default. |
| `link_clicks` | People likely to click. Clicks cost less, but more are accidental taps that never see the page. |

| `paused_by` | Who paused it |
|---|---|
| `person` | The founder. |
| `agent` | An agent. |
| `meta` | Someone, in Meta Ads Manager. |
| `limit` | Hunchwire, at the project's monthly limit. It resumes on the 1st. |
| `billing` | Hunchwire, when the account stopped paying. It resumes once the founder chooses a plan. |
| `guard` | Hunchwire, before the page its ads lead to was removed. |

Its *ad sets* each have:
- `id`, `campaign_id` and `name`.
- `state`: `draft` until the campaign launches, then `active`, `paused` or `ended`. `paused_by` is `person`, `agent` or `meta`, else `null`.
- `daily_budget`: what it may spend a day, or `null` with a lifetime budget.
- `audience`: who sees it, in words, like `US, CA except Quebec · ages 25–54 · Advantage+ · mobile only · English · 6 placements · interests: Startups · and also: Software as a service`. `null` when Hunchwire can't read it.
- `countries`, `age_min` and `age_max`: where they live and how old they are.
- `excluded_regions`: states or provinces left out of its countries, each with `id`, `name` and `country`. Empty when none are.
- `advantage_audience`: `true` when Meta may reach past its oldest age and interests (Advantage+ audience). `false` keeps Meta strictly to them.
- `interests`: people who match any of these. Each has `id`, `name` and `type`.
- `narrow_interests`: people must also match one of these (Must also match in Ads Manager). Empty when it doesn't narrow.
- `languages`: the languages they use, each with `id` and `name`. Empty for all languages.
- `placements`: where its ads show, from the table below. Empty for Meta's automatic placements.
- `devices`: `both`, `mobile` or `desktop`. The devices its ads show on.
- `external_id`: the platform's id for it, once it's on Meta.

| `placements` key | Where the ads show |
|---|---|
| `facebook_feed` | Facebook Feed |
| `facebook_stories` | Facebook Stories |
| `facebook_reels` | Facebook Reels |
| `instagram_feed` | Instagram Feed |
| `instagram_stories` | Instagram Stories |
| `instagram_reels` | Instagram Reels |

Choosing placements leaves out Audience Network, Messenger and the right column. Meta removed Instagram Explore: a request naming `instagram_explore` returns 422 with the placement named, and an ad set saved earlier with it shows on its other placements, with a line in the campaign's history.

Its *ads* each have:
- `id`, `campaign_id`, `ad_set_id`, `name`, `headline`, `body`, `call_to_action` and `destination_path` (the page it leads to).
- `headlines` and `bodies`: every headline and text, the lead first. `headline` and `body` are the lead.
- `format`: `image`, `video` or `carousel`.
- `description`: the line under the headline, or `null`.
- `state`: `active`, `paused` or `ended`.
- `review`: Meta's review, `pending`, `approved` or `rejected`. `rejection_reason` is Meta's words for a rejected ad, else `null`. Tell the founder what to change in Meta.
- `campaign_link_id` and `tags`: the campaign link it counts through. See Campaign links.
- `image`: `true` once it has an image. `video`: `true` once it has a video.
- `story_image` and `story_video`: `true` once it has a Stories and Reels image or video.
- `cards`: a carousel's cards, in order. Each has `id`, `position`, `headline`, `description`, `destination_path` and `image`. A card's `null` headline, description or page shows the ad's.

### Read campaigns

- `GET /projects/:id/campaigns?days=30`: list the project's campaigns with what each brought in the period. *analytics*
  ```json
  {"days": 30, "from": "2026-09-19", "to": "2026-10-18", "totals": {"spend": 252.0, "visitors": 755, "visitor_share": 0.65, "people": 38, "conversion": 0.0503, "cost_per_email": 6.63, "platforms": [{"platform": "Meta", "spend": 112.0, "people": 15, "cost_per_email": 7.47}, {"platform": "Reddit", "spend": 140.0, "people": 23, "cost_per_email": 6.09}]}, "campaigns": [{"id": 4, "name": "Founders · broad", "state": "active", "spend": 62.0, "impressions": 8420, "clicks": 1880, "click_rate": 0.2233, "visitors": 214, "people": 11, "cost_per_email": 5.64, "ads": [{"id": 9, "name": "Habits", "review": "approved", "spend": 38.0, "visitors": 128, "people": 8, "cost_per_email": 4.75, "shared": false}]}], "links": [{"name": "Productivity subreddits", "platform": "Reddit", "spend": 140.0, "visitors": 410, "people": 23, "cost_per_email": 6.09}], "ad_accounts": [{"id": 3, "name": "Kairic Studio", "state": "connected"}]}
  ```
  - Takes `days`: 7, 14, 30 or `all`. Filters don't apply.
  - A campaign with a start time counts only visitors whose first visit came at or after `starts_at`. Visits before it are Meta's review, so its ads can show fewer visitors than their links in Analytics.
  - `days`, `from` and `to`: the period.
  - `campaigns`: live ones first, then by spend. Each is a campaign with its numbers for the period:
    - `spend`, `impressions` and `clicks`: from the ad platform. `clicks` are link clicks. `click_rate` is clicks out of impressions, or `null`.
    - `visitors` and `people`: who its ads' links brought. A link two ads share counts once.
    - `cost_per_email`: `spend` divided by `people`, or `null` without both.
    - `ad_sets`: each ad set, with the same numbers for its own ads.
    - `ads`: each ad, with the same numbers. `shared` is `true` when another ad uses the same link, so their visitors can't be told apart.
  - `links`: paid links no ad uses, with spend typed in for the period. Each has `name`, `platform` (from its `source`), `tags`, `spend`, `visitors`, `people` and `cost_per_email`.
  - `totals`: everything ads brought. `visitor_share` is the share of all the project's visitors (0 to 1). `conversion` is `people` out of `visitors`. `platforms` gives each platform's `spend`, `people` and `cost_per_email`.
  - `ad_accounts`: the workspace's accounts, with `id`, `name`, `state`, `reconnect_needed_at` and `last_synced_at`. Numbers stop updating while one needs a reconnect.
- `GET /campaigns/:id?days=30`: get one campaign, its ads and its history. *analytics*
  - Returns `days`, `from`, `to` and `campaign`: the campaign, its numbers and `ads`, as above.
  - `history`: what happened to it, newest first. Each entry has `kind`, `description` (who did what, with the amount), `ad_id` and `ad_set_id` (what it concerned, or `null`) and `at`. `kind` is `imported`, `changed_in_meta`, `drafted`, `launched`, `held`, `failed`, `paused`, `resumed`, `ended`, `budget` or `edited`.

### Create a campaign

1. `GET /ad_accounts` to see each project's `room_left` this month. With no limit for the project, ask the founder to set one.
2. Find interests and countries with `GET /ad_accounts/:id/targeting`.
3. Upload images as project images (see Pages).
4. Create the draft with its ad sets and their ads.
5. Launch it. Tell the founder what you launched and what it may spend.

- `POST /projects/:id/campaigns`: create a draft. *ads*
  ```
  {"campaign": {"name": "Founders", "starts_on": "2026-10-20", "ends_on": "2026-11-03", "ad_sets": [{"name": "Founders · broad", "daily_budget": 6, "countries": ["US", "CA"], "age_min": 25, "age_max": 54, "interests": [{"id": "6003397425735", "name": "Startups"}], "ads": [{"headline": "Habits your team keeps", "body": "Loop keeps your team's habits on track, one check-in a day.", "destination_path": "/", "image_path": "/images/hero.png"}]}]}}
  ```
  - `name` and at least one ad set are required.
  - `ad_account_id` is the project's limit account by default.
  - `starts_on` and `ends_on` are whole days in the ad account's `time_zone`. Both are optional on a draft. Leave them out to let the founder pick them at launch.
  - A launch needs `starts_on`. Leave `ends_on` out to run until you end it.
  - To start or stop at an exact time, send `starts_at` and `ends_at` instead. A local time, like `2026-10-21T19:00`, is 7:00 PM on the ad account's clock. It works while `time_zone` is `null`.
  - `starts_at` and `ends_at` also take a time with an offset, like `2026-10-21T23:00:00Z`. Hunchwire keeps it as the ad account's clock time. It's refused while `time_zone` is `null`.
  - A date alone, like `2026-10-21`, is the whole day.
  - `starts_at` wins over `starts_on`, and `ends_at` over `ends_on`. Send `ends_at` as `null` to run until you end it.
  - The end must be after the start. Hunchwire keeps minutes and drops seconds.
  - `ad_sets`: each needs `daily_budget` (in the ad account's currency, above 0) and at least one country. `name` defaults to the campaign's.
  - `age_min` and `age_max` are 18 to 65. Leave `interests` out to let Meta find people.
  - `narrow_interests`, `languages` and `placements` are optional. A `type` left out is `interest`. Narrowing with no `interests` targets the narrow ones alone.
  - `excluded_regions` is optional. Each region must be in one of the ad set's `countries`. Find them with `kind=region`.
  - `advantage_audience` is `false` by default. With `true`, Meta treats `age_max` and the interests as suggestions it may go beyond. Countries, regions, languages and `age_min` stay limits, and `age_min` must be 25 or under.
  - `devices` is `both` by default. Send `mobile` or `desktop` to show its ads on those devices only.
  - `ads`: each ad set's ads, with the fields of `POST /campaigns/:id/ads`.
  - `optimization_goal` is `landing_page_views` by default. Send `link_clicks` for cheaper clicks. Every ad set gets it.
  - It's fixed once the campaign launches. Sign-up conversions aren't sent to Meta, and Meta needs no pixel for either goal.
  - Returns `201` with `campaign`, its `ads` included.
- `POST /campaigns/:id/ads`: add an ad. *ads*
  ```
  {"ad": {"ad_set_id": 12, "headline": "Habits your team keeps", "body": "Loop keeps your team's habits on track, one check-in a day.", "call_to_action": "SIGN_UP", "destination_path": "/", "image_path": "/images/hero.png"}}
  ```
  - `ad_set_id` is the ad set it runs in. Leave it out when the campaign has one ad set. With several, leaving it out is a 422 `ad_set_required`.
  - `headline` is up to 100 characters, `body` up to 1,000.
  - To give Meta up to 5 headlines and 5 texts to rotate, send `headlines` and `bodies` as lists instead. The first of each leads. Meta shows each person the mix it expects to work best, and never writes its own.
  - `headlines` and `bodies` work on `image` and `video` ads. A carousel takes one of each: its cards carry their own.
  - `description` is the line under the headline, up to 100 characters. It's optional.
  - `call_to_action` is the button: `SIGN_UP` (the default), `LEARN_MORE`, `GET_OFFER` or `SUBSCRIBE`.
  - `destination_path` is the page it leads to, on the project's own domain.
  - `format` is `image` (the default), `video` or `carousel`. The table below says what each one needs.
  - `image_path` is one of the project's uploaded images. Or upload a PNG or JPEG up to 8 MB as a multipart `ad[image]`.

  | `format` | Send | Limits |
  |---|---|---|
  | `image` | `image_path`, or a multipart `ad[image]` | PNG or JPEG, up to 8 MB |
  | `video` | a multipart `ad[video]` | MP4 or MOV, up to 100 MB. Larger is a 422 `video_too_large`. |
  | `carousel` | `cards`: 2 to 10, in the order people swipe them | Each card needs an image |

  - To show a different file in Stories and Reels, send a 9:16 one beside the feed file. An image ad takes `story_image_path` or a multipart `ad[story_image]`. A video ad takes a multipart `ad[story_video]`. Same types and limits as above.
  - Meta then shows the Stories and Reels file in Facebook and Instagram Stories and Reels, and the feed file everywhere else. Without one, Meta fits the feed file into every placement.
  - A carousel takes no Stories and Reels file. Its cards show there too.

  An image ad with three headlines and three texts, the first of each leading:
  ```
  {"ad": {"headlines": ["Habits your team keeps", "Standups that stick", "One check-in a day"], "bodies": ["Loop keeps your team's habits on track.", "Loop runs your standup in Slack.", "Answers land in one thread."], "destination_path": "/", "image_path": "/images/hero.png"}}
  ```

  A carousel, with the same headline and page on every card:
  ```
  {"ad": {"format": "carousel", "headline": "Start in 2 minutes", "body": "Would anyone use your app? Find out before you write a line.", "description": "A page and a sign-up list for your idea.", "call_to_action": "LEARN_MORE", "destination_path": "/", "cards": [{"image_path": "/images/card1.png"}, {"image_path": "/images/card2.png"}, {"image_path": "/images/card3.png"}, {"image_path": "/images/card4.png"}]}}
  ```
  - Each card takes `image_path` (or a multipart `image`), and optionally its own `headline`, `description` and `destination_path`. Meta keeps the cards in your order and adds no end card.
  - A video goes as multipart, for example `curl -F "ad[format]=video" -F "ad[video]=@ad.mp4" -F "ad[headline]=Start in 2 minutes" …`. Meta processes it before the ad runs. Hunchwire waits for that, for up to 15 minutes.
  - Each ad gets its own campaign link: `source` `facebook`, `medium` `paid`, `campaign` the campaign's slug, `content` the ad's. Its visitors and sign-ups are counted apart.
  - `campaign_link_id` uses one of the project's saved links instead. The ad takes the link's tags and its page, so leave `destination_path` out. Several ads can share one link. Their visitors then count together.
  - A saved link must lead to the project's own domain. Otherwise Hunchwire answers `422` `link_not_on_own_domain`.
  - On a live campaign, the ad goes to Meta at once, for review. Returns `201` with `ad`.

### Launch, pause, resume and end

- `POST /campaigns/:id/launch`: launch a draft, or retry a failed launch. *ads*
  - Hunchwire checks the campaign has a start date, the ad account is connected, every ad set that runs has an ad, every ad has a headline, text, its image, video or 2 to 10 cards with images, and a page, and the project has its own domain.
  - It checks the budget fits: its ad sets' daily budgets together, times the days it runs this month, must be at most the project's `room_left`. Two campaigns that each fit alone can't launch together past the limit.
  - Everything is made paused on Meta, then switched on in one last call. Meta's `spend_cap` is the room left.
  - Returns `202` with `campaign` in `launching`. Read it again with `GET /campaigns/:id`. A refused launch ends in `failed`, with `last_error`.
  - Meta reviews new ads before they run, usually within a day. `review` is `pending` until then.
  - It's a release gate. On a plan without ads, Hunchwire answers `402` with `held: true`. The campaign is `held` and launches once the founder upgrades (see Plans and gates).
- `POST /campaigns/:id/pause`: pause a live campaign. *ads*
- `POST /campaigns/:id/resume`: resume a paused one. It checks the room left again, and raises `spend_cap` to its spend so far plus the room left. *ads*
  - While the account is inactive, it answers `402` `plan_required`.
- `POST /campaigns/:id/end`: end it for good. Ending a `held` campaign cancels its launch. *ads*
- Each returns `campaign`. Launching and pausing add a chart note. Every change shows in the founder's **Agents › Ads**, with its amount.
- An imported campaign is read-only. Edit it first with `PATCH /campaigns/:id` to manage it from Hunchwire. If Hunchwire paused it, you can resume it as it is.

### When Hunchwire pauses ads

Hunchwire pauses live campaigns by itself in three cases. Each pause shows in the campaign's `history` and as a chart note. The founder sees a banner on **Campaigns**, and gets an email when email is on.

- **At the monthly limit.** Hunchwire checks each project's spend every hour, after reading Meta. At 80% it emails the founder once that month. At 100% it pauses the project's live campaigns on its ad account (`paused_by` `limit`) and emails once. They resume on the 1st, if they still fit.
- **When the account stops paying.** A cancelled plan, a payment that didn't go through, or the emails limit makes the account inactive. Hunchwire pauses every live campaign in the workspace (`paused_by` `billing`). Once the founder chooses a plan, it resumes those campaigns, and only those.
- **Before something removes where ads lead.** Deleting the project, removing its domain, deleting a page an ad leads to, ending the version that holds it, or switching to its own site pauses those campaigns first (`paused_by` `guard`). The call returns them as `paused_campaigns`, each with `id`, `name`, `state` and `paused_by`. If Meta won't pause one, nothing is removed: the call answers with Meta's error.

Campaigns that you or the founder paused stay paused in every case. Raising the limit resumes nothing by itself.

- `POST /projects/:id/campaigns/resume`: resume every campaign the project's limit paused, after the founder raised it. *ads*
  - Each one is checked like `POST /campaigns/:id/resume`.
  - Returns `campaigns`: the ones resumed. When none fit, it answers with the error, such as `422` `over_spend_limit`.

### Change a campaign and its ads

- `PATCH /campaigns/:id`: change a campaign's `name`, dates or times, and its ad sets. *ads*
  ```
  {"campaign": {"starts_at": "2026-10-21T19:00", "ends_at": "2026-10-25T19:00", "ad_sets": [{"id": 12, "daily_budget": 8}]}}
  ```
  - Dates and times take the same forms as when you create it. A `starts_on` or `ends_on` sent alone is the whole day again.
  - On a live campaign, the change goes to Meta at once. A later end must still fit the room left.
  - `ad_sets` changes each ad set named by `id`, as `PATCH /ad_sets/:id` does. An ad set without `id` is added, as `POST /campaigns/:id/ad_sets` does.
  - Editing an imported campaign makes it Hunchwire's. Its ads get Hunchwire's tags on Meta, and Meta reviews them again.
  - `optimization_goal` changes only before launch. Once its ad sets are on Meta, a change is a 422 `optimization_goal_locked`. After a failed launch that made no ad set, you can still change it and launch again.
  - An ended campaign can't change.
- `POST /campaigns/:id/ad_sets`: add an ad set, with its ads. *ads*
  ```
  {"ad_set": {"name": "Team leads", "daily_budget": 4, "countries": ["US"], "age_min": 30, "age_max": 54, "ads": [{"headline": "Standups that stick", "body": "Loop runs your standup in Slack.", "destination_path": "/", "image_path": "/images/hero.png"}]}}
  ```
  - Takes the fields of an ad set in `POST /projects/:id/campaigns`.
  - On a running campaign, it needs an ad, and goes to Meta at once. On a live one, the campaign's new total must fit the room left.
  - Returns `201` with `ad_set` and its `ads`.
- `PATCH /ad_sets/:id`: change an ad set's `name`, `daily_budget` or audience. *ads*
  ```
  {"ad_set": {"daily_budget": 8}}
  ```
  - On Meta, the change goes there at once. A higher budget on a live campaign must still fit the room left.
  - Meta allows 4 budget changes an ad set an hour. Past that, Hunchwire answers `429` with `retry_after`.
  - Returns `ad_set`.
- `POST /ad_sets/:id/pause`: pause one ad set. The campaign and its other ad sets run on. *ads*
- `POST /ad_sets/:id/resume`: resume a paused ad set. On a live campaign, it checks the room left again. *ads*
- `POST /ad_sets/:id/end`: end an ad set for good. One that never reached Meta is deleted, with its ads. A campaign keeps at least one. *ads*
  - Each returns `ad_set`, or `204` when it was deleted.
- `PATCH /campaigns/:id/ads/:ad_id`: change an ad's `headline`, `body`, `headlines`, `bodies`, `description`, `call_to_action`, `destination_path`, `campaign_link_id`, `format`, image, video, Stories and Reels file or `cards`. *ads*
  - An ad on a saved link keeps the link's page. To send it elsewhere, pick another saved link. Send `campaign_link_id` `null` to give it its own link again.
  - On Meta, the ad gets a new version, and Meta reviews it again. Use it to fix a rejected ad.
  - `cards` replaces the carousel's cards. Send a card's `id` to keep it, and its image with it.
  - A new video waits for Meta to process it, then goes to Meta. If Meta can't take it, the campaign's `history` says why.
- `DELETE /campaigns/:id/ads/:ad_id`: remove an ad. *ads*
  - An ad on Meta ends there. Its history and results stay. A draft ad is deleted.

### Errors

Each error has `error` (the code), `message` (for the founder) and the fields below.

| Status | `error` | Adds | What to do |
|---|---|---|---|
| 402 | `plan_required` | `held`, `upgrade_url` | Give the founder `upgrade_url`. It launches once they upgrade. On resume, the account is inactive: ask the founder to choose a plan. |
| 403 | | | You lack the `ads` permission. Ask the founder to reconnect you with Run ads. |
| 409 | `account_reconnect_needed` | `ad_account_id` | Ask the founder to reconnect the ad account. |
| 409 | `time_zone_unknown` | `ad_account_id` | Hunchwire doesn't know the ad account's time zone yet, so it sends Meta no dates. Try again after the next hourly read, or ask the founder to reconnect the account. Launching a dated campaign, changing a running campaign's dates, and adding an ad set to a running dated campaign wait for it. |
| 422 | `dates_required` | | Set the campaign's `starts_on` with `PATCH /campaigns/:id`, or ask the founder when it starts. Then launch again. |
| 422 | `over_spend_limit` | `room_left` | Lower the budget or end it sooner, or ask the founder for a higher limit. |
| 422 | `needs_own_domain` | | Ask the founder to connect the project's own domain. Meta ads never lead to the shared pages domain. |
| 422 | `link_not_on_own_domain` | | Pick a saved link on the project's own domain, or leave `campaign_link_id` out. |
| 422 | `rejected_by_meta` | | Fix what Meta's `message` names. |
| 422 | `optimization_goal_locked` | | Leave `optimization_goal` as it is. To optimize for the other goal, create a new campaign. |
| 422 | `ad_set_required` | | Send `ad_set_id`: the campaign has several ad sets. |
| 422 | `video_too_large` | | Send a video of 100 MB or less. |
| 429 | `rate_limited` | `retry_after` | Wait that many seconds, then retry. |

A change the campaign's state doesn't allow, or a missing piece, is `422` with `errors`.

## Chart notes

Mark the day something changed, so you can read the numbers against it.

- `POST /projects/:id/notes`: add a note. *manage*
  ```
  {"note": {"on": "2026-09-21", "text": "Reddit launch ads start"}}
  ```
  - `text` is at most 140 characters.
  - Returns the note: `id`, `on` and `text`.
- `DELETE /projects/:id/notes/:note_id`: remove a note. *manage*

## Insights

Save your conclusions about a project's results on the project. The founder sees new and pinned ones on **Analytics › Overview**, and all of them on **Analytics › Insights**. The next agent, or you next time, starts from them.

An insight is new until the founder has seen it. Overview shows the newest new one, then the pinned ones. With neither, Overview shows no insights at all.

Hunchwire writes no insights. They're yours.

- `GET /projects/:id/analytics/insights`: list every insight on the project. *analytics*
  - Returns `insights`, newest first, dismissed ones included. Each has:
    - `id`, `text` and `created_at`.
    - `by`: the agent connection that wrote it, like "Claude Code · MacBook".
    - `link`: the Analytics view it cites, or `null`.
    - `pinned`: the founder keeps it on Overview.
    - `seen`: the founder has read it. A new one (`false`) shows on Overview until then.
    - `dismissed`: the founder or an agent put it away. It's no longer shown, so don't repeat it.
    - `reply` and `replied_at`: the founder's one answer, or `null`. It's often what they did about it: "Moved the Reddit budget to B on Oct 12".
- `POST /projects/:id/insights`: save an insight. *manage*
  ```
  {"insight": {"text": "Founder story (B) converts at 9.8% vs 5.1% for A, mostly from Reddit. Worth sending the Reddit budget to B.", "link": "/projects/12/analytics/compare?by=version&days=30", "seen": true}}
  ```
  - `text`: one or two sentences, at most 280 characters. Say what you found, its number, and what to try.
  - An insight is about the results, never about one person. A `text` with an email address or a phone number is a 422.
  - `link`: optional. The path of the Analytics view the numbers came from, with its period and filters. It's `/projects/:id`, or `/projects/:id/analytics/compare`, `/pages` or `/visitors`, plus a query. Anything else is a 422.
  - `seen`: send `true` when you agreed on it with the founder in this conversation. They've read it, so it isn't news. Leave it out when they weren't there, such as on a scheduled run. Then it shows on Overview as new.
  - Returns the insight, as above.
  - You can't change its text later. Dismiss it and save a new one.
- `PATCH /projects/:id/insights/:insight_id`: pin, dismiss or reply to an insight, or mark it seen. *manage*
  ```
  {"insight": {"pinned": true}}
  ```
  - `pinned`: `true` or `false`. A project can have at most 2 pinned. Pinning a third is a 422 until one is unpinned.
  - `dismissed`: `true` puts it away and unpins it. `false` restores it.
  - `reply`: the founder's answer, at most 280 characters, with no email address or phone number. `""` clears it. Write a reply only when the founder asks you to.
  - `seen`: `true` once the founder has read it, such as when you told them in a chat. It can't be unset.
  - Pinning, dismissing and replying also mark it seen, as on screen.
  - Returns the insight.

## Internal traffic

Bots, the founder's own visits and builders' checks would swamp a project's numbers. Hunchwire drops them before saving anything. Their visits, engagement and clicks get the usual response and are never recorded.

Sign-ups always go through. Nothing already recorded changes.

- **Bots and automated browsers** are always ignored: crawlers, link previews, scripts like `curl`, headless Chrome, Playwright, Puppeteer and Selenium. Hunchwire spots them by User-Agent, or by `navigator.webdriver` in the browser.
  - Your own headless browser can check the live site without counting. `ignored_bot_count` going up confirms it.
  - Visits from Meta's own network count as bots too. Meta reviews each new ad by opening its link in ordinary-looking browsers. People who tap an ad in Facebook or Instagram still count.
  - Visitors who arrive from ChatGPT or Claude still count. That's their referrer.
- **Ignored IP addresses** belong to the workspace, so they apply to every project. Hunchwire compares visitors' IPs and never stores them.
- **Pages nobody saw** don't count. A page view counts once the page is on screen. An in-app browser that loads an ad's page in the background counts it only when the visitor opens it. Engaged time and scroll start then. These aren't in `ignored_bot_count`.
- **Ignore this browser.** Opening any page of a site with `?hunchwire=ignore` (the `ignore_links`) stops that browser counting on that host. A pill on the page says so and offers "Count it again". `?hunchwire=count` does the same.

- `GET /ignored_networks`: list the workspace's ignored IP addresses. *analytics*
  - Returns `ignored_networks`, each with `id`, `label`, `network` and `created_at`.
  - `current_ip`: your IP, as Hunchwire sees it.
  - `current_ip_ignored`: whether your IP is ignored.
- `POST /ignored_networks`: ignore an address or range. *manage*
  ```
  {"ignored_network": {"network": "203.0.113.7", "label": "Office"}}
  ```
  - `network`: an IPv4 or IPv6 address, or a range like `203.0.113.0/24`.
  - A bare IPv6 address means its whole /64 network, because devices change the rest every day or so.
  - Ranges can't be wider than /8 for IPv4 or /32 for IPv6.
  - To ignore your own IP, send `{"current": true}` instead. An IPv6 address counts as its /64. Use it for the machine your browser checks run from.
  - `label`: optional, at most 50 characters.
  - A workspace can have at most 20 entries. A duplicate, or one over the limit, is a 422.
  - Returns the entry.
- `DELETE /ignored_networks/:id`: count that address again. *manage*
