---
name: leads-rolodex
description: >-
  REST API for the Leads Rolodex — an internal lead deduplicator keyed on website, with a
  uuid alternate key and indexed lookup by any alt field (email, social handle, phone,
  dollarplatoon task id, tag, assignee, custom field). Use it to create leads, merge
  duplicates, find a lead from a dollarplatoon task id, record who a lead was assigned to,
  and record whether the lead replied. Also receives Gigdesk chatbot transcripts through a
  logs-forwarding webhook and serves them back as markdown.
---

# Leads Rolodex — REST API

Base URL: `https://leads-rolodex.cloud.zoomgtm.com`
This document: `https://leads-rolodex.cloud.zoomgtm.com/skill.md`

An internal tool. It is not part of the Gigdesk suite and shares no account model with it.
It does one job: keep exactly one record per company website, and make that record findable
by any string you happen to hold.

---

## 1. Authentication

One shared key for everything. Send it in any of these four ways:

| Method | Example |
|---|---|
| Header (preferred) | `x-service-key: WIFIBREAD` |
| Header (alias) | `x-admin-key: WIFIBREAD` |
| Bearer | `Authorization: Bearer WIFIBREAD` |
| Query string | `?key=WIFIBREAD` |

The comparison is case-insensitive and constant time. `GET /api/health` is the only
endpoint that does not need the key.

A missing or wrong key returns `401 {"error":"unauthorized"}`.

---

## 2. Identity model

| Key | Role | Uniqueness |
|---|---|---|
| `website` | Deduplication key and primary identifier | **Unique.** One lead per host. |
| `uuid` | Alternate identifier | Unique. Stable. Never reused. |
| any alt key | Convenience lookup | **May repeat** across leads. |

`website` is normalized to a bare host before anything happens to it:

```
https://WWW.Acme.com/pricing?x=1   →   acme.com
acme.com:443                       →   acme.com
```

So `POST /api/leads` twice with `https://www.acme.com/careers` and `acme.com` creates
**one** lead, not two.

The exception is a host that carries many unrelated businesses — `etsy.com/foocrafts`,
`github.com/janedev`. There the key keeps the tenant segment, and the caller supplies it as
`dedup_key`. See §4 for why and how.

### Alt keys

Every value below is written to a lookup index automatically. You never register an alt key
by hand — you just put the value on the lead.

| Field name | Comes from | Normalization |
|---|---|---|
| `uuid` | `uuid` | lowercase |
| `website` | `website` | bare host |
| `dedup_key` | `website`, when it carries a tenant path | exact, e.g. `etsy.com/foocrafts` |
| `name` | `name` | lowercase, whitespace collapsed |
| `email` | each of `emails[]` | lowercase |
| `phone` | each of `phones[]` | digits only |
| `social:<network>` | each key of `socials{}` | handle only (`https://x.com/@Foo/` → `foo`) |
| `dp_task_id` | each of `dp_task_ids[]` | ULID match, prefix ignored |
| `tag` | each of `tags[]` | lowercase |
| `status`, `source`, `assigned_to` | those fields | lowercase |
| `gigdesk:<key>` | each key of `gigdesk{}` | lowercase |
| `stripe_customer_id` | `stripe.customer_id` | lowercase |
| `stripe_payment_id` | each `stripe.payments[].id` | lowercase |
| `custom:<key>` | each scalar in `custom{}` | lowercase |

Dollarplatoon ids match on the ULID and ignore the prefix, so `TASK_01HZX…`,
`task_01hzx…` and the bare `01HZX…` all find the same lead.

### Identifiers in a URL path

Anywhere the path says `:id`, you may pass **any** of these. They resolve in this order:
uuid → website → alt key.

```
/api/leads/2f1c9e4a-....-....                     uuid
/api/leads/acme.com                               website
/api/leads/https%3A%2F%2Fwww.acme.com%2Fabout     website (url-encoded)
/api/leads/TASK_01HZXABCDEF0123456789ABCDE         dollarplatoon task id
/api/leads/buyer@acme.com                         email
```

Pin the field with a `field:value` prefix when a bare string would be ambiguous:

