# Pages

Hunchwire hosts the site and your agent writes its pages. You can add your own domain.

## Pages

A page has `id`, `path`, `version`, `media_type`, `updated_at` and `content`.
- `version`: the key of the page's version. Only an HTML page of a project testing versions has one.
- `content`: only when you read one page with `GET`. Adding or changing a page returns it without `content`, since you just sent it. Images have `byte_size` instead of `content`.

The path sets how a page is served:
- A path ending in `.css`, `.js`, `.svg`, `.txt` or `.md` is served as that type. `.txt` is `text/plain` and `.md` is `text/markdown`.
- An image path is served as its image type.
- Any other path is an HTML page.
- Hunchwire itself uses `/embed.js`, `/e/…`, `/up`, `/robots.txt` and `/sitemap.xml`.
- `/404` is the page a missing path shows, with status 404. Without one, visitors get a plain "Page not found" that links to `/`.

- `POST /projects/:id/pages`: add a page. *manage*
  ```
  {"page": {"path": "/pricing", "content": "<!doctype html>…"}}
  ```
  - In a project testing versions, each HTML page belongs to one version. Send `"version": "B"` in `page`.
  - Without a version, the page is a 422. That way you never give one version a page the others lack by accident.
  - Files (`.css`, `.js`, images…) are shared by every version and take no `version`.
  - Versions never share HTML. To change a page in every version, change each one.
- `POST /projects/:id/pages` with an image: upload it as a multipart file, not JSON. *manage*
  ```
  curl -X POST https://app.hunchwire.com/projects/:id/pages \
    -H "Authorization: Bearer hw_…" \
    -F "page[path]=/images/hero.png" -F "page[file]=@hero.png"
  ```
  - PNG, JPG, WebP, GIF or AVIF, up to 5 MB. The file's contents must match its extension.
  - Leave out `page[path]` to use `/images/<file name>`.
  - Show it on a page with `<img src="/images/hero.png">`.
  - To replace an image, `PATCH` the page with a new `page[file]`.
- `GET /projects/:id/pages/:page_id`: get one page, with its content. Images return `byte_size` instead of content. *analytics*
- `PATCH /projects/:id/pages/:page_id`: change a page's `path` or `content`. *manage*
- `DELETE /projects/:id/pages/:page_id`: remove a page. *manage*
  - Returns `204`. If live campaigns' ads lead to it, Hunchwire pauses them on Meta first and returns `200` with `paused_campaigns`. A path another version keeps doesn't count.

### Write pages

- `/` is the home page. Hunchwire adds its script to every HTML page.
- Put a form where you want it with `<div data-hunchwire="waitlist"></div>` or `<div data-hunchwire="login"></div>`. See The embed for what each does and its options. You don't add the script tag: hosted pages load it themselves.
- Count clicks on any link or button with `data-hunchwire-track="plan-team"`.
- Count something that isn't a click from your page's own script: `window.hunchwire?.track("name-typed")`. Use it for typing in a field or a swipe. It's recorded as a tracked click with that name, up to 100 characters.
- Hunchwire doesn't count visits, engagement or clicks from bots, automated browsers, the workspace's ignored IP addresses, or browsers opted out with `?hunchwire=ignore`. See Internal traffic.
- Every page also reports how long it was visible and how far it was scrolled. Hunchwire records each visit's device, browser and country. There's nothing to set up.
- To share one stylesheet, add a `/styles.css` page and link to it. Scripts work the same way, with `/app.js`.
- Real product photos or renders sell a product better than drawings. Upload them as images.
- A site that looks like a real product, with home, pricing, sign-up and log-in pages, tests demand far better than a lone form.
- **Search engines:** nothing to set up.
  - Each site serves `/robots.txt` and `/sitemap.xml`.
  - The sitemap lists every HTML page except `/404` and pages with `<meta name="robots" content="noindex">`. Add that tag to a page that shouldn't be found, like a fake login.
  - Every page with a `<head>` gets a `<link rel="canonical">` to the site's main address, unless it has its own. That address is your connected domain when you have one, else `<slug>.hunchwire.site`. For Hunchwire's own site, it's `hunchwire.com`.
  - `/Pricing` and `/pricing/` redirect (301) to `/pricing`, keeping the query string. Link pages by their lowercase path, with no trailing slash.
  - Hunchwire caches `.css`, `.js`, `.svg`, `.txt`, `.md` and image files, and Markdown copies, for 5 minutes. An edit can take that long to show.
