# FreshLeads: every app feature, by recipe

Read `https://freshleads.cc/skill.md` first. It has the key, the rules for
every call, and the main routes. This file is the rest of the app: a map of
the areas, then step-by-step recipes for the common tasks. Each area has a
full reference file with every field, error and example.

Contents: 1. The areas · 2. Before you start · 3. Estimate a filter change ·
4. Send one email from FreshLeads · 5. Schedule emails by tag ·
6. Connect a sender · 7. Export with AI messages · 8. Search the archive ·
9. Deep scan · 10. Money: what needs the user

## 1. The areas

Every route here is under `https://freshleads.cc/api/v1` and takes the same
API key. The answer is `{"data": …}`; a failure is
`{"error": {"code", "message"}}`.

| Area | Read | What is in it |
|---|---|---|
| search | `https://freshleads.cc/skill/app/search.md` | Estimate a filter change, translate keywords, try the AI again, existing-business searches, business categories |
| contacts | `https://freshleads.cc/skill/app/contacts.md` | The full contact search with facets, bulk tags and delete, comments, a lead's share link, one AI message for one lead |
| exports | `https://freshleads.cc/skill/app/exports.md` | CSV files, background exports with AI messages |
| mail | `https://freshleads.cc/skill/app/mail.md` | Senders (Resend domains, Gmail), the email rules, suppressions, replies |
| chat | `https://freshleads.cc/skill/app/chat.md` | Check, review, send, schedule and cancel email in a thread; the inbox |
| calendar | `https://freshleads.cc/skill/app/calendar.md` | Schedule by tag, move or remove scheduled emails, warmup, health tests |
| email-setup | `https://freshleads.cc/skill/app/email-setup.md` | The free-domain gift and the own-domain (Resend) wizard |
| archive | `https://freshleads.cc/skill/app/archive.md` | Search past months by meaning, deep scans over stored pages |
| billing | `https://freshleads.cc/skill/app/billing.md` | The plan, credits, gift codes |
| settings | `https://freshleads.cc/skill/app/settings.md` | Profile, the user's AI, Resend, Instantly and Truelist keys |
| public | `https://freshleads.cc/skill/app/public.md` | Shared leads, join links, a shared filter's contacts, prices, removals |
| auth | `https://freshleads.cc/skill/app/auth.md` | Email sign-in (only to get a first key) |

The full machine spec of every route is `https://freshleads.cc/api/v1/openapi.json`.

## 2. Before you start

```bash
KEY=fl_live_…                      # the user's API key
API=https://freshleads.cc/api/v1
H="authorization: Bearer $KEY"
```

- **Read the plan first.** `GET /me` gives `data.account.plan` and `limits`.
  Email, the calendar and CSV need Pro or higher (`403 plan_limit`). The
  archive and deep scans need Archive or higher.
- **Ask before you send, spend or delete.** Every recipe below marks the
  call that sends email, spends money or deletes data with **(ask first)**.
  Show the user the numbers (the dry run, the price) and wait for "yes".
- **A 200 can still be a failure** on three routes: the email draft
  (`status: "error"`), an approved AI action (`status: "failed"`) and a poll
  that was too soon (`checked: false`). Read those fields.

## 3. Estimate a filter change

Never change a filter's rules blind. A rule change runs the filter again and
can spend the user's AI quota or AI key.

```bash
# 1. The filter now.
curl -s -H "$H" "$API/filters/$FILTER_ID" | jq '.data.filter.spec' > spec.json

# 2. Change spec.json, then ask what the save would do. Nothing is saved.
curl -s -X POST "$API/filters/estimate" -H "$H" -H 'content-type: application/json' \
  -d "{\"id\":\"$FILTER_ID\",\"spec\":$(cat spec.json)}" \
  | jq '.data.estimate | {rerun, leads, to_score, unscored, key_cost_usd, confidence}'
```

Read the answer:

| Field | Tell the user |
|---|---|
| `rerun: false` | Nothing new runs. Save freely. |
| `leads` | About this many leads fit the new rules (`confidence` says how sure). |
| `unscored` > 0 | These leads arrive without an AI score: the free AI quota is used and there is no AI key. Offer `PUT /me/ai-key`. |
| `key_cost_usd` | What the user's own AI key will pay. |
| `range_reset: true` | The save cuts the date range to 7 days. |

Then save with `PATCH /filters/{id}` (`skill/filters.md`). Every field is in
`skill/app/search.md`, "POST /filters/estimate".

## 4. Send one email from FreshLeads

FreshLeads can send from the user's own sender: a Resend domain (the email
setup) or a Gmail. The email lives in a thread on the contact, and replies come
back to the same thread. Pro or higher.

**One time per account:**

```bash
# A. Is there a sender, and are the email rules accepted?
curl -s -H "$H" "$API/mail" | jq '.data.mailbox | {status, from, gmails, domains, terms_accepted_at}'
```