```
/api/leads/dp:TASK_01HZX…          /api/leads/email:buyer@acme.com
/api/leads/website:acme.com        /api/leads/uuid:2f1c9e4a-…
/api/leads/tag:conference-2026     /api/leads/custom:crm_id:4471
```

Accepted prefixes: `uuid` `id` · `website` `site` `domain` `url` · `email` `mail` ·
`phone` `tel` · `dp` `task` `dp_task_id` · `name` · `tag` · `status` · `source` ·
`assignee` `assigned_to` · `stripe` · `social:<network>` · `gigdesk:<key>` · `custom:<key>`

If an alt key matches **more than one** lead, the call returns `409`:

```json
{ "error": "ambiguous_identifier",
  "identifier": "tag:conference-2026",
  "candidates": [ { "uuid": "…", "website": "acme.com", "field": "tag", "value": "conference-2026" } ] }
```

Pick a candidate and retry with its `uuid`.

---

## 3. The lead document

`POST` and `PATCH` accept **any** extra top-level keys and store them untouched. The named
fields below are only the ones the index, the web page and the future Stripe rollup read.

```json
{
  "uuid": "2f1c9e4a-8b21-4c33-9a10-71d0d0c2e551",
  "website": "acme.com",
  "website_url": "https://acme.com",
  "name": "Acme Corporation",
  "emails": ["hiring@acme.com", "jane@acme.com"],
  "phones": ["+1 415 555 0142"],
  "socials": {
    "linkedin": "acme-corp", "x": "acmecorp", "instagram": "acme",
    "facebook": "", "tiktok": "", "youtube": ""
  },
  "addresses": [],
  "dp_task_ids": ["TASK_01HZXABCDEF0123456789ABCDE"],
  "tags": ["saas", "series-a"],
  "source": "clients-vending-machine",
  "status": "new",
  "assigned_to": "",
  "assigned_at": "",
  "outreach": {
    "emailed": false, "emailed_at": "", "email_count": 0,
    "opened": false, "opened_at": "",
    "replied": false, "replied_at": "", "reply_snippet": "",
    "bounced": false, "bounced_at": "",
    "last_channel": ""
  },
  "gigdesk": {
    "project_id": "proj_x2F9EuEPZKGO0SRK",
    "workspace_id": "", "bot_id": "bot_UwIM-LS0c6Ywnn2r",
    "ticket_id": "", "notification_tag": ""
  },
  "stripe": { "customer_id": "", "total_paid_cents": 0, "currency": "usd", "payments": [] },
  "notes": [ { "id": "a1b2c3", "ts": "2026-09-12T09:00:00.000Z", "author": "luna", "kind": "note", "text": "…" } ],
  "custom": { "crm_id": 4471, "headcount": 32 },
  "created_at": "2026-09-12T09:00:00.000Z",
  "updated_at": "2026-09-12T09:00:00.000Z",
  "rev": 1
}
```

`status` is free text. The web page colours these: `new` `contacted` `replied`
`qualified` `won` `lost` `dead`.

**Read-only.** `uuid`, `rev`, `created_at`, `s3_key` are set by the server. Sending them is
not an error; they are ignored.

---

## 4. Endpoints

### `GET /api/health` — no key required

```json
{ "ok": true, "service": "leads-rolodex", "stage": "production", "time": "…" }
```

### `POST /api/auth/check`

Validates the key and nothing else. The web page calls it from the password modal.
`200 {"ok":true}` or `401`.

---

### `POST /api/leads` — create, with deduplication

`website` is required. Everything else is optional.

```bash
curl -sX POST https://leads-rolodex.cloud.zoomgtm.com/api/leads \
  -H 'x-service-key: WIFIBREAD' -H 'content-type: application/json' -d '{
    "website": "https://www.acme.com/pricing",
    "name": "Acme Corporation",
    "emails": ["hiring@acme.com"],
    "socials": { "linkedin": "acme-corp", "x": "https://x.com/@acmecorp" },
    "dp_task_ids": ["TASK_01HZXABCDEF0123456789ABCDE"],
    "tags": ["saas"],
    "source": "clients-vending-machine",
    "notes": [{ "text": "Pulled from the bulk board, morning 3." }],
    "actor": "luna"
  }'
```