- **AI assistants and agents:** nothing to set up.
  - Every page in the sitemap also has a Markdown copy of its text at its path plus `.md`: `/pricing.md`, and `/index.md` for `/`. The copy names the page as canonical.
  - To write the copy yourself, upload a page at that path.
  - To give assistants a summary of the site, add an `/llms.txt` page in the [llms.txt format](https://llmstxt.org): a `# Name`, a `> one-line summary`, then links to the pages, ideally their `.md` copies.

Be honest with visitors. Forms must reveal that the product isn't available yet. Pages must not invent customers, testimonials or statistics.

## Versions

Test several pitches at once. Each visitor gets one version of the hosted site, picked at random by weight, and keeps it for 90 days through a cookie on the site. Search engines and bots always see the first live version. Versions work only with `hosted_pages`.

A version has:
- `key`: `A`, `B`… in the order added. Keys are never reused.
- `name`: its name.
- `weight`: its share of new visitors, relative to the other live versions.
- `status`: `live`, `paused` (keeps its visitors but gets no new ones) or `ended`.
- `pages`: how many HTML pages it has.
- `preview_url`: a link that shows this version as visitors see it. `null` for an ended version.

- `GET /projects/:id/versions`: list the versions. *analytics*
  - Returns `versions`, oldest first, and `split_runs`: true while more than one version is live or paused.
  - An empty list means one version: everyone sees the same pages.
- `POST /projects/:id/versions` with `{"version": {"name": "Founder story"}}`: add a version. *manage*
  - It copies the first live version's HTML pages once. The first time, the project's pages become `A` "Original" and the copy becomes `B`. From then on, the versions are independent.
  - Then change only what differs: `PATCH /projects/:id/pages/:page_id` on the new version's pages, which `GET /projects/:id/setup` lists.
  - At most 4 versions can be live or paused at once. Adding one sets the split to even.
- `PATCH /projects/:id/versions/:version_id`: change a version. *manage*
  - `version_id` is the version's `key`, like `B`.
  - Rename it: `{"version": {"name": "…"}}`.
  - Pause or resume it: `"status": "paused"` or `"live"`.
  - Set the split: `{"split": "even"}`, or `{"weights": {"A": 70, "B": 30}}` for the live versions. Weights add up to 100.
- `DELETE /projects/:id/versions/:version_id`: end a version and delete its pages. *manage*
  - Its results stay in Analytics. You can't delete the last version.
  - Live campaigns whose ads lead to a page only this version has are paused on Meta first. Returns `versions` and `paused_campaigns`.
- `POST /projects/:id/versions/:version_id/keep`: end the test and keep this version. *manage*
  - Everyone sees this version from now on. The others end, and their pages are kept.
  - Only do this when the founder decides. Hunchwire never picks a winner.

### Preview a version

Open a version's `preview_url` to see it as visitors do, for example `https://loop.hunchwire.site/?version=C`.
- Add `?version=` with a live or paused version's key to any page of the site.
- Hunchwire records nothing: no visit, no clicks and no sign-up. The form shows, but takes no sign-up.
- The preview never sets or changes the version you get on the site. Search engines don't index it.
- Links between the site's pages keep `?version=`, so you can click through the version.
- An unknown or ended key shows the site as usual.

Send the founder the link when you change a version's pages, so they can check it.

See results by version in Analytics with `filter[version][]=B`, or use `by=version` to see the funnels side by side.

## Your domain

Use the founder's own domain for two things: to show a hosted project's pages there, with a certificate (`hosted_pages` only), and to email the project's people from it (any project; see Email). A project on its own site (`own_site_forms`) adds its domain for email only.

Adding a domain works on any plan. Connecting it for pages, and sending email, need a paid plan.

Each project has one domain. For pages, a bare domain (`tryloop.app`) or its `www` adds both, and another subdomain (`go.tryloop.app`) covers only itself. For email, Hunchwire uses the domain as typed.

A site has one address: its primary host. That's the bare domain once it serves, else its `www`, else the free address. Every other address of the site answers a `GET` with a `301` to the same path and query there: the other half of the domain, and `<slug>.hunchwire.site`. A `POST` stays where it lands. Ask the founder to add the records for both halves, so both open.

- `GET /projects/:id/domain`: get the domain and the DNS records it needs. *analytics*
  - `domain`: the domain added, or `null`.
  - `primary_host`: the site's one address. Every other address of the site redirects there.
  - `on`: false until Hunchwire turns on custom domains.
  - `connected`: whether the domain is connected for pages.
  - `hostnames`: each has `hostname`, `status`, `connected`, `serving`, `checked_at`, `error` and its own `records`. `status` is `pending`, `active` (ownership verified and certificate issued) or `failed`. `serving` means active and connected: pages show there.
  - `redirects_to`: the host a hostname redirects to, or `null` when it's the primary host or doesn't serve yet.
  - `records`: every DNS record the founder adds where they bought the domain, for pages and email together. Each has `type` (`ALIAS`, `CNAME`, `TXT` or `MX`), `name` (relative to the domain; `@` is the bare domain), `host`, `value`, `priority`, `hint`, `for` (`pages`, `ownership` or `email`) and `status` (`found` or `waiting`).
  - `priority`: the MX priority, as its own number, since DNS forms ask for it apart from the mail server. `null` for other types. `value` is the mail server alone.
  - `hint`: a line for the founder when their provider may need another type, or `null`. The bare domain's `ALIAS` says: on Cloudflare, add a CNAME on `@`, DNS only (not proxied).
  - The email records are TXT `hw1._domainkey` (the signing key), MX and TXT on `bounce` (where bounces come back), and TXT `_dmarc`. A DMARC record the domain already has counts.
  - Many providers call the bare domain's `ALIAS` an ANAME or "CNAME flattening". If the founder's provider has neither, they forward the bare domain to `www` there.
  - `email`: the domain's email setup.
    - `on`: false until Hunchwire turns on email.
    - `status`: `pending`, `verified`, `failed` or `paused`, or `null` before the domain is added.
    - `paused`: too many bounces or complaints. Sending stops until Hunchwire resumes it.
    - `ready`: whether the project can email its people now.
    - `blocker`: why not (see Email).
    - `setup`: the first email setup step still missing, or `null` once it's done. It's `no_domain`, `unverified`, `failed`, `paused`, `no_mail_from` or `no_sender`. Unlike `blocker`, it ignores the plan.
    - `by_hand`: whether the founder sends by hand instead (see Send by hand). Always false once email is set up.
    - `checked_at` and `error`.
- `PATCH /projects/:id/domain` with `{"domain": "tryloop.app"}`: add the domain. *manage*
  - `PUT` works too.
  - The records come back at once. Give them to the founder, or add them yourself if you can reach their DNS.
  - A domain added before email was on gets its email records from this call, sent with no `domain`.
- `POST /projects/:id/domain/check`: look up the records now. *manage*
  - Hunchwire also checks every 5 minutes for a week. After that, it checks one half of the domain once a day while the other half serves.
- `POST /projects/:id/domain/connect`: connect the domain for pages, once the founder agrees. *manage*
  - It's a release gate. Without a plan that has the domain feature, Hunchwire answers `402` `plan_required` (see Plans and gates). It then connects the domain by itself once the founder upgrades.
- `DELETE /projects/:id/domain`: remove the domain. *manage*
  - Pages stop showing there, and email stops coming from it, at once. A held connect is dropped.
  - Live campaigns whose ads lead to it are paused on Meta first. The response then adds `paused_campaigns`.

### Forward paths to your app

Send some paths on the connected domain to the founder's own app. Visitors stay on the founder's domain: `tryloop.app/` shows the hosted pages, and `tryloop.app/join` comes from the app.

Forwarding works only on the connected domain of a project with hosted pages. On the primary host, every method forwards. On the other half of the domain, a `GET` redirects to the primary host first, and other methods forward. The free address never forwards: a `GET` there redirects to the primary host too.

| Path | Matches |
|---|---|
| `/privacy` | `/privacy` only |
| `/join*` | `/join`, `/join/step-2` and `/joined` |
| `/hb-assets/*` | everything under `/hb-assets/` |

- `GET /projects/:id/forwarding`: get what the project forwards. *manage*
  ```json
  {"to": "https://app.tryloop.app", "paths": ["/join*", "/privacy"], "forwarding_secret_hint": "x3Fq", "hidden_pages": ["/privacy"]}
  ```
  - `to`: the app's address, or `null` when nothing is forwarded.
  - `paths`: the paths forwarded.
  - `forwarding_secret_hint`: the last 4 characters of the forwarding secret, or `null`.
  - `hidden_pages`: hosted pages a path hides. A forwarded path wins over a page.
- `PATCH /projects/:id/forwarding`: set the app's address and the paths. *manage*
  ```
  {"forwarding": {"to": "https://app.tryloop.app", "paths": ["/join*", "/privacy"]}}
  ```
  - `to` is https and names a host, with nothing after it. It can't be a Hunchwire address, an IP address or a private network.
  - You can forward up to 20 paths. Each starts with `/`. End one with `*` to include everything after it.
  - Hunchwire's own paths can't be forwarded: `/`, `/embed.js`, `/e/*`, `/robots.txt`, `/sitemap.xml`, `/share/*`, `/invite/*`, `/confirm/*`, `/unsubscribe/*`, `/up` and `/lab_mail/*`.
  - The first save makes the forwarding secret. It comes back once, as `forwarding_secret`. Give it to the founder for the app's environment.
  - A `422` lists what's wrong in `errors`.
- `POST /projects/:id/forwarding/secret`: make a new forwarding secret. Only do this when the founder asks. *manage*
  - It comes back once, as `forwarding_secret`. The old one stops working at once.
- `DELETE /projects/:id/forwarding`: stop forwarding. *manage*
  - The paths show the hosted pages again, and the forwarding secret is gone.

Hunchwire sends the visitor's method, path, query, body and cookies on to the app. It returns the app's answer as it is, redirects and cookies included. It never follows a redirect.

Hunchwire adds these headers:

| Header | Value |
|---|---|
| `X-Forwarded-Host` | The domain the visitor used, such as `tryloop.app`. |
| `X-Forwarded-Proto` | `https`. |
| `X-Hunchwire-Client-Ip` | The visitor's IP address. |
| `X-Hunchwire-Time-Zone` | The visitor's time zone, such as `America/Toronto`. Left out when it isn't known. |
| `X-Hunchwire-Forwarding-Secret` | The forwarding secret. Trust the two headers above only when it matches. |

Limits: the app has 5 seconds to connect and 15 to answer. A request body can be up to 1 MB, and an answer up to 10 MB.

| Status | Meaning |
|---|---|
| `502` | The app didn't answer in time, or answered with more than 10 MB. |
| `413` | The request body is over 1 MB. |
| `503` | Too many forwards at once. Retry after the seconds in `Retry-After`. |

Forwarded pages count in Analytics only with the embed snippet (see The embed). On the same domain, a visitor who moves from a hosted page to a forwarded one is one visit.
