---
name: zoomgtm-crm
description: Import contacts (leads) into the CRM of a Gigdesk workspace, each with a short "who this is", tags, where to reach them and a prewritten first message, and optionally a task in a vending machine. Report wins on a contact (a success metric from the member's contract, such as a booked meeting or a sale). Use this when the user asks to "import my leads into Gigdesk", "add these contacts to the CRM", "upload my lead list", "report a win", "log a booked meeting / closed deal", or gives you a Gigdesk workspace ID and an agent key.
homepage: https://gigdesk.cc
---

# Gigdesk CRM

Each Gigdesk workspace has a CRM (contact relationship management). A
**contact** is one person or company to sell to. The API calls a contact a
**lead**. The first member who adds a contact holds it, and the program
refuses the same contact from anybody else (deduplication).

A contact and a task are separate. A contact has no task, unless you ask for
one with `task`. A task is in one of the workspace's vending machines on Dollar
Platoon. A new task made from a contact has a page that shows:

1. who the contact is (a short explainer),
2. an "Open lead" link to their profile or listing,
3. one collapsed section per channel (email, LinkedIn, X, WhatsApp, …), with
   the address, the prewritten message and copy buttons.

Your job is usually: take the user's list of contacts, write a good **first
message** for each one, and import them.

## What you need from the user

- **The agent key.** In the Gigdesk portal: open the workspace, click the
  **Contacts** tab, click **Import**, then **Make a key for my agent**. The key
  starts with `zg_` and works for 90 days. The modal also shows the workspace ID.
- **The workspace ID.** It starts with `workspace_` (an older `ws_` or `offer_` ID still works). It is in the portal URL:
  `/portal/app/ws/<workspace_id>/contacts`.
- **A task or no task.** Ask the user if each contact must also be a task in a
  vending machine. If yes, get the machine's `gigId` from `GET /crm`
  (`machines`).

Never print the key back in full, and never put it in a file that the user
did not ask for.

The agent key works only for the CRM routes on this page (and `GET /me`). It
cannot change the account, make other keys or move money.

## The API

Base URL: `https://qctq6ovfmh.execute-api.us-east-1.amazonaws.com/prod`
(the value of `window.ZGTM_API` in https://gigdesk.cc/assets/portal.config.js).

Every call sends:

```
Authorization: Bearer zg_...
Content-Type: application/json
```

In the examples, `ZGTM_API` is the base URL, `ZGTM_KEY` is the agent key, and
`C` is `$ZGTM_API/app/workspaces/<workspace_id>/crm`.

Start with one read, to check the key and the workspace ID:

```bash
curl -s "$C" -H "Authorization: Bearer $ZGTM_KEY"
```

### Errors

Every error is JSON: `{ "error": "what is wrong, in words" }`, sometimes with
more fields.

| Status | Meaning | What to do |
|---|---|---|
| `400` | The request is wrong. The `error` names the field. | Fix it. Do not send the same request again. |
| `401` | The key is missing, expired or revoked. | Ask the user for a new agent key. |
| `403` | This key may not do that (for example, a member who gives a contact to somebody else). | Tell the user. |
| `404` | No such workspace, contact or route, or the user may not see it. | Check the IDs. |
| `409` | The contact is already held (`duplicate`). | Report it to the user. Do not retry. |
| `429` | Too many requests. | Wait a few seconds, then send it again. |
| `500`, `502`, `503` | A failure on our side or on Dollar Platoon. It has a `requestId`. | Wait, then send it again, at most 3 times. Then tell the user, with the `requestId`. |

A `500` on an import or a win is safe to send again (see below).

### Import contacts — `POST /app/workspaces/{workspace_id}/crm/import`

Send 1 to 25 leads in one call. For a longer list, send more calls, one
after the other (not at the same time). A call with more than 25 leads gets a
`400`.

```json
{
  "leads": [
    {
      "name": "Jane Doe",
      "company": "Acme Inc.",
      "email": "jane@acme.com",
      "phone": "+1 555 010 0000",
      "website": "acme.com",
      "tags": ["saas", "warm"],
      "url": "https://www.linkedin.com/in/janedoe",
      "notes": "Head of growth at Acme (40 people, B2B SaaS). Posted last week that their outbound is stalling.",
      "message": "Hi Jane, saw your post about outbound stalling…",
      "subject": "Your outbound post",
      "channels": [
        { "ch": "linkedin", "to": "https://www.linkedin.com/in/janedoe" },
        { "ch": "email", "to": "jane@acme.com", "subject": "Your outbound post" },
        { "ch": "x", "to": "@janedoe", "message": "A shorter version for X…" }
      ],
      "value": 1200,
      "task": { "gigId": "GIG_…" }
    }
  ]
}
```

| Field | Meaning |
|---|---|
| `name`, `company` | Text. One of the two is required. |
| `email`, `phone`, `website` | Used for deduplication. Give at least one that the workspace matches on (by default: email, website domain or phone; `GET /crm` gives `settings.match`). |
| `tags` | Optional. A list of short labels, such as `["saas", "warm"]` (or one text, separated by commas). Tags are made lowercase, with spaces changed to `-`. 20 at most. Use a tag for the deal step too, such as `contacted`, `won` or `lost` (there is no stage field). The CRM sets `tasked`, `proven` and `task_<id>` by itself and ignores them in what you send. |
| `url` | The contact's profile or listing. A task shows it as "Open lead". |
| `notes` | Who the contact is, in 1 to 3 sentences. It goes at the top of a task. |
| `message` | The first message. Each channel uses it unless the channel has its own `message`. |
| `subject` | The email subject. |
| `channels` | Where to reach them, 10 at most: `ch` is one of `email`, `linkedin`, `x`, `instagram`, `facebook`, `tiktok`, `whatsapp`, `sms`, `phone`, `website`, `other` (any other value becomes `other`). `to` is the address, the handle or the URL. With no `channels`, the task uses `email`, `url`, `phone` (as WhatsApp) and `website`. |
| `value` | Optional. The expected deal value in USD, a number of 0 or more. |
| `body` | Optional. `{ "format": "text" or "html", "content": "…" }` replaces the default task page. Leave it out to get the default page. |
| `task` | Optional. Leave it out for a contact with no task. `{ "gigId": "…" }` makes a new task in that vending machine. `{ "gigId": "…", "taskId": "…" }` links a task that is already there. A `gigId` that is not in `machines` makes the row fail with `400`. When Dollar Platoon fails to make the task, the contact is still saved, and the row has `taskError`. |

The workspace owner may add `"holder": "member@example.com"` to give the contacts
to an active member of the program.

The answer is `200`, with one result for each lead:

```json
{
  "results": [
    { "i": 0, "ok": true, "lead": { "id": "lead_…", "name": "Jane Doe", … } },
    { "i": 1, "ok": false, "status": 409,
      "error": "this lead is already registered in this program (it matched on email)",
      "duplicate": { "field": "email", "mine": false, "since": "…", "until": "…" } },
    { "i": 2, "ok": false, "status": 400, "error": "that email does not look right" }
  ],
  "done": 3,
  "total": 3
}
```

- `i` is the position in the list you sent.
- `status` on a failed row: `400` fix the row, `409` a duplicate (report it,
  do not retry), `500` send that row again.
- A duplicate with `"mine": true` is a contact that this user already holds.
  It is not a failure: an import that you send again shows these rows as
  `mine`.
- When `done` is less than `total`, the call ran out of time: send the leads
  from position `done` again.
- An `ok` row can have `taskError`: the contact is saved, but the task was not
  made. Tell the user.

### Read and change contacts

| Call | What it does |
|---|---|
| `GET /app/workspaces/{workspace_id}/crm` | The CRM: the dedupe rules (`settings`), the vending machines you may use (`machines`) and the contacts you can see (`leads`). A member sees only their own contacts. |
| `GET /app/workspaces/{workspace_id}/crm/leads/{lead_id}` | One contact, with its task, its notes and its proofs. |
| `POST /app/workspaces/{workspace_id}/crm/leads` | Add one contact (the same fields as one row above). `201` with `{ lead }`. `409` with `duplicate` when it is taken. |
| `PATCH /app/workspaces/{workspace_id}/crm/leads/{lead_id}` | Change a contact: send only the fields to change. `tags` replaces the full list. `task` makes or links a task; `"task": null` unlinks it (the task stays on Dollar Platoon). |
| `DELETE /app/workspaces/{workspace_id}/crm/leads/{lead_id}` | Remove the contact for good. Its dedupe keys are free again. |
| `POST /app/workspaces/{workspace_id}/crm/leads/{lead_id}/notes` | Add a note: `{ "body": "Sent the LinkedIn message." }` |
| `POST /app/workspaces/{workspace_id}/crm/preview` | The task page that these fields make, as `{ html }`. Use it to check a page before you import. |
| `GET /app/workspaces/{workspace_id}/crm/machines/{gigId}/tasks` | The tasks in a vending machine that you may link to a contact. |
| `POST /app/workspaces/{workspace_id}/crm/queue` | Refill a vending machine: `{ "tag": "warm", "gigId": "…" }`. Every contact with the tag and no task gets a new task there. Answers `{ done, failed, skipped, remaining }`; send it again while `remaining` is above 0. |
| `POST /app/workspaces/{workspace_id}/crm/unqueue` | The reverse: `{ "tag": "warm", "gigId": "…" }`. Only unfinished tasks of the contacts with the tag leave the machine: a task with a proof in review or approved stays. The contacts stay. |

A contact ID starts with `lead_`. The portal can show it as `contact_…`; the
API takes both.

Every task made from a contact is the standard task page, with a hidden
`<input name="agent_data">` that holds the same contact as JSON:
`{ "type": "zoomgtm_contact", "version": 1, "offer", "contact_id", "contact": { name, company, email, phone, website, url, tags, who }, "outreach": [ { channel, to, href, subject, message } ], "proof" }`.

### Archive a contact

Add the tag `archived` with `PATCH …/crm/leads/{lead_id}` (send the full
`tags` list). The Contacts list hides archived contacts by default. Remove the
tag to unarchive. `DELETE` removes the contact for good.

## Report a win

A **win** says: "this contact hit a success metric, and this member gets
the credit". The success metrics (`events`) are the steps of the contract
that the member signed, such as `Meeting booked` or `Sale`. A win is a
record. It does not pay by itself: the workspace owner pays it.

The owner may credit any active member. A member may credit only
themselves. Both may credit somebody who is not a member with
`"member": "unofficial"`.

### 1. Get the events and the members — `GET /app/workspaces/{workspace_id}/crm/leads/{lead_id}/wins`

Add `?member=<user id, email or @username>` to get the events of that
member's contract (`400` when that person is not in `members`). With no
`member`, it gives the events of the member who holds the contact.

```bash
curl -s "$C/leads/lead_muxydxfb56f8aa99/wins" -H "Authorization: Bearer $ZGTM_KEY"
```

```json
{
  "wins": [],
  "events": [
    { "id": "event_meeting_booked", "name": "Meeting booked", "pay": 50 },
    { "id": "event_sale", "name": "Sale", "pay": 500, "sale": true, "clearDays": 30 },
    { "id": "event_leads", "name": "Leads", "pay": 0 }
  ],
  "contract": { "slug": "default", "agreementId": "agreement_…", "from": "agreement" },
  "members": [
    { "userId": "user_…", "username": "janedoe", "name": "Jane Doe", "email": "jane@example.com", "holder": true }
  ],
  "defaultMember": "user_…",
  "member": "user_…",
  "mentions": [
    { "kind": "user", "uid": "user_…", "username": "dana", "name": "Dana", "owner": true },
    { "kind": "agent", "uid": "agent_coach", "username": "coachagent", "name": "AI sales coach" }
  ]
}
```

### 2. Report it — `POST /app/workspaces/{workspace_id}/crm/leads/{lead_id}/wins`

```bash
curl -s -X POST "$C/leads/lead_muxydxfb56f8aa99/wins" \
  -H "Authorization: Bearer $ZGTM_KEY" -H "Content-Type: application/json" \
  -d '{
    "event": "event_meeting_booked",
    "member": "@janedoe",
    "proof": "https://calendly.com/acme/intro/2026-10-09",
    "notes": "30 minute intro with their head of growth. They want a proposal by Friday.",
    "tags": ["inbound", "q4"],
    "notify": { "url": "https://calendly.com/acme/intro/2026-10-09", "mentions": ["@dana", "@coachagent"] }
  }'
```

| Field | Meaning |
|---|---|
| `event` | Required. An `id` (or the exact `name`) from `events`. Or `"custom"`, with `customEvent`. |
| `customEvent` | The event in words, when `event` is `"custom"`. Example: `"Podcast interview booked"`. |
| `member` | Who gets the credit: a user id, an email or an `@username` from `members`. Leave it out for the member who holds the contact. Or `"unofficial"`, with `unofficialMember`. |
| `unofficialMember` | A name, when `member` is `"unofficial"`. Example: `"Sam Lee (agency partner)"`. |
| `proof` | Optional. A link or a reference: a booking link, a call id, an invoice number. |
| `notes` | Optional. What happened. |
| `tags` | Optional. Tags on the win. |
| `notify.url` | Optional. The link that the notification opens (an `https://` URL). Default: the contact's page in the portal. |
| `notify.mentions` | Optional. Up to 10 `@username`s from `mentions` (people and agents). A person gets a notification. An agent reads the win in the contact's notes. The owner and the member who gets the credit always get a notification. |
| `amount` | Optional. The dollar value of the win, a number of 0 or more. Default: the `pay` of the event. |

The answer is `201` with `{ win, wins }`. The win also goes in the member's
Wins History, and in the contact's notes, so the agents know it.

A `400` names the problem. A wrong `event` lists the events that are good.

**Retries are safe.** The same `event`, for the same `member`, with the same
`proof`, in 10 minutes, is the same win: the answer is `200` with
`"repeated": true` and the win that is already there. A second real win of the
same kind (for example, a second sale) needs a different `proof`.

Do not report a win two times on purpose. Before you report, read `wins` from
step 1 and check that the same event for the same member is not there.

### Other win calls

| Call | What it does |
|---|---|
| `GET /app/workspaces/{workspace_id}/crm/wins` | Every win on the contacts you can see, newest first. Each one has its `contact`. |
| `DELETE /app/workspaces/{workspace_id}/crm/leads/{lead_id}/wins/{win_id}` | Remove a win. The owner removes any win. A member removes a win that they reported. |

## Pay a win with a cash envelope

A win does not pay by itself. To pay it, the owner or staff put USDC in a
**cash envelope**: a smart contract on Base holds the USDC until the member
tears the envelope open in the portal. The USDC then goes to the member's
wallet in the workspace. The envelope has a public name, `PAYLOG_<32 hex>`.
When you paste that name in the chat, the chat shows the envelope.

The USDC comes from the caller's own wallet in the workspace. That wallet
must hold the USDC and a little ETH on Base for gas.

### Make one — `POST /app/workspaces/{workspace_id}/payouts` (the owner or staff)

```bash
curl -s -X POST "$ZGTM_API/app/workspaces/<workspace_id>/payouts" \
  -H "Authorization: Bearer $ZGTM_KEY" -H "Content-Type: application/json" \
  -d '{"member": "@dana", "amount": "25", "unlockAt": "now", "memo": "The Acme sale", "winId": "win_..."}'
```

| Field | What it is |
|---|---|
| `member` | The member who gets it: an email, a `user_<hex>` ID, or an `@username`. |
| `amount` | USDC, as a string, from `0.01` to `100000`, with at most 6 decimals. |
| `unlockAt` | When the member may open it: an ISO date, a unix time, or `"now"` (the default). |
| `memo`, `winId`, `leadId` | Optional. What it pays for. |
| `expiresAt` | Optional. After this time, the owner or staff may take back an envelope that nobody opened. At least 1 day after `unlockAt`. With no `expiresAt`, the USDC is the member's for ever. |

The answer has `payout` (the envelope), `share` (the `PAYLOG_` name) and
`secret` (`paysecret_<64 hex>`). The answer shows the secret one time only.
Keep the secret private.

### Other envelope calls

| Call | What it does |
|---|---|
| `GET /app/workspaces/{workspace_id}/payouts?member=` | The envelopes. The owner and staff see all of them; a member sees their own. |
| `GET /app/payouts/{paylog}` | One envelope: `status` (`pending`, `held`, `redeemed`, `reclaimed`, `failed`), `unlocked`, `amount`, `php`, `tier`, and the links on Base. |
| `POST /app/payouts/{paylog}/release` | The owner or staff open the lock before `unlockAt`. |
| `POST /app/payouts/{paylog}/redeem` | Open the envelope. The member may send `{}`. Anyone else must send `{"secret": "paysecret_..."}`. The USDC always goes to the member's wallet. |
| `POST /app/payouts/{paylog}/reclaim` | The owner or staff take back the USDC after `expiresAt`. |
| `GET /public/payouts/{paylog}` | No key needed. The amount and the state, for anyone with the name. |

A release, a redeem and a deposit wait for the chain. Each one takes about 2 to
10 seconds.

## Write good first messages

- Write one message for each contact, from what you know about that contact.
  Do not send the same template to everybody.
- Keep it short: 2 to 4 sentences. One clear question at the end.
- Say why you write to this person now (their post, their launch, their job).
- No links in a first message on LinkedIn or X, unless the user asks for it.
- Put the facts that you used in `notes`, so the member who sends the message
  knows the context.

## A full import, step by step

1. `GET /crm`. Check the key (`401` means a new key), read `settings.match`
   and, if the user wants tasks, pick a `gigId` from `machines`.
2. For each contact, write `notes` and a first `message`. Make sure each one
   has a field from `settings.match`.
3. Send the list in calls of 25, one after the other. Send a `500` row, or the
   rows from `done`, again.
4. Tell the user: how many were added, which were duplicates (and who holds
   them, when the answer says), and which rows failed and why.

## CSV

The user can also upload a CSV in the portal (Contacts → Import). The columns are
the field names above. A column named for a channel (`linkedin`, `x`,
`instagram`, `whatsapp`, …) is the address on that channel, and
`<channel>_message` is a message for that channel only. A `tags` column holds
the tags, separated by commas.

A CSV exported from freshleads imports as it is: `name` is the company,
`person_name` the contact, `phones` and `socials` the channels, `description`
(with the category and the place) the "who this is", `email_subject` and
`<channel>_body` the first message of each channel.