The normal path is to include the dollarplatoon task id **at create time**, as above. That
is what later makes `PATCH /api/leads/dp:TASK_…` work without you holding a uuid.

| Result | Status | Body |
|---|---|---|
| New lead | `201` | `{ ok, created:true, deduplicated:false, lead }` |
| Website already known | `200` | `{ ok, deduplicated:true, merged:false, lead }` |

Add `"merge": true` (or `?merge=1`) to merge the payload into the existing lead instead of
returning it untouched. `POST /api/leads/upsert` is the same endpoint with `merge` defaulting
to true.

`400 {"error":"website_required"}` if the website is missing or is not a resolvable host.

#### `dedup_key` — marketplace tenants

Some hosts carry thousands of unrelated businesses. `etsy.com/foocrafts` and
`etsy.com/barware` are two companies; `github.com/janedev` is a person, not GitHub Inc.
Normalizing those URLs to a bare host would merge every tenant into one lead.

The rule for where a tenant boundary sits lives in
`ZoomGTM/vending-machine-leads/src/shared/lead-identity.mjs`, which knows the per-host
quirks. A caller that has already computed an identity sends it as `dedup_key`, and it is
used **verbatim** as the key:

```json
{ "dedup_key": "etsy.com/foocrafts", "website": "https://etsy.com/shop/FooCrafts", "name": "Foo Crafts" }
```

Without `dedup_key` the key is the bare host, which is correct for an ordinary company site.
Send `dedup_key` whenever the lead came out of the vending-machine-leads pipeline.

#### `match` — land on a lead you already know by another id

An upsert may name identifiers to try **before** the website. The first one that resolves
uniquely wins; ambiguous ones are skipped. Use it when your idea of the website is less
precise than the one that created the record.

```json
{ "website": "https://etsy.com/shop/FooCrafts",
  "match": ["custom:zoomgtm_task_id:twitter-etsy-foocrafts", "dp:TASK_01HZX…"],
  "assigned_to": "gigworker_amina" }
```
```json
{ "ok": true, "deduplicated": true, "merged": true,
  "matched_on": "custom:zoomgtm_task_id:twitter-etsy-foocrafts", "lead": { … } }
```

**An upsert never re-keys the lead it lands on.** `website`, `website_url` and `dedup_key`
are ignored once a lead is matched or deduplicated — the stored key wins, because it can be
more precise than the one you sent. Renaming is `PATCH`, which is explicit.

---

### `POST /api/leads/bulk` — up to 200 at once

```json
{ "merge": true, "actor": "bulk-board-sync", "leads": [ { "website": "acme.com", "…": "…" } ] }
```

Each row takes the same fields as `POST /api/leads`, including `dedup_key` and `match`.
Returns a per-row outcome:

```json
{ "ok": true, "created": 184, "deduplicated": 16, "failed": 0,
  "results": [ { "website": "acme.com", "requested": "acme.com",
                 "ok": true, "created": true, "uuid": "…" } ] }
```

`website` is the key the row **landed on**; `requested` is the key you sent. They differ
when `match` or `dedup_key` steered the row onto an existing lead. Reconcile on `requested`.

---

### `GET /api/leads/:id`

Returns the whole document plus the 50 newest events. Add `?events=0` to skip the events.

```json
{ "ok": true, "lead": { … }, "events": [ { "type": "created", "at": "…", "actor": "luna" } ] }
```

`404 {"error":"not_found"}` · `409 {"error":"ambiguous_identifier", "candidates":[…]}`

---

### `PATCH /api/leads/:id` — update by any identifier

This is the endpoint that "takes a dollarplatoon task id, finds the lead, and updates it".

```bash
curl -sX PATCH https://leads-rolodex.cloud.zoomgtm.com/api/leads/dp:TASK_01HZXABCDEF0123456789ABCDE \
  -H 'x-service-key: WIFIBREAD' -H 'content-type: application/json' -d '{
    "assigned_to": "gigworker_amina",
    "status": "contacted",
    "emails": ["jane@acme.com"],
    "custom": { "headcount": 32 },
    "add_note": { "text": "Sent intro from batch sender 2.", "author": "gigdesk-bulk" },
    "actor": "gigdesk-bulk"
  }'
```

