HunchwireDocs hunchwire.com Sign in

Guides

Projects

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

View as Markdown

Use your agent. Paste this into it:

Read hunchwire.com/docs/projects and launch a smoke test for my idea.
On this page Projects Launching a smoke test Analyze with the founder Campaign links Spend Ad accounts Campaigns Chart notes Insights Internal traffic

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:

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

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

GET /projects?days=30 Needs analytics

List every project, with its numbers for the period.

Show 5 details
  • 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
curl https://app.hunchwire.com/projects?days=30 \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"

GET /projects/:id?days=30 Needs analytics

Get one project, with full analytics.

Show 24 details
  • 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
curl https://app.hunchwire.com/projects/:id?days=30 \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"

GET /projects/:id/analytics/pages?page=/pricing Needs analytics

Get the Pages view.

Details
  • 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/pages
curl https://app.hunchwire.com/projects/:id/analytics/pages?page=/pricing \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"

GET /projects/:id/analytics/compare?by=campaign Needs analytics

Compare campaign links, versions or ads side by side (the Compare view).

Show 6 details
  • 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/compare
curl https://app.hunchwire.com/projects/:id/analytics/compare?by=campaign \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"

GET /projects/:id/analytics/visitors?page=1 Needs analytics

List the visitors in the period, under the filters.

Details
  • 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
curl https://app.hunchwire.com/projects/:id/analytics/visitors?page=1 \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"

GET /projects/:id/analytics/visitors/:visitor_id Needs analytics

Get one visitor's journey over all time: every visit, and every page, click and form in each.

Show 6 details
  • 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/analytics/visitors/:visitor_id
curl https://app.hunchwire.com/projects/:id/analytics/visitors/:visitor_id \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"

GET /projects/:id/live Needs analytics

See who's on the site now.

Details
  • 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.
GET /projects/:id/live
curl https://app.hunchwire.com/projects/:id/live \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"

POST /projects Needs manage

Create a project.

Show 6 details
  • 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.
POST /projects
curl -X POST https://app.hunchwire.com/projects \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"project": {"name": "Loop: habit tracker for teams", "slug": "loop", "way": "hosted_pages", "sender": "Loop Labs Inc.", "domains": ["getloop.app"]}}'

PATCH /projects/:id Needs manage

Change name, slug, way, sender, mail_from, mailing_address, email_by_hand or domains.

Details
  • 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).
PATCH /projects/:id
curl -X PATCH https://app.hunchwire.com/projects/:id \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"

POST /projects/:id/go_live Needs manage

Put a project in preview live.

Details
  • 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 /projects/:id/go_live
curl -X POST https://app.hunchwire.com/projects/:id/go_live \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"

POST /feature_requests Needs manage

with feature payments or reddit_ads: ask to hear when payments or Reddit ads ship.

Details
  • 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.
POST /feature_requests
curl -X POST https://app.hunchwire.com/feature_requests \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"

DELETE /projects/:id?confirm=<slug> Needs manage

Delete a project, with its pages, analytics and people.

Details
  • 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.
DELETE /projects/:id
curl -X DELETE https://app.hunchwire.com/projects/:id?confirm=<slug> \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"

GET /projects/:id/setup Needs analytics

Get the project's setup.

Show 8 details
  • 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
curl https://app.hunchwire.com/projects/:id/setup \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"

GET /projects/:id/setup/:section Needs analytics

Get the same response. section is connect, tracking, share, payments or settings, the founder's Setup views.

GET /projects/:id/setup/:section
curl https://app.hunchwire.com/projects/:id/setup/:section \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"

GET /projects/:id/setup/site/pages Needs analytics

Get the same response. The founder's pages and files are in Build › Page.

Details
  • To put the form on a site Hunchwire doesn't host, see The embed.
GET /projects/:id/setup/site/pages
curl https://app.hunchwire.com/projects/:id/setup/site/pages \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"

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 Needs analytics

Get the steps and the link to send the founder.

Details
  • 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.
GET /projects/:id/build
curl https://app.hunchwire.com/projects/:id/build \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"
Response
{"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"}

PATCH /projects/:id/funnel Needs manage

Set the funnel.

Details
  • 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.
PATCH /projects/:id/funnel
curl -X PATCH https://app.hunchwire.com/projects/:id/funnel \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"funnel": {"steps": [{"kind": "page", "value": "/pricing"}, {"kind": "click", "value": "plan-*"}, {"kind": "form", "value": "waitlist"}]}}'

POST /projects

with a name, slug and way. Use hosted_pages to build the test site yourself.