- `status` is `none`: there is no sender. Do recipe 6 first.
- `terms_accepted_at` is `null`: show the user the email rules: "I email
  only people I have a reason to contact, sign with my name and postal
  address, and stop when asked." After their "yes", call `POST /mail/terms`
  with `{"accept": true}`. The postal address goes in `PUT /me/settings`.

**Each email:**

```bash
# 1. A saved prompt for the voice (any one; GET /prompts lists them).
PROMPT_ID=$(curl -s -H "$H" "$API/prompts" | jq -r '.data.prompts[0].id')

# 2. An email thread on the contact (lead_key = a domain or a lead_id).
CHAT_ID=$(curl -s -X POST "$API/prompts/$PROMPT_ID/chats" -H "$H" -H 'content-type: application/json' \
  -d '{"lead_key":"acmeplumbing.com","kind":"email"}' | jq -r '.data.chat.id')

# 3. Can the address get email? (waits up to ~20 s)
curl -s -X POST "$API/prompts/$PROMPT_ID/chats/$CHAT_ID/emails/check" -H "$H" \
  -H 'content-type: application/json' -d '{}' | jq '.data | {to, can_send, verify, blocked}'

# 4. The AI writes the email (or you write it).
curl -s -X POST "$API/prompts/$PROMPT_ID/chats/$CHAT_ID/draft" -H "$H" \
  -H 'content-type: application/json' -d '{"guide":"Offer a free 10-minute site review."}' \
  | jq '.data | {status, subject, body}'

# 5. Review it. A first email gets an AI review.
curl -s -X POST "$API/prompts/$PROMPT_ID/chats/$CHAT_ID/emails/review" -H "$H" \
  -H 'content-type: application/json' -d '{"subject":"…","body":"…"}' | jq '.data.review'

# 6. (ask first) Send now, or add "send_at" to schedule it.
curl -s -X POST "$API/prompts/$PROMPT_ID/chats/$CHAT_ID/emails" -H "$H" \
  -H 'content-type: application/json' \
  -d '{"subject":"…","body":"…","send_at":"2026-11-05T15:00:00Z"}' | jq '.data.message'
```

Decide at each step:

| Step | Answer | Do this |
|---|---|---|
| 3 | `can_send: false` | Stop for this contact. `blocked.reason` says why (do not contact, unsubscribed, bounced, invalid…). |
| 3 | `verify: "risky"` or `"unverified"` | You may send. Tell the user the address is not proven. |
| 4 | `status` is not `ok` | Write the email yourself, or tell the user (`no_key`: the plan has no AI emails). |
| 5 | `review.blocked: true` | Never send it. It touches a restricted topic. |
| 5 | `review.must_fix: true` | Send the fixed `review.subject` and `review.body` (links removed), not yours. |
| 5 | other `issues` | Advice. Fix them if you can. |
| 6 | `429 daily_limit` | 30 emails per sender per UTC day. Schedule it for tomorrow. |

To cancel a scheduled email: `DELETE /prompts/{id}/chats/{chat_id}/emails/{msg_id}`.
Replies: `POST /mail/poll`, then `GET /inbox`. A thread with `review: true`
waits for an answer. Every field: `skill/app/chat.md` and `skill/app/mail.md`.

## 5. Schedule emails by tag

For many contacts at once: the user tags them, and FreshLeads writes and
schedules one first email for each. The emails then **send by themselves**.

```bash
# 1. Dry run: how many contacts, how many get a slot. Writes nothing.
curl -s -X POST "$API/mail/calendar/queue" -H "$H" -H 'content-type: application/json' -d '{
  "include": [["dentist","london"]],
  "exclude": ["contacted","email_invalid","email_warmup"],
  "prompt_id": "'$PROMPT_ID'",
  "start": "2026-11-05", "end": "2026-11-20", "per_day": 10,
  "tz_offset": 0, "dry_run": true
}' | jq '.data | {matched, skipped, skip_reasons, queued, over, first_day, last_day}'

# 2. (ask first) Show the user the numbers. On "yes", send the same body without "dry_run".
#    The answer has data.job.id.

# 3. Watch it: the job and the scheduled emails.
curl -s -H "$H" "$API/mail/calendar" | jq '.data.jobs[0] | {id, status, total, done, scheduled, failed}'

# To stop the job:            POST /mail/calendar/jobs/{id}/stop
# To remove what it scheduled: POST /mail/calendar/unqueue  {"include":[["dentist","london"]]}
```

- `include` is tag groups: `[["a","b"],["c"]]` means (a and b) or c.
- `tz_offset` is minutes, as JavaScript `getTimezoneOffset()` (UTC+8 is
  `-480`). Emails go out 09:00 to 16:30 the leads' local time, weekdays only
  unless `"weekdays": false`.