Merge rules:

| Patch value | Effect |
|---|---|
| scalar | overwrites |
| object | merges key by key, recursively |
| array of scalars | **unions** with what is there, duplicates removed |
| array of objects | appends |
| `null` | deletes the key |

Extra body keys that control the merge instead of being stored:

| Key | Meaning |
|---|---|
| `replace_arrays: true` | arrays overwrite instead of union |
| `add_note` | string or `{text, author, kind}` — appends one note |
| `actor` | who did it; recorded on the event, not on the lead |
| `expected_rev` | optimistic lock. `409 {"error":"rev_conflict","rev":N}` on mismatch |

Changing `website` moves the deduplication pointer. If the new host already belongs to
another lead the call returns `409 {"error":"website_taken","uuid":"…"}` and nothing changes.

`PUT` behaves the same as `PATCH`.

---

### `POST /api/leads/lookup` — resolve without a 404

Never returns 404. Use it when you only want to know whether a lead exists.

```json
{ "dp_task_id": "TASK_01HZX…" }
```
```json
{ "ok": true, "found": true, "lead": { … } }
```

Accepts any one of `identifier`, `website`, `uuid`, `dp_task_id`, `email`, `q`.
Also available as `GET /api/leads/lookup?dp_task_id=…`.

---

### `GET /api/search` — flexible search

| Param | Default | Meaning |
|---|---|---|
| `q` | — | the search term. Omit it to list the newest leads. |
| `field` | none | pin the alt-key field, e.g. `email`, `tag`, `social:linkedin`, `custom:crm_id` |
| `mode` | `auto` | `exact` \| `contains` \| `auto` |
| `limit` | 50 | 1–200 |
| `cursor` | — | opaque page token from `next_cursor` |

* `exact` — indexed alt-key lookup. Constant time. This is the one to use from code.
* `contains` — substring over the index summaries. Bounded, and it may report
  `"truncated": true` when it stopped early.
* `auto` — `exact` first, and `contains` only if `exact` found nothing.

A bare term is tried under every plausible normalization at once, so
`?q=https://x.com/@acmecorp`, `?q=@acmecorp` and `?q=acmecorp` all hit the same social handle.

```bash
curl -s 'https://leads-rolodex.cloud.zoomgtm.com/api/search?q=acmecorp&mode=exact' \
  -H 'x-service-key: WIFIBREAD'
```
```json
{ "ok": true, "mode": "exact", "q": "acmecorp", "count": 1,
  "results": [ { "uuid": "…", "website": "acme.com", "name": "Acme Corporation",
                 "status": "new", "assigned_to": "", "replied": false,
                 "matched_on": "social:x" } ] }
```

`results` are search summaries, not whole documents. Fetch `GET /api/leads/:uuid` for the rest.

---

### `GET /api/leads` — newest first

`?limit=50&cursor=…` → `{ ok, count, next_cursor, results:[summary] }`.
Ordered by `updated_at` descending.

---

### Shorthand writers

Each one is a patch plus a typed event, so the activity log stays readable.
All take `actor` (or `by`) and an optional `at` (ISO 8601, defaults to now).

| Endpoint | Body | Does |
|---|---|---|
| `POST /api/leads/:id/assign` | `{assignee, status?, note?}` | sets `assigned_to` + `assigned_at` |
| `POST /api/leads/:id/status` | `{status, note?}` | sets `status` |
| `POST /api/leads/:id/notes` | `{text, author?, kind?}` | appends one note |
| `POST /api/leads/:id/outreach` | `{action, channel?, subject?, snippet?, status?}` | records an outreach step |
| `POST /api/leads/:id/payments` | `{amount_cents, currency?, id?, status?, customer_id?, description?}` | appends a payment |
| `POST /api/leads/:id/events` | `{type, note?, data?}` | writes a bare event, changes no field |
| `GET /api/leads/:id/events` | `?limit=50` | newest events first |

