Guides
Projects
A project is one idea: its site, its forms, and the numbers that show whether people want it.
Use your agent. Paste this into it:
Read hunchwire.com/docs/projects and launch a smoke test for my idea.
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 inpreview.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 atpreview_url(nullotherwise). 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 indomains. 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 withown_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 inhosts. 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
errornames it.
Dimensions:
channel: where the visitor first came from. One ofPaid search,Paid social,Paid other(a paid medium:cpc,ppc,paid,paid_social),Organic search,Organic social,Email,AI assistants,ReferralorDirect.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'sutm_source, else the referrer's host, elsedirect. Tagged visits keep theirutm_source.- A social link's host shows as the platform.
t.co,x.comandtwitter.comareX.redd.itandold.reddit.comareReddit. LinkedIn, Facebook, Instagram, YouTube, Pinterest, TikTok, Threads and Bluesky work the same way. - A
sourcefilter takes either spelling:filter[source][]=t.comatchesX.
- A social link's host shows as the platform.
campaign: their first visit's campaign link, such asreddit · 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>orform:<name>, done in the period.step: a funnel step they reached.1is "Visited site".device:Desktop,MobileorTablet.browser:Chrome,Safari,Firefox,Edge,Opera,Samsung InternetorOther.country: a two-letter code, such asCA.returning:neworreturning. A returning visitor visited on two different days.day: a date, such as2026-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).Unknownfor views from before it did.list: a list a person joined, such aswhatsapp. It comes fromdata-liston the form, orlistin 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.
GET /projects/:id?days=30&filter[channel][]=Paid search&filter[channel][]=Paid social&exclude[landed_on][]=/pricing
GET
/projects?days=30
List every project, with its numbers for the period.
Show 5 details
last_<days>_days(e.g.last_30_days): the period'svisitors,people,confirmedandconversion. It also hasspend: everything spent in the period, typed in or from ads, ornullwithout spend.cost_per_emailis that spend divided bypeople, ornull.webhook_failing_since: when its webhook endpoint started failing, ornull.setup: where its setup stands, from the checklist the founder sees in Build › Go live.donecounts the checks done, out oftotal.nextis the first check not done, ornullwhen all are done. It has:key: one of thechecklistkeys (see Read a project's setup), such asdetails,siteormail_from.title, anddetail: why, in a line.section: where the founder fixes it, as inchecklist.
last_visit_atandlast_signup_at: the newest page view and sign-up, all time, ornull.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 hasid,text,by,link,pinned,seen,replyandcreated_at(see Insights).
curl https://app.hunchwire.com/projects?days=30 \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.get(
"https://app.hunchwire.com/projects?days=30",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/projects?days=30", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
GET
/projects/:id?days=30
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. Withdays=all, it counts from the first visit or sign-up.fromandto: the period's first and last day, as UTC dates.filters: the filters applied, each adimension, anop(isoris_not) and itsvalues. 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.nullwhile 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.nullwhen 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 itsreferralsaspeopleandshare.nullwithdays=all, since there's no period before.daily: one entry per day, oldest first, withdate,visitors,people,conversion,engaged_ms,bounce_rate,returning_rateandreferrals(sign-ups from friends that day).previous_daily: the same for the period before, day by day, to compare the shape.nullwithdays=all.funnel: the steps, each withlabel,visitors,share(of all visitors) anddrop_off(from the step before;nullon the first).- A step whose value ends in
*also hasmatches. That's each page, click or form it matched, asvalue, with thevisitorswho reached that step. A visitor can count under several matches.
- A step whose value ends in
notes: chart notes dated inside the period, oldest first, each withid,on(the day) andtext. 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 byby. Pick it with?by=:channel(default),source,campaign,landed_on,device,countryorversion.rows: the top 5 values by visitors, thenOther, so each step's rows add up to that step.- Each row has
name,visitors,conversion,confirmedandsteps.stepshas one entry per funnel step after "Visited site", each withvisitorsandshare(of the row's visitors). landed_oncredits each visitor to the page of their first visit in the period.
- Breakdown rows all have
name,visitors,people,confirmed(nullwhile the project doesn't confirm),conversion(of the row's visitors) andengaged_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 tovisitors.channels: rows namedPaid search,Paid social,Paid other,Organic search,Organic social,Email,AI assistants,ReferralorDirect. Filters has the rules.sources: rows byutm_source, else the referrer's host, elsedirect.campaigns: one row per campaign link, named by every tag, such asreddit · 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'ssource,medium,campaignandcontent, eachnullwhen not set.tagsisnullfor 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.spendandcost_per_email, in the project's currency.spendis the spend entered for the link on days inside the period.cost_per_emailis that spend divided by the row'speople. Both arenullwithout spend, andcost_per_emailis alsonullwith no people. Filters don't narrow spend.impressionsandclicks: what the link's ad delivered in the period, from the ad platform.clicksare link clicks. Both arenullfor a link no ad uses.ad: the ad whose link it is, ornull. It hasid,name(the ad's headline, quoted),campaign_id,campaign(the campaign's name) andplatform, such asMeta.
pages: the listslanded_on(the first page of a visit),viewed(any page seen) andleft_from(the last page of a visit). Rows are pages, and a visitor can be under several. Each row also hasbounce_rate, of visits that landed on the page (nullwhen unknown). Itsengaged_msis time on that page.key_actions: forms submitted and tracked clicks. For one page, seeGET /projects/:id/analytics/pages. Use it to answer "which page sells Pro?". Each has:kind(formorclick),nameandvisitors.conversion: the share of all visitors who did it.top_channel: where most of them came from.pages: the pages it happened on, each apathwithvisitors.(unknown)means the page wasn't recorded.
devices:device,browserandcountryrows, from each visitor's first page view in the period. Countries are two-letter codes.Unknownwhen 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 allpeoplethey make up (0 to 1).sharers: people who brought at least one of those friends.sharer_share: sharers out ofpeople.median_friendsandtop_friends: friends per sharer, the median and the most.friends_per_referrer: sharers grouped by friends brought, each alabel(1,2or3+) withreferrers.top_referrers: up to 10 sharers, most friends first, each withfriends,place(their place in line now),emailandphone(one isnull).emailandphoneare 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 inpeople,sharersor the line.visitors: visitors who arrived through a friend's link.conversion: the share of them who submitted a form. Compare it with the overallconversion.
- 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.
curl https://app.hunchwire.com/projects/:id?days=30 \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.get(
"https://app.hunchwire.com/projects/:id?days=30",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/projects/:id?days=30", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
GET
/projects/:id/analytics/pages?page=/pricing
Get the Pages view.
Details
- Takes
daysand filters, like the Overview. days,from,toandfilters, as above.pages: thelanded_on,viewedandleft_fromlists, as in the Overview.page: one page's detail when you sendpage, elsenull. It has:pathandvisitors.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 eachdepth(25, 50, 75 and 100 percent), theshareof its viewers who got at least that far. It counts only viewers whose browser reported scroll.reached_bottomis 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, andDid a key action here.- Each
came_fromandwent_nextrow is anamewithvisitors. key_actions: forms and tracked clicks done on this page, each withkind,name,visitorsandshare(of the page's visitors).
curl https://app.hunchwire.com/projects/:id/analytics/pages?page=/pricing \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.get(
"https://app.hunchwire.com/projects/:id/analytics/pages?page=/pricing",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/projects/:id/analytics/pages?page=/pricing", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
GET
/projects/:id/analytics/compare?by=campaign
Compare campaign links, versions or ads side by side (the Compare view).
Show 6 details
- Takes
daysand filters, like the Overview. Every column follows them. by:campaign,versionorad. Without it, it'sversionwhen the project tests versions, elsecampaign.versionon a project that doesn't test versions givescampaign, and so doesadon a project with no campaigns.days,from,toandfilters, 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,conversionandengaged_ms.tags: a campaign link'ssource,medium,campaignandcontent.nullfor versions.low_sample: true under 100 visitors. Don't call such a column better or worse.spendandcost_per_email, for campaign links with spend in the period.nullotherwise, and always for versions.impressions,clicksandclick_rate(clicks out of impressions, 0 to 1), for links an ad uses.nullotherwise.ad: the ad whose link it is, as in the Overview's campaign rows, ornull.daily: one entry per day, oldest first, withdate,visitors,peopleandconversion.
- Hunchwire never picks a winner, and the founder's screen says so. Neither should you on a low sample.
curl https://app.hunchwire.com/projects/:id/analytics/compare?by=campaign \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.get(
"https://app.hunchwire.com/projects/:id/analytics/compare?by=campaign",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/projects/:id/analytics/compare?by=campaign", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
GET
/projects/:id/analytics/visitors?page=1
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),pageandper_page.visitors, each with:id: the browser's id. Use it to get the journey.label: a short name, such asVisitor 7f3a.came_from: their first visit's source and campaign, such asreddit · 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.
curl https://app.hunchwire.com/projects/:id/analytics/visitors?page=1 \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.get(
"https://app.hunchwire.com/projects/:id/analytics/visitors?page=1",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/projects/:id/analytics/visitors?page=1", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
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.
Show 6 details
404if the browser never visited.id,label,first_came,channel,campaign(reddit / cpc / launch;nulluntagged),source,device,browser,country,pages(page views) andengaged_ms(their total).visits: oldest first, each withstarted_at,source(where that visit came from),returning(an earlier visit was on another day) andevents.events: each akind(view,clickorform),name(the page, click or form) andat. Views also haveengaged_msandscroll_depth(percent),nullwhen 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.
curl https://app.hunchwire.com/projects/:id/analytics/visitors/:visitor_id \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.get(
"https://app.hunchwire.com/projects/:id/analytics/visitors/:visitor_id",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/projects/:id/analytics/visitors/:visitor_id", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
GET
/projects/:id/live
See who's on the site now.
Details
count: visitors with a page view in the last 5 minutes.visitors: each withid,label,path(their latest page),seen_at,sourceanddevice.last_signup_at: the newest sign-up, ornull.
curl https://app.hunchwire.com/projects/:id/live \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.get(
"https://app.hunchwire.com/projects/:id/live",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/projects/:id/live", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
POST
/projects
Create a project.
Show 6 details
wayis required:hosted_pagesorown_site_forms. Ask the founder whether they already have a site. If they do, it's usuallyown_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,mailandstatusare 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. domainscan't includehunchwire.siteor anything under it or underhunchwire.com. Those are Hunchwire's own. Hunchwire's own landing page is the one project that listshunchwire.comitself.
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"]}}'
import os, requests
r = requests.post(
"https://app.hunchwire.com/projects",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
json={"project": {"name": "Loop: habit tracker for teams", "slug": "loop", "way": "hosted_pages", "sender": "Loop Labs Inc.", "domains": ["getloop.app"]}},
)
r.json()
const res = await fetch("https://app.hunchwire.com/projects", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"project": {"name": "Loop: habit tracker for teams", "slug": "loop", "way": "hosted_pages", "sender": "Loop Labs Inc.", "domains": ["getloop.app"]}}),
})
const data = await res.json()
PATCH
/projects/:id
Change name, slug, way, sender, mail_from, mailing_address, email_by_hand or domains.
Details
- Changing
wayaway fromhosted_pagestakes the hosted site offline. Its pages are kept. Only do it when the founder asks, and tell them first. Hunchwire's own landing page stayshosted_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).
curl -X PATCH https://app.hunchwire.com/projects/:id \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.patch(
"https://app.hunchwire.com/projects/:id",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/projects/:id", {
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
POST
/projects/:id/go_live
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
402plan_required. The project then goes live by itself once the founder upgrades.
curl -X POST https://app.hunchwire.com/projects/:id/go_live \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.post(
"https://app.hunchwire.com/projects/:id/go_live",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/projects/:id/go_live", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
POST
/feature_requests
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
featureandrequested_at.
curl -X POST https://app.hunchwire.com/feature_requests \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.post(
"https://app.hunchwire.com/feature_requests",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/feature_requests", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
DELETE
/projects/:id?confirm=<slug>
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 returns200withpaused_campaigns.
curl -X DELETE https://app.hunchwire.com/projects/:id?confirm=<slug> \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.delete(
"https://app.hunchwire.com/projects/:id?confirm=<slug>",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/projects/:id?confirm=<slug>", {
method: "DELETE",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
GET
/projects/:id/setup
Get the project's setup.
Show 8 details
- Returns the project, plus
pages(without content), theembed_snippetandspends(each withid,tags,amountandspent_on). checklist: what the project needs before going live, as in the founder's Build › Go live. Each check haskey,title,done(true or false),detail(why, in one line),sectionandaction(the step, as the founder's button names it).sectionis where the founder fixes it: a Build step asbuild/<step>(build/where,build/page,build/after,build/place), or a Setup view (settings,tracking,share),versions,let-in,apiorwebhooks. The checks followway, 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.linksis 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,optionalleaves outemail.
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) anddomain(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) andemail(Email your people: done once the project can email its people, or the founder sends by hand;detailnames the next step).
- For
last_visit_atandlast_signup_at: the newest page view and sign-up, ornull. Use them to answer "is it working?".live_check: the founder's Live check.stateiswaiting(nothing yet),received(the newest event is a sign-up) orrefused(the newest event is a refused site).last_signup: the newest sign-up, ornull. It hasat,via(embedorapi),place(while the line moves, elsenull) andemail(with emails, elsenull).last_refused: the newest site refused because it isn't one of the project's hosts, ornull. It hashostandat.
tracked_clicks: the tracked clicks seen in the last 30 days, most visitors first. Each hasname,page(where it's clicked most),visitorsandlast_click_at.ad_limit: the project's monthly limit for ads, with the fields inlimits(see Ad accounts), ornull.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_linkshas one "ignore this browser" link per host.ignored_bot_countandignored_internal_countare the page views dropped so far, from bots and from the founder's ignored IP addresses.current_ip_ignoredsays whether your own IP is one of them.
curl https://app.hunchwire.com/projects/:id/setup \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.get(
"https://app.hunchwire.com/projects/:id/setup",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/projects/:id/setup", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
GET
/projects/:id/setup/:section
Get the same response. section is connect, tracking, share, payments or settings, the founder's Setup views.
curl https://app.hunchwire.com/projects/:id/setup/:section \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.get(
"https://app.hunchwire.com/projects/:id/setup/:section",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/projects/:id/setup/:section", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
GET
/projects/:id/setup/site/pages
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.
curl https://app.hunchwire.com/projects/:id/setup/site/pages \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.get(
"https://app.hunchwire.com/projects/:id/setup/site/pages",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/projects/:id/setup/site/pages", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
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.
Details
steps: in the founder's order, each withkey,title,doneandsummary(one line, as the founder sees it). The steps followway:hosted_pages:where,page,form,after,referrals,live.own_site_formsandown_site_api:where,form,after,referrals,place,live.
donecounts the steps done, out oftotal.openis the first step not done: Build opens there.preview_url: Build, for the founder to open after you change the page or the form.whereis done for hosted pages, for an own site oncedomainshas one, and for the API once the secret key exists.pageis done once there's a home page at/.referralsis done while they're on.placeis done once a visit or sign-up came from one of the project'sdomains.liveis done while the project is live.
curl https://app.hunchwire.com/projects/:id/build \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.get(
"https://app.hunchwire.com/projects/:id/build",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/projects/:id/build", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.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"}
PATCH
/projects/:id/funnel
Set the funnel.
Details
- Up to 5 steps after "Visited site".
kindispage,clickorform.- A value ending in
*matches a prefix. - Send
[]for the default funnel: visited, then any form.
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"}]}}'
import os, requests
r = requests.patch(
"https://app.hunchwire.com/projects/:id/funnel",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
json={"funnel": {"steps": [{"kind": "page", "value": "/pricing"}, {"kind": "click", "value": "plan-*"}, {"kind": "form", "value": "waitlist"}]}},
)
r.json()
const res = await fetch("https://app.hunchwire.com/projects/:id/funnel", {
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"funnel": {"steps": [{"kind": "page", "value": "/pricing"}, {"kind": "click", "value": "plan-*"}, {"kind": "form", "value": "waitlist"}]}}),
})
const data = await res.json()
POST
/projects
with a name, slug and way. Use hosted_pages to build the test site yourself.
curl -X POST https://app.hunchwire.com/projects \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.post(
"https://app.hunchwire.com/projects",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/projects", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
-
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.
curl -X PATCH https://app.hunchwire.com/projects/:id/funnel \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.patch(
"https://app.hunchwire.com/projects/:id/funnel",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/projects/:id/funnel", {
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
-
Bring visitors. Run a small Meta ad from Hunchwire (see Create a campaign). It needs the
adspermission 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 withPOST /projects/:id/campaign_links, a differentcontentper ad. Give the founder eachurl. Without them, traffic from apps that hide their referrer shows as "direct". -
Later,
GET /projects/:id?days=7and 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.
- Start from what's known.
GET /projectsreturns each project'sinsights: what was concluded before, and the founder'sreply. Pick up from there. - Read the numbers. Compare
GET /projects/:id?days=30withprevious. Then put campaigns side by side withGET /projects/:id/analytics/compare(or versions, when the project tests versions), and use the filters below. - Say it plainly. Two or three lines: what you found, its number, and what you'd try. Then ask what they make of it.
- 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, withlinkto the view it came from, andseen: 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_msand a highbounce_rateon 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_offis 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/blockedafter an ad run to see what was turned away. - Login attempts (the
click:login-attemptkey 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
previousto say whether things are improving, not only where they are. Checknotesbefore you blame or credit a change. - Where each audience drops:
funnel_breakdownwithby=campaignshows which ad's visitors stop at pricing. - Word of mouth:
referrals.shareis how much of the list came from friends. A highsharer_sharemeans most people share when asked. A fewtop_referrerswith 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.
Details
- Returns
campaign_links: active links first, then by when they were saved. Add?archived=trueto include archived links. - Each link has
id,name,tags,destination,url,archived,created_atand its numbers for all time.tags:source,medium,campaignandcontent. A tag that isn't set isnull.destination: the page the link leads to.url: the link to put in the ad. It'sdestinationplus oneutm_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.nullwithout spend.cost_per_email:spenddivided bypeople.nullwithout 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-1isn'tad-1. unsaved: tagged visits whose tags match no saved link, archived links included. Each hastags,visitorsandpeople. A typo in an ad's link shows up here. To track it, save it as its own link.
curl https://app.hunchwire.com/projects/:id/campaign_links \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.get(
"https://app.hunchwire.com/projects/:id/campaign_links",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/projects/:id/campaign_links", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
POST
/projects/:id/campaign_links
Save a link.
Show 6 details
sourceanddestinationare required.destinationmust be on one of the project's hosts. Hunchwire drops its query.nameis 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
contentfor each ad, even when two ads lead to different pages. - Returns the link, with the
urlto give the founder.
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"}}'
import os, requests
r = requests.post(
"https://app.hunchwire.com/projects/:id/campaign_links",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
json={"campaign_link": {"name": "Reddit · ad 1", "source": "reddit", "medium": "paid", "campaign": "t1", "content": "ad-1", "destination": "https://hunchwire.com/pricing"}},
)
r.json()
const res = await fetch("https://app.hunchwire.com/projects/:id/campaign_links", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"campaign_link": {"name": "Reddit · ad 1", "source": "reddit", "medium": "paid", "campaign": "t1", "content": "ad-1", "destination": "https://hunchwire.com/pricing"}}),
})
const data = await res.json()
PATCH
/projects/:id/campaign_links/:campaign_link_id
Change name or destination, or archive the link.
Details
- Send
archived: trueto archive it.falserestores 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.
curl -X PATCH https://app.hunchwire.com/projects/:id/campaign_links/:campaign_link_id \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.patch(
"https://app.hunchwire.com/projects/:id/campaign_links/:campaign_link_id",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/projects/:id/campaign_links/:campaign_link_id", {
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
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.
Details
- The tags must match the link's UTM tags exactly.
sourceis required. - For a saved link, send
"campaign_link_id": <id>instead of the tags. amountis 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
422with the reason.
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"}}'
import os, requests
r = requests.post(
"https://app.hunchwire.com/projects/:id/spends",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
json={"spend": {"source": "reddit", "medium": "paid", "campaign": "t1", "content": "juicer", "amount": 42.50, "spent_on": "2026-09-28"}},
)
r.json()
const res = await fetch("https://app.hunchwire.com/projects/:id/spends", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"spend": {"source": "reddit", "medium": "paid", "campaign": "t1", "content": "juicer", "amount": 42.50, "spent_on": "2026-09-28"}}),
})
const data = await res.json()
PATCH
/projects/:id/spends/:spend_id
Change an entry's tags, amount or spent_on.
curl -X PATCH https://app.hunchwire.com/projects/:id/spends/:spend_id \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.patch(
"https://app.hunchwire.com/projects/:id/spends/:spend_id",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/projects/:id/spends/:spend_id", {
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
DELETE
/projects/:id/spends/:spend_id
Remove an entry.
curl -X DELETE https://app.hunchwire.com/projects/:id/spends/:spend_id \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.delete(
"https://app.hunchwire.com/projects/:id/spends/:spend_id",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/projects/:id/spends/:spend_id", {
method: "DELETE",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
-
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.
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, likeAmerica/Toronto. Campaign dates are days in it. It'snullwhile 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_idandpage_name: the Facebook Page its ads run as.state:connectedorreconnect_needed.reconnect_neededmeans Meta stopped accepting the token. Ask the founder to reconnect it.reconnect_needed_at: when Meta stopped accepting it, ornull.last_synced_at: when Hunchwire last read the account's results, ornull.projects: the projects its campaigns lead to, each withidandname.campaigns_imported: how many of its campaigns were made in Meta and imported.spent_this_month: what its ads spent this calendar month, in itscurrency.limits: the projects that run ads on it, each with its monthly limit (see Campaigns). Each hasproject_id,ad_account_id,currency,monthly_limit,spent_this_month,room_leftandresets_on(the day the limit starts again).warningistruefrom 80% of the limit.paused_at_limitistruewhile the limit has the project's ads paused this month.room_leftis 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.
curl https://app.hunchwire.com/ad_accounts \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.get(
"https://app.hunchwire.com/ad_accounts",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/ad_accounts", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.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}]}
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
qandkind:country,region,languageorinterest. Withoutkind, it returns countries, regions, then interests. - Returns
targets. Each haskind,id,name,typeandcountry. - An interest is anything Ads Manager's Detailed targeting finds: interests, behaviors, job titles, industries and life events.
typenames which, likeinterest,behaviororwork_position. It'snullfor a country or a language. - A country's
idis its two-letter code. A language'sidis Meta's:1001is English (All). - A region is a state or province. Its
idis Meta's, andcountryis its country's two-letter code.countryisnullfor anything else. - Put an interest's
id,nameandtypein an ad set'sinterestsornarrow_interests, a language'sidandnamein itslanguages, a country'sidin itscountries, and a region'sid,nameandcountryin itsexcluded_regions.
curl https://app.hunchwire.com/ad_accounts/:id/targeting?q=trainer \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.get(
"https://app.hunchwire.com/ad_accounts/:id/targeting?q=trainer",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/ad_accounts/:id/targeting?q=trainer", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
{"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:
- Open Ad accounts in the account menu, then Connect Meta.
- In Meta's Business Settings › Users › System users, add a system user named Hunchwire.
- Assign it the ad account and the Facebook Page, with full control.
- Generate a token with
ads_managementandads_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_idandplatform(meta).state: where it is, in the table below.paused_bysays who paused it, in the second table.paused_at: when it was last paused, ornull.imported:truefor a campaign made in Meta and not yet edited here.optimization_goal: what Meta optimizes its ad sets for, in the table below.nullon 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.nullwith lifetime budgets. You set budgets on each ad set.starts_onandends_on: its dates, ornull. Every ad set runs on them.time_zone: its ad account's time zone, ornullwhile Hunchwire doesn't know it. Start and end times are in this zone.starts_atandends_at: when it starts and stops, with the zone's offset, like2026-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 arenullwithout the date or whiletime_zoneisnull.starts_at_utcandends_at_utc: the same moments in UTC, like2026-10-21T23:00:00.000Z.- Check
time_zone,starts_atandends_atbefore 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, ornull. Meta stops it there even if Hunchwire's hourly read is late.last_error: Meta's reason for a failed launch, elsenull.launched_at: when it first went live, ornull.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_idandname.state:draftuntil the campaign launches, thenactive,pausedorended.paused_byisperson,agentormeta, elsenull.daily_budget: what it may spend a day, ornullwith a lifetime budget.audience: who sees it, in words, likeUS, CA except Quebec · ages 25–54 · Advantage+ · mobile only · English · 6 placements · interests: Startups · and also: Software as a service.nullwhen Hunchwire can't read it.countries,age_minandage_max: where they live and how old they are.excluded_regions: states or provinces left out of its countries, each withid,nameandcountry. Empty when none are.advantage_audience:truewhen Meta may reach past its oldest age and interests (Advantage+ audience).falsekeeps Meta strictly to them.interests: people who match any of these. Each hasid,nameandtype.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 withidandname. Empty for all languages.placements: where its ads show, from the table below. Empty for Meta's automatic placements.devices:both,mobileordesktop. 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_actionanddestination_path(the page it leads to).headlinesandbodies: every headline and text, the lead first.headlineandbodyare the lead.format:image,videoorcarousel.description: the line under the headline, ornull.state:active,pausedorended.review: Meta's review,pending,approvedorrejected.rejection_reasonis Meta's words for a rejected ad, elsenull. Tell the founder what to change in Meta.campaign_link_idandtags: the campaign link it counts through. See Campaign links.image:trueonce it has an image.video:trueonce it has a video.story_imageandstory_video:trueonce it has a Stories and Reels image or video.cards: a carousel's cards, in order. Each hasid,position,headline,description,destination_pathandimage. A card'snullheadline, description or page shows the ad's.
GET
/projects/:id/campaigns?days=30
List the project's campaigns with what each brought in the period.
Show 7 details
- Takes
days: 7, 14, 30 orall. 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,fromandto: the period.campaigns: live ones first, then by spend. Each is a campaign with its numbers for the period:spend,impressionsandclicks: from the ad platform.clicksare link clicks.click_rateis clicks out of impressions, ornull.visitorsandpeople: who its ads' links brought. A link two ads share counts once.cost_per_email:spenddivided bypeople, ornullwithout both.ad_sets: each ad set, with the same numbers for its own ads.ads: each ad, with the same numbers.sharedistruewhen 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 hasname,platform(from itssource),tags,spend,visitors,peopleandcost_per_email.totals: everything ads brought.visitor_shareis the share of all the project's visitors (0 to 1).conversionispeopleout ofvisitors.platformsgives each platform'sspend,peopleandcost_per_email.ad_accounts: the workspace's accounts, withid,name,state,reconnect_needed_atandlast_synced_at. Numbers stop updating while one needs a reconnect.
curl https://app.hunchwire.com/projects/:id/campaigns?days=30 \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.get(
"https://app.hunchwire.com/projects/:id/campaigns?days=30",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/projects/:id/campaigns?days=30", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.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"}]}
GET
/campaigns/:id?days=30
Get one campaign, its ads and its history.
Details
- Returns
days,from,toandcampaign: the campaign, its numbers andads, as above. history: what happened to it, newest first. Each entry haskind,description(who did what, with the amount),ad_idandad_set_id(what it concerned, ornull) andat.kindisimported,changed_in_meta,drafted,launched,held,failed,paused,resumed,ended,budgetoredited.
curl https://app.hunchwire.com/campaigns/:id?days=30 \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.get(
"https://app.hunchwire.com/campaigns/:id?days=30",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/campaigns/:id?days=30", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
GET
/ad_accounts
to see each project's room_left this month. With no limit for the project, ask the founder to set one.
curl https://app.hunchwire.com/ad_accounts \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.get(
"https://app.hunchwire.com/ad_accounts",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/ad_accounts", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
-
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
nameand at least one ad set are required.ad_account_idis the project's limit account by default.starts_onandends_onare whole days in the ad account'stime_zone. Both are optional on a draft. Leave them out to let the founder pick them at launch.- A launch needs
starts_on. Leaveends_onout to run until you end it. - To start or stop at an exact time, send
starts_atandends_atinstead. A local time, like2026-10-21T19:00, is 7:00 PM on the ad account's clock. It works whiletime_zoneisnull. starts_atandends_atalso take a time with an offset, like2026-10-21T23:00:00Z. Hunchwire keeps it as the ad account's clock time. It's refused whiletime_zoneisnull.- A date alone, like
2026-10-21, is the whole day. starts_atwins overstarts_on, andends_atoverends_on. Sendends_atasnullto run until you end it.- The end must be after the start. Hunchwire keeps minutes and drops seconds.
ad_sets: each needsdaily_budget(in the ad account's currency, above 0) and at least one country.namedefaults to the campaign's.age_minandage_maxare 18 to 65. Leaveinterestsout to let Meta find people.narrow_interests,languagesandplacementsare optional. Atypeleft out isinterest. Narrowing with nointereststargets the narrow ones alone.excluded_regionsis optional. Each region must be in one of the ad set'scountries. Find them withkind=region.advantage_audienceisfalseby default. Withtrue, Meta treatsage_maxand the interests as suggestions it may go beyond. Countries, regions, languages andage_minstay limits, andage_minmust be 25 or under.devicesisbothby default. Sendmobileordesktopto show its ads on those devices only.ads: each ad set's ads, with the fields ofPOST /campaigns/:id/ads.optimization_goalislanding_page_viewsby default. Sendlink_clicksfor 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
201withcampaign, itsadsincluded.
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"}]}]}}'
import os, requests
r = requests.post(
"https://app.hunchwire.com/projects/:id/campaigns",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
json={"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"}]}]}},
)
r.json()
const res = await fetch("https://app.hunchwire.com/projects/:id/campaigns", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"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"}]}]}}),
})
const data = await res.json()
POST
/campaigns/:id/ads
Add an ad. ads
Show 9 details
ad_set_idis the ad set it runs in. Leave it out when the campaign has one ad set. With several, leaving it out is a 422ad_set_required.headlineis up to 100 characters,bodyup to 1,000.- To give Meta up to 5 headlines and 5 texts to rotate, send
headlinesandbodiesas lists instead. The first of each leads. Meta shows each person the mix it expects to work best, and never writes its own. headlinesandbodieswork onimageandvideoads. A carousel takes one of each: its cards carry their own.descriptionis the line under the headline, up to 100 characters. It's optional.call_to_actionis the button:SIGN_UP(the default),LEARN_MORE,GET_OFFERorSUBSCRIBE.destination_pathis the page it leads to, on the project's own domain.formatisimage(the default),videoorcarousel. The table below says what each one needs.image_pathis one of the project's uploaded images. Or upload a PNG or JPEG up to 8 MB as a multipartad[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_pathor a multipartad[story_image]. A video ad takes a multipartad[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 multipartimage), and optionally its ownheadline,descriptionanddestination_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:
sourcefacebook,mediumpaid,campaignthe campaign's slug,contentthe ad's. Its visitors and sign-ups are counted apart. campaign_link_iduses one of the project's saved links instead. The ad takes the link's tags and its page, so leavedestination_pathout. 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
422link_not_on_own_domain. - On a live campaign, the ad goes to Meta at once, for review. Returns
201withad.
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"}}'
import os, requests
r = requests.post(
"https://app.hunchwire.com/campaigns/:id/ads",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
json={"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"}},
)
r.json()
const res = await fetch("https://app.hunchwire.com/campaigns/:id/ads", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"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"}}),
})
const data = await res.json()
{"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"}}
{"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_capis the room left. - Returns
202withcampaigninlaunching. Read it again withGET /campaigns/:id. A refused launch ends infailed, withlast_error. - Meta reviews new ads before they run, usually within a day.
reviewispendinguntil then. - It's a release gate. On a plan without ads, Hunchwire answers
402withheld: true. The campaign isheldand launches once the founder upgrades (see Plans and gates).
curl -X POST https://app.hunchwire.com/campaigns/:id/launch \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.post(
"https://app.hunchwire.com/campaigns/:id/launch",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/campaigns/:id/launch", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
POST
/campaigns/:id/pause
Pause a live campaign. ads
curl -X POST https://app.hunchwire.com/campaigns/:id/pause \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.post(
"https://app.hunchwire.com/campaigns/:id/pause",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/campaigns/:id/pause", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
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
402plan_required.
curl -X POST https://app.hunchwire.com/campaigns/:id/resume \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.post(
"https://app.hunchwire.com/campaigns/:id/resume",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/campaigns/:id/resume", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
POST
/campaigns/:id/end
End it for good. Ending a held campaign cancels its launch. ads
curl -X POST https://app.hunchwire.com/campaigns/:id/end \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.post(
"https://app.hunchwire.com/campaigns/:id/end",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/campaigns/:id/end", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
-
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/:idto 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_bylimit) 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_bybilling). 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_byguard). The call returns them aspaused_campaigns, each withid,name,stateandpaused_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 as422over_spend_limit.
curl -X POST https://app.hunchwire.com/projects/:id/campaigns/resume \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.post(
"https://app.hunchwire.com/projects/:id/campaigns/resume",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/projects/:id/campaigns/resume", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
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_onorends_onsent 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_setschanges each ad set named byid, asPATCH /ad_sets/:iddoes. An ad set withoutidis added, asPOST /campaigns/:id/ad_setsdoes.- Editing an imported campaign makes it Hunchwire's. Its ads get Hunchwire's tags on Meta, and Meta reviews them again.
optimization_goalchanges only before launch. Once its ad sets are on Meta, a change is a 422optimization_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.
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}]}}'
import os, requests
r = requests.patch(
"https://app.hunchwire.com/campaigns/:id",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
json={"campaign": {"starts_at": "2026-10-21T19:00", "ends_at": "2026-10-25T19:00", "ad_sets": [{"id": 12, "daily_budget": 8}]}},
)
r.json()
const res = await fetch("https://app.hunchwire.com/campaigns/:id", {
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"campaign": {"starts_at": "2026-10-21T19:00", "ends_at": "2026-10-25T19:00", "ad_sets": [{"id": 12, "daily_budget": 8}]}}),
})
const data = await res.json()
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
201withad_setand itsads.
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"}]}}'
import os, requests
r = requests.post(
"https://app.hunchwire.com/campaigns/:id/ad_sets",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
json={"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"}]}},
)
r.json()
const res = await fetch("https://app.hunchwire.com/campaigns/:id/ad_sets", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"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"}]}}),
})
const data = await res.json()
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
429withretry_after. - Returns
ad_set.
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}}'
import os, requests
r = requests.patch(
"https://app.hunchwire.com/ad_sets/:id",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
json={"ad_set": {"daily_budget": 8}},
)
r.json()
const res = await fetch("https://app.hunchwire.com/ad_sets/:id", {
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"ad_set": {"daily_budget": 8}}),
})
const data = await res.json()
POST
/ad_sets/:id/pause
Pause one ad set. The campaign and its other ad sets run on. ads
curl -X POST https://app.hunchwire.com/ad_sets/:id/pause \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.post(
"https://app.hunchwire.com/ad_sets/:id/pause",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/ad_sets/:id/pause", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
POST
/ad_sets/:id/resume
Resume a paused ad set. On a live campaign, it checks the room left again. ads
curl -X POST https://app.hunchwire.com/ad_sets/:id/resume \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.post(
"https://app.hunchwire.com/ad_sets/:id/resume",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/ad_sets/:id/resume", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
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, or204when it was deleted.
curl -X POST https://app.hunchwire.com/ad_sets/:id/end \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.post(
"https://app.hunchwire.com/ad_sets/:id/end",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/ad_sets/:id/end", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
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_idnullto 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.
cardsreplaces the carousel's cards. Send a card'sidto 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
historysays why.
curl -X PATCH https://app.hunchwire.com/campaigns/:id/ads/:ad_id \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.patch(
"https://app.hunchwire.com/campaigns/:id/ads/:ad_id",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/campaigns/:id/ads/:ad_id", {
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
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.
curl -X DELETE https://app.hunchwire.com/campaigns/:id/ads/:ad_id \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.delete(
"https://app.hunchwire.com/campaigns/:id/ads/:ad_id",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/campaigns/:id/ads/:ad_id", {
method: "DELETE",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
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.
Details
textis at most 140 characters.- Returns the note:
id,onandtext.
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"}}'
import os, requests
r = requests.post(
"https://app.hunchwire.com/projects/:id/notes",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
json={"note": {"on": "2026-09-21", "text": "Reddit launch ads start"}},
)
r.json()
const res = await fetch("https://app.hunchwire.com/projects/:id/notes", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"note": {"on": "2026-09-21", "text": "Reddit launch ads start"}}),
})
const data = await res.json()
DELETE
/projects/:id/notes/:note_id
Remove a note.
curl -X DELETE https://app.hunchwire.com/projects/:id/notes/:note_id \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.delete(
"https://app.hunchwire.com/projects/:id/notes/:note_id",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/projects/:id/notes/:note_id", {
method: "DELETE",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
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.
Details
- Returns
insights, newest first, dismissed ones included. Each has:id,textandcreated_at.by: the agent connection that wrote it, like "Claude Code · MacBook".link: the Analytics view it cites, ornull.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.replyandreplied_at: the founder's one answer, ornull. It's often what they did about it: "Moved the Reddit budget to B on Oct 12".
curl https://app.hunchwire.com/projects/:id/analytics/insights \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.get(
"https://app.hunchwire.com/projects/:id/analytics/insights",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/projects/:id/analytics/insights", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
POST
/projects/:id/insights
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
textwith 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,/pagesor/visitors, plus a query. Anything else is a 422.seen: sendtruewhen 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.
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}}'
import os, requests
r = requests.post(
"https://app.hunchwire.com/projects/:id/insights",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
json={"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}},
)
r.json()
const res = await fetch("https://app.hunchwire.com/projects/:id/insights", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"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}}),
})
const data = await res.json()
PATCH
/projects/:id/insights/:insight_id
Pin, dismiss or reply to an insight, or mark it seen.
Show 6 details
pinned:trueorfalse. A project can have at most 2 pinned. Pinning a third is a 422 until one is unpinned.dismissed:trueputs it away and unpins it.falserestores 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:trueonce 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.
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}}'
import os, requests
r = requests.patch(
"https://app.hunchwire.com/projects/:id/insights/:insight_id",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
json={"insight": {"pinned": True}},
)
r.json()
const res = await fetch("https://app.hunchwire.com/projects/:id/insights/:insight_id", {
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"insight": {"pinned": true}}),
})
const data = await res.json()
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 bynavigator.webdriverin the browser.- Your own headless browser can check the live site without counting.
ignored_bot_countgoing 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.
- Your own headless browser can check the live site without counting.
-
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(theignore_links) stops that browser counting on that host. A pill on the page says so and offers "Count it again".?hunchwire=countdoes the same.
GET
/ignored_networks
List the workspace's ignored IP addresses.
Details
- Returns
ignored_networks, each withid,label,networkandcreated_at. current_ip: your IP, as Hunchwire sees it.current_ip_ignored: whether your IP is ignored.
curl https://app.hunchwire.com/ignored_networks \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.get(
"https://app.hunchwire.com/ignored_networks",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/ignored_networks", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
POST
/ignored_networks
Ignore an address or range.
Show 7 details
network: an IPv4 or IPv6 address, or a range like203.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.
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"}}'
import os, requests
r = requests.post(
"https://app.hunchwire.com/ignored_networks",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
json={"ignored_network": {"network": "203.0.113.7", "label": "Office"}},
)
r.json()
const res = await fetch("https://app.hunchwire.com/ignored_networks", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"ignored_network": {"network": "203.0.113.7", "label": "Office"}}),
})
const data = await res.json()
DELETE
/ignored_networks/:id
Count that address again.
curl -X DELETE https://app.hunchwire.com/ignored_networks/:id \
-H "Authorization: Bearer $HUNCHWIRE_TOKEN"
import os, requests
r = requests.delete(
"https://app.hunchwire.com/ignored_networks/:id",
headers={"Authorization": f"Bearer {os.environ['HUNCHWIRE_TOKEN']}"},
)
r.json()
const res = await fetch("https://app.hunchwire.com/ignored_networks/:id", {
method: "DELETE",
headers: {
Authorization: `Bearer ${process.env.HUNCHWIRE_TOKEN}`,
},
})
const data = await res.json()
Made from skill.md, the file agents read. Add .md to any page's address for its Markdown.