POST /projects
curl -X POST https://app.hunchwire.com/projects \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"
  • Add pages: home, pricing (with tracked plan buttons), sign-up (a waitlist) and log-in (the fake login).

PATCH /projects/:id/funnel

with the path you expect: page → click → form.

PATCH /projects/:id/funnel
curl -X PATCH https://app.hunchwire.com/projects/:id/funnel \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"
  • 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".

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

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.

List the saved links.

Details
  • 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.
GET /projects/:id/campaign_links
curl https://app.hunchwire.com/projects/:id/campaign_links \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"

POST /projects/:id/campaign_links Needs manage

Save a link.

Show 6 details
  • 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.
POST /projects/:id/campaign_links
curl -X POST https://app.hunchwire.com/projects/:id/campaign_links \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"campaign_link": {"name": "Reddit · ad 1", "source": "reddit", "medium": "paid", "campaign": "t1", "content": "ad-1", "destination": "https://hunchwire.com/pricing"}}'

Change name or destination, or archive the link.

Details
  • 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.
PATCH /projects/:id/campaign_links/:campaign_link_id
curl -X PATCH https://app.hunchwire.com/projects/:id/campaign_links/:campaign_link_id \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"

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 Needs manage

Add an entry.

Details
  • 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.
POST /projects/:id/spends
curl -X POST https://app.hunchwire.com/projects/:id/spends \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"spend": {"source": "reddit", "medium": "paid", "campaign": "t1", "content": "juicer", "amount": 42.50, "spent_on": "2026-09-28"}}'

PATCH /projects/:id/spends/:spend_id Needs manage

Change an entry's tags, amount or spent_on.

PATCH /projects/:id/spends/:spend_id
curl -X PATCH https://app.hunchwire.com/projects/:id/spends/:spend_id \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"

DELETE /projects/:id/spends/:spend_id Needs manage

Remove an entry.

DELETE /projects/:id/spends/:spend_id
curl -X DELETE https://app.hunchwire.com/projects/:id/spends/:spend_id \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"
  • 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 Needs analytics

List the connected ad accounts.

Show 19 details
  • 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
curl https://app.hunchwire.com/ad_accounts \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"
Response
{"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}]}

GET /ad_accounts/:id/targeting?q=trainer

Search Meta for countries, regions, languages and interests to aim an ad set at. ads

Show 6 details
  • 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.
GET /ad_accounts/:id/targeting
curl https://app.hunchwire.com/ad_accounts/:id/targeting?q=trainer \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"
Response
{"targets": [{"kind": "interest", "id": "108240792538224", "name": "Personal trainer", "type": "work_position"}, {"kind": "interest", "id": "6003139266461", "name": "Entrepreneurship", "type": "interest"}]}

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.

GET /projects/:id/campaigns?days=30 Needs analytics

List the project's campaigns with what each brought in the period.

Show 7 details
  • 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 /projects/:id/campaigns
curl https://app.hunchwire.com/projects/:id/campaigns?days=30 \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"
Response
{"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"}]}

GET /campaigns/:id?days=30 Needs analytics

Get one campaign, its ads and its history.

Details
  • 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.
GET /campaigns/:id
curl https://app.hunchwire.com/campaigns/:id?days=30 \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"

GET /ad_accounts

to see each project's room_left this month. With no limit for the project, ask the founder to set one.

GET /ad_accounts
curl https://app.hunchwire.com/ad_accounts \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"
  • Find interests and countries with GET /ad_accounts/:id/targeting.

  • Upload images as project images (see Pages).

  • Create the draft with its ad sets and their ads.

  • Launch it. Tell the founder what you launched and what it may spend.

POST /projects/:id/campaigns

Create a draft. ads

Show 19 details
  • 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 /projects/:id/campaigns
curl -X POST https://app.hunchwire.com/projects/:id/campaigns \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"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"}]}]}}'

POST /campaigns/:id/ads

Add an ad. ads

Show 9 details
  • 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
Details
  • 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:

A carousel, with the same headline and page on every card:

Show 6 details
  • 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.
POST /campaigns/:id/ads
curl -X POST https://app.hunchwire.com/campaigns/:id/ads \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"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"}}'
Create a campaign
{"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"}}
Create a campaign
{"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"}]}}

POST /campaigns/:id/launch

Launch a draft, or retry a failed launch. ads

Show 6 details
  • 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/launch
curl -X POST https://app.hunchwire.com/campaigns/:id/launch \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"

POST /campaigns/:id/pause

Pause a live campaign. ads

POST /campaigns/:id/pause
curl -X POST https://app.hunchwire.com/campaigns/:id/pause \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"

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