`outreach.action` is one of `emailed` · `opened` · `replied` · `bounced`.
It moves `status` forward on its own — `new → contacted` on `emailed`, and
`new`/`contacted` → `replied` on `replied` — unless you send an explicit `status`.

```bash
# the lead answered the cold email
curl -sX POST https://leads-rolodex.cloud.zoomgtm.com/api/leads/acme.com/outreach \
  -H 'x-service-key: WIFIBREAD' -H 'content-type: application/json' \
  -d '{"action":"replied","channel":"email","snippet":"Sure, send times.","by":"gigworker_amina"}'
```

`payments` recomputes `stripe.total_paid_cents` from every `succeeded` payment and sets
`status: "won"` when the amount is positive. Send `"mark_won": false` to stop that.

---

### `DELETE /api/leads/:id`

Removes the index rows, the website pointer and the S3 document. The bucket is versioned,
so the document itself is recoverable from its previous version.

```json
{ "ok": true, "deleted": true, "uuid": "…", "website": "acme.com" }
```

---

### `GET /api/stats`

```json
{ "ok": true, "total": 1204, "assigned": 880, "emailed": 903, "replied": 61,
  "reply_rate": 0.0675, "revenue_cents": 0,
  "by_status": { "new": 301, "contacted": 842, "replied": 61 },
  "by_assignee": { "gigworker_amina": 120 },
  "by_source": { "clients-vending-machine": 1204 },
  "truncated": false }
```

---

## 5. Gigdesk chat transcripts

A second, separate store. The Gigdesk chat lambda posts one row per user turn to whatever
URL a chatbot carries in `logs_forwarding`. This is the receiver, and it groups those rows
into readable transcripts.

### `POST /api/webhooks/gigdesk/chat`

Set this as the chatbot's **Logs forwarding** URL. The chat lambda sends no custom headers,
so the key has to be in the URL:

```
https://leads-rolodex.cloud.zoomgtm.com/api/webhooks/gigdesk/chat?key=WIFIBREAD
```

Add `&link=1` to also attach turns to leads (see below). The body is whatever Gigdesk sends:

```json
{ "event": "chat_turn", "session_id": "…", "thread_label": "Acme follow-up",
  "workspace_id": "WS_…", "chatbot_id": "bot_UwIM-LS0c6Ywnn2r", "chatbot_name": "…",
  "user_id": "…", "user_email": "…", "occurred_at": 1757000000000,
  "provider": "openrouter", "model": "…", "status": "ok",
  "user_message": "…", "attachments": [], "reply": "…" }
```

`session_id` is what groups turns into one transcript. Turns are **appended**, never merged:
re-posting the same turn adds it again, because nothing here can tell a retry from a user
who said the same thing twice.

For a replay or a backfill, send a batch of up to 100: `{"rows": [ … ]}` or a bare array.

```json
{ "ok": true, "count": 1,
  "results": [ { "ok": true, "session_id": "…", "turn": 7, "linked_leads": [] } ] }
```

A session keeps at most 800 turns; older ones drop off and are counted in `dropped_turns`.

### `GET /api/chats`

| Param | Meaning |
|---|---|
| `chatbot_id` | only this bot, e.g. `bot_UwIM-LS0c6Ywnn2r` |
| `user_email` | only this gigworker |
| `q` | substring of the newest message, the thread label or the email |
| `limit` `cursor` | paging; 1–200, default 50 |

Newest turn first. Returns session summaries, not transcripts.

### `GET /api/chats/:session_id`

The whole session as JSON. `?format=md` — or the path `/api/chats/:session_id.md` — returns
a `text/markdown` transcript instead: a header block, then every turn as
`## Turn n` with the user message and the reply.

### `GET /api/chats/export`

Every matching transcript in one markdown response. Takes the same filters as `GET /api/chats`
plus `format=json`. This is the "hand a bot's history to an agent" endpoint:

```bash
curl -s 'https://leads-rolodex.cloud.zoomgtm.com/api/chats/export?chatbot_id=bot_UwIM-LS0c6Ywnn2r&limit=25' \
  -H 'x-service-key: WIFIBREAD' > transcripts.md
```

Default `limit` is 20, maximum 100.

### `DELETE /api/chats/:session_id`