- `over` > 0: the date range has too few slots. Widen it or raise `per_day`.

Every field: `skill/app/calendar.md`, "POST /mail/calendar/queue".

## 6. Connect a sender

Pick one. `GET /mail` shows the result.

**A. A Gmail** (fast). The user makes a Google app password
(myaccount.google.com/apppasswords; it needs 2-step sign-in) and pastes it to
you. Never print it back.

```bash
curl -s -X POST "$API/mail/connect-gmail" -H "$H" -H 'content-type: application/json' \
  -d '{"address":"mark.acme@gmail.com","app_password":"abcd efgh ijkl mnop","name":"Mark"}'
```

`400 gmail_imap`: turn on IMAP in Gmail settings. `400 gmail_refused`: a wrong
app password.

**B. The user's own domain on their Resend account.** The user needs a
Resend account and a **Full access** key (`re_…`).

1. `POST /email-setup/resend/domains` with the key: pick the domain's `id`.
2. `POST /email-setup/resend/connect` with the key and `domain_id`: give the
   user the `records` to add at their DNS host.
3. `POST /email-setup/resend/status` with `"nudge": true` until `status` is
   `verified` (minutes to hours).
4. `POST /email-setup/resend/test` sends a test email and makes the domain the
   sender.
5. `POST /mail/connect-domain` makes it the default sender.

**C. A free domain from us** (Pro and higher, one per account): `GET /free-domain`,
`POST /free-domain/search`, then **(ask first)** `POST /free-domain/start`,
then poll `GET /free-domain` until `status` is `ready`.

Every field: `skill/app/email-setup.md` and `skill/app/mail.md`.

## 7. Export with AI messages

```bash
# 1. Start: one AI email for each kept contact (at most 1,000 new messages).
EXPORT_ID=$(curl -s -X POST "$API/exports" -H "$H" -H 'content-type: application/json' \
  -d '{"prompt_id":"'$PROMPT_ID'","query":{"states":"kept"},"channels":["email"]}' | jq -r '.data.export.id')

# 2. Poll every 10 seconds until status is done or failed.
curl -s -H "$H" "$API/exports/$EXPORT_ID" | jq '.data.export | {status, done, written, failed, download_url}'

# 3. Download within 15 minutes of the read.
curl -s -o leads.csv "$DOWNLOAD_URL"
```

For a plain CSV with no AI, use `GET /leads?format=csv` (`skill/leads.md`) or
`GET /export.csv`. Every field: `skill/app/exports.md`.

## 8. Search the archive

Past months of new websites, searched by meaning. Archive plan; each search
costs a small amount of credits (`data.cost`), taken from the month's free
grant first, then from bought credits.

```bash
curl -s -G -H "$H" "$API/archive/search" \
  --data-urlencode 'q=vegan bakery' -d months=3 -d countries=GB -d limit=20 \
  | jq '.data | {cost: .cost.charged_text, results: [.results[] | {domain, name, city, has_email}]}'
```

`402 no_credits`: the grant and bought credits do not cover the search. Search
fewer months, or the user buys credits in the app (recipe 10). Every field:
`skill/app/archive.md`.

## 9. Deep scan

A regular expression over the stored HTML of every site in a date range, for
example to find shops on one platform. Archive plan.

```bash
# 1. The price. Holds nothing.
curl -s -X POST "$API/archive/deep-scans" -H "$H" -H 'content-type: application/json' \
  -d '{"pattern":"cdn\\.shopify\\.com","from":"2026-10-01","to":"2026-10-31","countries":["US"],"dry_run":true}' \
  | jq '.data.estimate.max_price_text'

# 2. (ask first) The same body without "dry_run". HTTP 201, data.scan.id.
# 3. Poll every 30 seconds until status is succeeded, then read the rows.
curl -s -H "$H" "$API/archive/deep-scans/$SCAN_ID" | jq '.data | {status: .scan.status, rows: (.rows | length)}'
```

A scan holds `max_price` (the month's grant first, then bought credits) and
charges only what it reads; the rest goes back. Stop it with
`DELETE /archive/deep-scans/{id}`.

## 10. Money: what needs the user

| Task | Can you do it alone? |
|---|---|
| Read the plan, the offers, the credits | Yes: `GET /billing`, `GET /credits`. |
| Start a plan or buy credits | **No.** A card payment needs the user in a browser (Stripe confirms it). Send them to `https://freshleads.cc/app/`. |
| End the free trial early (pay now) | Yes, with the user's explicit "yes": `POST /billing/prepay` with their words in `authorization_text`. |
| Cancel, resume, ask for a refund | Yes, only when the user asks: `POST /billing/cancel`, `/billing/resume`, `/billing/refund-request`. |
| Redeem a gift code | Yes: `POST /gifts/redeem`. |

Every field: `skill/app/billing.md`.