Details
  • While the account is inactive, it answers 402 plan_required.
POST /campaigns/:id/resume
curl -X POST https://app.hunchwire.com/campaigns/:id/resume \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"

POST /campaigns/:id/end

End it for good. Ending a held campaign cancels its launch. ads

POST /campaigns/:id/end
curl -X POST https://app.hunchwire.com/campaigns/:id/end \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"
  • 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

Details
  • 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.
POST /projects/:id/campaigns/resume
curl -X POST https://app.hunchwire.com/projects/:id/campaigns/resume \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"

PATCH /campaigns/:id

Change a campaign's name, dates or times, and its ad sets. ads

Show 6 details
  • 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.
PATCH /campaigns/:id
curl -X PATCH https://app.hunchwire.com/campaigns/:id \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"campaign": {"starts_at": "2026-10-21T19:00", "ends_at": "2026-10-25T19:00", "ad_sets": [{"id": 12, "daily_budget": 8}]}}'

POST /campaigns/:id/ad_sets

Add an ad set, with its ads. ads

Details
  • 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.
POST /campaigns/:id/ad_sets
curl -X POST https://app.hunchwire.com/campaigns/:id/ad_sets \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"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"}]}}'

PATCH /ad_sets/:id

Change an ad set's name, daily_budget or audience. ads

Details
  • 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.
PATCH /ad_sets/:id
curl -X PATCH https://app.hunchwire.com/ad_sets/:id \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"ad_set": {"daily_budget": 8}}'

POST /ad_sets/:id/pause

Pause one ad set. The campaign and its other ad sets run on. ads

POST /ad_sets/:id/pause
curl -X POST https://app.hunchwire.com/ad_sets/:id/pause \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"

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/resume
curl -X POST https://app.hunchwire.com/ad_sets/:id/resume \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"

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

Details
  • Each returns ad_set, or 204 when it was deleted.
POST /ad_sets/:id/end
curl -X POST https://app.hunchwire.com/ad_sets/:id/end \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"

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

Details
  • 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.
PATCH /campaigns/:id/ads/:ad_id
curl -X PATCH https://app.hunchwire.com/campaigns/:id/ads/:ad_id \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"

DELETE /campaigns/:id/ads/:ad_id

Remove an ad. ads

Details
  • An ad on Meta ends there. Its history and results stay. A draft ad is deleted.
DELETE /campaigns/:id/ads/:ad_id
curl -X DELETE https://app.hunchwire.com/campaigns/:id/ads/:ad_id \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"

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 Needs manage

Add a note.

Details
  • text is at most 140 characters.
  • Returns the note: id, on and text.
POST /projects/:id/notes
curl -X POST https://app.hunchwire.com/projects/:id/notes \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"note": {"on": "2026-09-21", "text": "Reddit launch ads start"}}'

DELETE /projects/:id/notes/:note_id Needs manage

Remove a note.

DELETE /projects/:id/notes/:note_id
curl -X DELETE https://app.hunchwire.com/projects/:id/notes/:note_id \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"

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 Needs analytics

List every insight on the project.

Details
  • 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".
GET /projects/:id/analytics/insights
curl https://app.hunchwire.com/projects/:id/analytics/insights \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"

POST /projects/:id/insights Needs manage

Save an insight.

Show 6 details
  • 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.
POST /projects/:id/insights
curl -X POST https://app.hunchwire.com/projects/:id/insights \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"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}}'

PATCH /projects/:id/insights/:insight_id Needs manage

Pin, dismiss or reply to an insight, or mark it seen.

Show 6 details
  • 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.
PATCH /projects/:id/insights/:insight_id
curl -X PATCH https://app.hunchwire.com/projects/:id/insights/:insight_id \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"insight": {"pinned": true}}'

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 Needs analytics

List the workspace's ignored IP addresses.

Details
  • 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.
GET /ignored_networks
curl https://app.hunchwire.com/ignored_networks \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"

POST /ignored_networks Needs manage

Ignore an address or range.

Show 7 details
  • 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.
POST /ignored_networks
curl -X POST https://app.hunchwire.com/ignored_networks \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"ignored_network": {"network": "203.0.113.7", "label": "Office"}}'

DELETE /ignored_networks/:id Needs manage

Count that address again.

DELETE /ignored_networks/:id
curl -X DELETE https://app.hunchwire.com/ignored_networks/:id \
  -H "Authorization: Bearer $HUNCHWIRE_TOKEN"

Made from skill.md, the file agents read. Add .md to any page's address for its Markdown.