### Linking a turn to a lead

Off by default. A transcript is evidence about a conversation, not about a company, so
nothing is attached on a guess unless you ask with `&link=1`. With it on:

1. an explicit `website`, `lead_uuid` or `dp_task_id` field on the row wins;
2. otherwise the turn text is scanned for domains and `TASK_…` ids, and the lead is linked
   **only when exactly one** candidate resolves. Two candidates link nothing.

A linked turn appends a `chat_turn` event to that lead's timeline and adds the lead's uuid
to the session's `linked_leads`. No lead field is ever changed by a chat.

---

## 6. Errors

| Status | `error` | Meaning |
|---|---|---|
| 400 | `website_required` | no resolvable host on a create |
| 400 | `bad_json` | body was not valid JSON |
| 400 | `too_many` | bulk over 200 rows |
| 401 | `unauthorized` | missing or wrong service key |
| 404 | `not_found` | the identifier matched nothing |
| 404 | `document_missing` | index row exists, S3 document does not |
| 409 | `ambiguous_identifier` | an alt key matched several leads — see `candidates` |
| 409 | `rev_conflict` | `expected_rev` did not match |
| 409 | `website_taken` | the new website already belongs to another lead |
| 500 | `server_error` | see `detail` |

---

## 7. Who writes here

Three writers today. All of them are best effort: the rolodex being down never blocks the
work it is reporting on.

### `ZoomGTM/vending-machine-leads` — where a lead is born

`src/shared/leads-rolodex.mjs`. Every channel already calls `recordContacted()` after a
successful webhook, so that one line is the hook. It spools the lead to
`leads/rolodex-pending.jsonl` synchronously, and the network happens later:

```bash
node src/shared/leads-rolodex.mjs --status
node src/shared/leads-rolodex.mjs --flush
node src/shared/leads-rolodex.mjs --backfill --confirm   # seed from contacted.jsonl
```

Sends `dedup_key` (the canonical identity), `website`, `source` (the channel), contacts,
and `custom.zoomgtm_task_id` — the dispatch-side task id.

`score-queue-priorities.mjs --apply --confirm` then folds in the AI qualification. That pass
is also the first moment this repo learns a lead's **dollarplatoon** task id, so it attaches
`dp_task_ids` plus `custom.queue_priority` and `custom.quality_score`.

### `gigdesk.cc/bulk` — where a lead is handed to a person

The poll that writes the `GIGDESK_EVENT` comment onto a dollarplatoon task now writes the
same handover here: `POST /api/leads/upsert` with `assigned_to`, `dp_task_ids`,
`gigdesk.project_id = proj_x2F9EuEPZKGO0SRK`, and the prescheduled morning and batch sender
in `custom`. It uses `match` on the dispatch task id first, so a marketplace tenant lands on
the lead the pipeline created rather than on the bare host.

Ticking a ticket's step forward on the board posts
`POST /api/leads/dp:<task id>/outreach {"action":"emailed"}`. Only rises count — re-saving
the same step is not a second email.

The poll's own response reports both stamps: `{ "noted": n, "synced": n }`.

### Gigdesk chats — section 5 above

`bot_UwIM-LS0c6Ywnn2r` and any other chatbot, through `logs_forwarding`.

### Still to come

* **Reply detection** — whatever sees the inbound reply calls
  `POST /api/leads/:id/outreach {"action":"replied"}`.
* **Stripe** — `POST /api/leads/:id/payments` closes the loop from lead to sale.
  `stripe_customer_id` is already an alt key, so a webhook can find the lead from the
  customer id alone.

Because the deduplication key is the same canonical website on every path, the same client
arriving from the pipeline, from the bulk board and from Stripe stays one record.

---

## 8. Storage, for the record

* **S3** holds the lead documents, one JSON object per lead (`leads/<uuid>.json`). Versioned.
  This is the record of truth.
* **DynamoDB** holds only the index: the website pointer, the alt keys, a small search
  summary per lead, and the event log. Everything in it can be rebuilt from S3.

That split is why a lead can carry unlimited notes, payments and custom fields without
hitting an item-size limit, and why a search never reads a document.
