# CRM

> The CRM API behind the SuperCool Zapier app. Contacts, tags, opportunities, tasks, workflows, texts and emails, REST hook triggers, and the Lead API.

Source: https://supercool.com/docs/api/crm

The SuperCool CRM has its own API. The SuperCool app on Zapier is built on it, and your code can call it too: create and update contacts, tag them, open and move opportunities, add tasks, put contacts into workflows, send texts and emails, subscribe to CRM events, and post leads from your own forms.

| | Details |
|---|---|
| Base URL | `https://api.supercool.com/api/v1/crm-app` |
| Auth | `Authorization: Bearer sc_key_…`, plus `X-Workspace-Id` for a team's CRM ([Authentication](#authentication)) |
| Format | JSON requests and responses |
| Errors | `{"detail": …}` with a matching HTTP status ([Errors](#errors)) |
| Rate limits | 300 requests per minute per key on `/zapier/…`, 600 leads per minute per key on `/leads` ([Rate limits](#rate-limits)) |

These endpoints are not under `/v1`. They use the same API keys as the rest of the [SuperCool API](https://supercool.com/docs/api), but their own base URL and error shape. They're also in the [API reference](https://supercool.com/docs/api/reference#crm) and the [OpenAPI spec](https://supercool.com/docs/api/openapi.yaml).

## Authentication {#authentication}

Send one of your dashboard [API keys](https://supercool.com/docs/api/authentication) as a bearer token:

```http
Authorization: Bearer sc_key_9fK2...x7Qa
X-Workspace-Id: 6650a1b2c3d4e5f6a7b8c9d0
```

- **Use a regular key.** An [isolated key](https://supercool.com/docs/api/authentication#isolated) gets `403`.
- **Pick the CRM.** Without `X-Workspace-Id`, requests go to the key owner's personal CRM. To use a team's CRM, send the team id in `X-Workspace-Id`. The key's owner must be a member of that team (any role). The team id is shown on the Zapier card in the CRM's **Settings → Integrations**. The `/zapier/…` endpoints also accept it as `?workspace_id=`, and `POST /leads` as `workspace_id` in the body; the header wins.
- **Everything is done as the key.** Contacts, tags, opportunities and tasks it creates, and the activity it writes on contacts, are attributed to the key (`api_key:<key id>`).

### Test a key {#me}

`GET /zapier/me` checks the key and returns who it belongs to and which CRM it reaches. Zapier calls it when you connect an account and shows `label` as the connection's name.

```bash
curl https://api.supercool.com/api/v1/crm-app/zapier/me \
  -H "Authorization: Bearer $SUPERCOOL_API_KEY" \
  -H "X-Workspace-Id: 6650a1b2c3d4e5f6a7b8c9d0"
```

```json
{
  "ok": true,
  "user_id": "64f1c2a9e4b0a1b2c3d4e5f6",
  "email": "you@example.com",
  "workspace_id": "6650a1b2c3d4e5f6a7b8c9d0",
  "team_name": "Rivera Studio",
  "role": "admin",
  "key": { "id": "pat:6a1b2c3d4e5f", "name": "Zapier", "hint": "sc_key_9fK2…x7Qa" },
  "label": "you@example.com · Rivera Studio"
}
```

`workspace_id` is the team id (`null` for a personal CRM), `team_name` the team's name, and `role` your role in that CRM: `admin` or `member`.

| Status | `detail` | Cause |
|---|---|---|
| `401` | `Invalid API key` | The key is missing, unknown, expired or revoked. The response has a `WWW-Authenticate: Bearer` header. |
| `403` | `An isolated API key cannot use the CRM; use a regular key` | An isolated key. On `POST /leads`: `An isolated API key cannot post leads; use a regular key`. |
| `403` | `The key's owner is not a member of that team` | `X-Workspace-Id` names a team the key's owner doesn't belong to. |

## Errors {#errors}

Errors have one field, `detail`. It's usually a sentence:

```json
{ "detail": "Contact not found" }
```

| Status | When |
|---|---|
| `400` | The input can't be used: no email or phone, an invalid email or phone number, an unknown trigger, a `target_url` that isn't a public URL, an unknown pipeline or stage, a contact that already has an open opportunity in that pipeline, an email address another contact already has, nothing to update. `detail` says which. |
| `401` | The API key is missing, unknown, expired or revoked. |
| `403` | An isolated key, or a team the key's owner isn't a member of. |
| `404` | The contact, opportunity, workflow or subscription isn't in this CRM. |
| `422` | The body doesn't match the endpoint's schema (a required field is missing, or a field has the wrong type), or a text or email was **refused** by the CRM's rules. See below. |
| `429` | Too many requests for this key. See [Rate limits](#rate-limits). |
| `503` | The feature isn't available on this account yet (`detail` names it, for example `Pipelines is not available in this build yet`). |

There is no `409`: conflicts, like a duplicate opportunity or an email address that's already taken, are `400`. An unexpected failure is `500` with `{"detail": "Something went wrong"}`.

A `422` has one of two shapes. A schema error lists the problems:

```json
{ "detail": [{ "type": "missing", "loc": ["body", "title"], "msg": "Field required", "input": {} }] }
```

A refused text or email has a machine-readable `reason` and a sentence you can show people:

```json
{ "detail": { "reason": "no_consent", "message": "Text campaigns go only to contacts with explicit SMS consent." } }
```

See [refusal reasons](#refusals) for every `reason`.

## Rate limits {#rate-limits}

| Limit | Value | Applies to |
|---|---|---|
| Zapier endpoints | 300 per minute | Each API key, every `/zapier/…` request |
| Lead API | 600 per minute | Each API key, `POST /leads` |

Over the limit, the request gets `429` with `{"detail": "Too many Zapier requests per minute for this key"}` (or `Too many leads per minute for this key`). The window is a rolling 60 seconds: wait, then retry. Texts and emails also have daily sending limits; going over them is a [refusal](#refusals), not a `429`.

## Contacts in requests {#contact-lookup}

Most actions work on one existing contact. Name it with any of these fields:

| Field | Meaning |
|---|---|
| `contact_id` | The contact's id (`ct_…`). |
| `contact` | One field that takes an id, an email address or a phone number. |
| `email` | The contact's email address. |
| `phone` | The contact's phone number. |

The contact is found by `contact_id` (or `contact`) as an id, then as an email address (if it contains `@`) or a phone number, then by `email`, then by `phone`. If none matches, the request gets `404` with `Contact not found`.

## The contact object {#contact}

Contact actions return the contact. The main fields:

| Field | Type | Description |
|---|---|---|
| `id` | string | The contact's id (`ct_…`). |
| `email` | string or null | Email address. |
| `phone` | string or null | Phone number in E.164 (`+15555550123`). |
| `first_name`, `last_name`, `company` | string or null | Name and company. |
| `address`, `city`, `state`, `postal_code`, `country`, `website` | string or null | Address and website, when set. |
| `display_name` | string | The full name, else the email, else the phone. |
| `tags` | string[] | Tag ids (`tag_…`). `GET /zapier/tags` maps them to names. |
| `fields` | object | Custom field values by key. `GET /zapier/fields` lists the keys. |
| `source` | string | Where the contact came from: `zapier` for contacts this API created, `lead_api` for the Lead API, and others such as `form`, `import` or `manual`. |
| `owner_user_id` | string or null | The team member who owns the contact. |
| `status` | string | `active`, `unsubscribed`, `bounced` or `blocked`. |
| `dnd` | object | Do Not Disturb: `email`, `sms`, `calls`, `all` (booleans), `reason`, `at`. |
| `consent` | object | `email` and `call`, each `{status, source, at}`. Email consent `explicit` is what [Send Email](#consent) needs. |
| `created_at`, `updated_at`, `last_activity_at` | string | ISO 8601 UTC. |

Other fields may be present, such as `attribution` (first and last touch from forms and the Lead API).

## Contacts

### Create or update a contact {#create-or-update-contact}

`POST /zapier/contacts` finds the contact by `email`, then by `phone`, and updates it, or creates it if there is none. A new contact's `source` is `zapier`. Only the non-empty fields you send are written; nothing is blanked.

| Field | Type | Description |
|---|---|---|
| `email` | string | Email address. `email` or `phone` is required. |
| `phone` | string | Phone number. US numbers without a country code get `+1`. |
| `first_name`, `last_name`, `company`, `address`, `city`, `state`, `postal_code`, `country`, `website` | string | Contact details. |
| `owner_user_id` | string | A team member's user id (`GET /zapier/team`). |
| `tags` | string[] or string | Tag names to add (a list, or one comma-separated string; up to 50). Missing tags are created. |
| `fields` | object | Custom field values by key. Unknown keys are ignored. |
| `consent` | boolean | `true` records **explicit email consent** (source `zapier`). Otherwise email consent is recorded as `imported_unknown`, unless the contact's is already explicit. |
| `sms_consent` | boolean | `true` records **explicit SMS consent** (source `zapier`), if the contact has a phone number. |

Explicit consent is never downgraded. Send `consent` and `sms_consent` only when the person actually agreed.

```bash
curl https://api.supercool.com/api/v1/crm-app/zapier/contacts \
  -H "Authorization: Bearer $SUPERCOOL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "jane@example.com",
    "first_name": "Jane",
    "last_name": "Doe",
    "phone": "(555) 555-0123",
    "tags": "lead, webinar",
    "consent": true
  }'
```

Returns `200` with the [contact](#contact) at the top level, plus `ok` and `created` (`true` if the contact is new):

```json
{
  "ok": true,
  "created": true,
  "id": "ct_8f2a6c1e9b3d5f7a0c4e",
  "email": "jane@example.com",
  "phone": "+15555550123",
  "first_name": "Jane",
  "last_name": "Doe",
  "display_name": "Jane Doe",
  "tags": ["tag_1a2b3c4d5e6f7a8b9c0d", "tag_0d9c8b7a6f5e4d3c2b1a"],
  "fields": {},
  "source": "zapier",
  "status": "active",
  "dnd": { "email": false, "sms": false, "calls": false, "all": false, "reason": null, "at": null },
  "consent": {
    "email": { "status": "explicit", "source": "zapier", "at": "2026-10-23T14:02:11.402000Z" },
    "call": { "status": "none", "source": null, "at": null }
  },
  "created_at": "2026-10-23T14:02:11.402000Z",
  "updated_at": "2026-10-23T14:02:11.402000Z"
}
```

Errors: `400` (`email or phone is required`, `Email address is not valid`, `Phone number is not valid`, a custom field value of the wrong type), `422` (wrong field types).

### Update a contact {#update-contact}

`POST /zapier/contacts/update` changes an existing contact. Name the contact with the [lookup fields](#contact-lookup), and send the fields to change: the same contact fields as [create or update](#create-or-update-contact). Only the fields you send change. `tags` are added to the contact's tags. Custom field keys must exist. `consent` and `sms_consent` are ignored here.

An `email` or `phone` that you used to find the contact, and that it already has, is not a change.

```bash
curl https://api.supercool.com/api/v1/crm-app/zapier/contacts/update \
  -H "Authorization: Bearer $SUPERCOOL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "contact": "jane@example.com", "company": "Example Co", "tags": ["customer"] }'
```

Returns `200` with `ok: true` and the updated [contact](#contact) at the top level.

Errors: `400` (`Nothing to update`, `Another contact already has that email`, `Unknown custom field '…'`, invalid email or phone), `404` (`Contact not found`).

### Find a contact {#find-contact}

`GET /zapier/contacts/find` looks a contact up by `id`, `contact`, `email` or `phone` (query parameters, same order as the [lookup fields](#contact-lookup)). It never returns `404`: no match is an empty list.

```bash
curl "https://api.supercool.com/api/v1/crm-app/zapier/contacts/find?email=jane@example.com" \
  -H "Authorization: Bearer $SUPERCOOL_API_KEY"
```

```json
{ "items": [{ "id": "ct_8f2a6c1e9b3d5f7a0c4e", "email": "jane@example.com", "first_name": "Jane", "last_name": "Doe" }] }
```

Each item is a full [contact](#contact) (shortened here).

### Add tags {#add-tags}

`POST /zapier/contacts/tags` adds tags to a contact, by name. Missing tags are created. Name the contact with the [lookup fields](#contact-lookup).

| Field | Type | Description |
|---|---|---|
| `tags` | string[] or string | Required. Tag names (a list or one comma-separated string; up to 50, 80 characters each). |

```bash
curl https://api.supercool.com/api/v1/crm-app/zapier/contacts/tags \
  -H "Authorization: Bearer $SUPERCOOL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "contact_id": "ct_8f2a6c1e9b3d5f7a0c4e", "tags": ["vip", "webinar"] }'
```

Returns `200` with `ok: true` and the [contact](#contact). Adding tags the contact already has changes nothing. Errors: `400` (`tags is required`), `404` (`Contact not found`).

### Remove tags {#remove-tags}

`POST /zapier/contacts/tags/remove` takes tags off a contact. `tags` takes names (matched ignoring case and extra spaces) or tag ids (`tag_…`). Tags the contact doesn't have are skipped.

```bash
curl https://api.supercool.com/api/v1/crm-app/zapier/contacts/tags/remove \
  -H "Authorization: Bearer $SUPERCOOL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "email": "jane@example.com", "tags": "webinar" }'
```

Returns `200` with `ok: true` and the [contact](#contact). Errors: `400` (`tags is required`), `404` (`Contact not found`).

## Opportunities

An opportunity is a potential sale: one contact, in one stage of one pipeline, with a status of `open`, `won`, `lost` or `abandoned`.

### Create an opportunity {#create-opportunity}

`POST /zapier/opportunities` opens an opportunity for a contact. Name the contact with the [lookup fields](#contact-lookup).

| Field | Type | Description |
|---|---|---|
| `pipeline_id` or `pipeline` | string | The pipeline, by id (`pl_…`) or name. Neither: the default pipeline. |
| `stage_id` or `stage` | string | The stage, by id (`st_…`) or name (any case). Neither: the pipeline's first stage. |
| `name` | string | Defaults to the contact's name. |
| `value` | number or string | A **number** is in minor units (cents): `150000` is 1,500.00. A **string** is in major units: `"1,500.00"` is 1,500.00. |
| `currency` | string | 3-letter code. Defaults to the CRM's currency. |
| `owner_user_id` | string | A team member's user id. Ignored if they're not on the team. |
| `tags` | string[] or string | Tag names. Missing tags are created. |
| `fields` | object | Opportunity custom field values by key. |
| `expected_close_at` | string | A date, `YYYY-MM-DD`. |
| `allow_duplicate` | boolean | A contact can have one open opportunity per pipeline. `true` allows another. |

```bash
curl https://api.supercool.com/api/v1/crm-app/zapier/opportunities \
  -H "Authorization: Bearer $SUPERCOOL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "email": "jane@example.com", "pipeline": "Sales", "stage": "Qualified", "value": "1500" }'
```

Returns `201` with the opportunity:

```json
{
  "id": "opp_7d8e9f0a1b2c3d4e5f6a",
  "pipeline_id": "pl_2b3c4d5e6f7a8b9c0d1e",
  "stage_id": "st_5e6f7a8b9c0d1e2f3a4b",
  "contact_id": "ct_8f2a6c1e9b3d5f7a0c4e",
  "name": "Jane Doe",
  "status": "open",
  "value": 150000,
  "currency": "usd",
  "source": "zapier",
  "owner_user_id": null,
  "tags": [],
  "fields": {},
  "expected_close_at": null,
  "created_at": "2026-10-23T14:05:00.118000Z",
  "updated_at": "2026-10-23T14:05:00.118000Z"
}
```

Other fields, such as `followers`, `lost_reason`, `stage_entered_at` and `days_in_stage`, are included too. `value` is always in minor units in responses. Responses from these endpoints don't fill in `pipeline_name` and `stage_name`; use `GET /zapier/pipelines` to name the ids.

Errors: `400` (`Pipeline not found`, `No stage '…' in pipeline '…'`, `Jane Doe already has an open opportunity in Sales`, `Value must be a number`), `404` (`Contact not found`), `503` (pipelines aren't available on the account).

### Move an opportunity {#move-opportunity}

`POST /zapier/opportunities/{opportunity_id}/move` moves an opportunity to another stage, another pipeline, or another status. Send at least one of:

| Field | Type | Description |
|---|---|---|
| `stage_id` or `stage` | string | The new stage, by id or name, in the opportunity's pipeline (or in the new pipeline). |
| `pipeline_id` or `pipeline` | string | Move it to this pipeline (by id or name). Without a stage, it goes to that pipeline's first stage. |
| `status` | string | `open`, `won`, `lost` or `abandoned`. |
| `lost_reason` | string | With `status: "lost"`. |

```bash
curl https://api.supercool.com/api/v1/crm-app/zapier/opportunities/opp_7d8e9f0a1b2c3d4e5f6a/move \
  -H "Authorization: Bearer $SUPERCOOL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "stage": "Proposal sent" }'
```

Returns `200` with the opportunity. Errors: `400` (`stage, pipeline or status is required`, an unknown stage or pipeline, `Status must be open, won, lost or abandoned`, the contact already has an open opportunity in the new pipeline), `404` (`Opportunity not found`), `503`.

## Tasks

### Create a task {#create-task}

`POST /zapier/tasks` adds a task, optionally on a contact.

| Field | Type | Description |
|---|---|---|
| `title` | string | Required. Longer titles are cut to 200 characters. |
| `description` | string | Details. |
| `contact_id`, `contact`, `email`, `phone` | string | Optional. The task's contact, found like the [lookup fields](#contact-lookup). If you send one and it doesn't match, the request gets `404`. |
| `opportunity_id` | string | Link the task to an opportunity. |
| `assigned_to` | string | A team member's user id (`GET /zapier/team`). |
| `due_at` | string | An exact ISO 8601 time. Wins over `due_date`. |
| `due_date` | string | `YYYY-MM-DD`. Without `due_time`, the task is due all day. |
| `due_time` | string | `HH:MM`, with `due_date`. |
| `timezone` | string | The IANA zone `due_date` and `due_time` are in, like `America/Chicago`. Defaults to the assignee's zone, else the CRM's, else UTC. |

```bash
curl https://api.supercool.com/api/v1/crm-app/zapier/tasks \
  -H "Authorization: Bearer $SUPERCOOL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "title": "Call Jane", "email": "jane@example.com", "due_date": "2026-10-27", "due_time": "10:00", "timezone": "America/Chicago" }'
```

Returns `201` with the task: `id` (`tsk_…`), `title`, `description_html`, `contact_id`, `opportunity_id`, `assigned_to`, `due_at`, `due_date`, `due_time`, `all_day`, `timezone`, `status` (`open`), `origin` (`{"kind": "manual", "reason": "Created by Zapier"}`), `created_at` and `updated_at`.

Errors: `400` (`title is required`, `due_date must be YYYY-MM-DD`, `due_time must be HH:MM`, `unknown time zone '…'`), `404` (`Contact not found`), `422` (no `title` field), `503`.

## Workflows

### Add a contact to a workflow {#enroll}

`POST /zapier/workflows/{workflow_id}/enroll` starts a published workflow for a contact. Name the contact with the [lookup fields](#contact-lookup). The workflow's own re-entry rules apply.

```bash
curl https://api.supercool.com/api/v1/crm-app/zapier/workflows/wf_0a1b2c3d4e5f6a7b8c9d/enroll \
  -H "Authorization: Bearer $SUPERCOOL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "email": "jane@example.com" }'
```

```json
{
  "ok": true,
  "reason": "enrolled",
  "enrollment_id": "wfe_6f7a8b9c0d1e2f3a4b5c",
  "workflow_id": "wf_0a1b2c3d4e5f6a7b8c9d",
  "workflow_name": "New lead follow-up",
  "contact_id": "ct_8f2a6c1e9b3d5f7a0c4e"
}
```

Not being enrolled is not an error: the response is `200` with `ok: false` and the `reason`:

| `reason` | Meaning |
|---|---|
| `enrolled` | The contact is in the workflow (`ok: true`). |
| `already_active` | The contact is already going through this workflow. |
| `no_reentry` | The contact went through it before, and the workflow doesn't allow re-entry. |
| `not_published` | The workflow isn't published. |

Errors: `404` (`Workflow not found`, `Contact not found`), `503`.

## Texts and emails {#messages}

### Consent rules {#consent}

**Send Text and Send Email are marketing sends.** They follow the same rules as a campaign, so they only reach people who agreed to hear from you:

- **Email needs explicit email consent.** The contact's email consent must be `explicit`. The API sets it with `consent: true` on [create or update](#create-or-update-contact) or the [Lead API](#leads). Contacts created without it get `imported_unknown`, which isn't enough.
- **Texts need explicit SMS consent.** The contact's SMS consent must be `explicit`. The API sets it with `sms_consent: true`, only for a contact with a phone number. Nothing else the API does grants SMS consent.
- **Opt-outs always win.** A contact on Do Not Disturb, an address that unsubscribed, bounced or complained, and a number that replied STOP are never sent to.
- **Texts respect quiet hours.** Texts go out only during the CRM's texting hours (8:00 to 21:00 by default) in the recipient's time zone, worked out from the number. Outside them, the send is refused with `quiet_hours`.
- **Daily limits apply**, and so do pauses on the account (for example after a high bounce rate).

A refused send gets `422` with a `reason` (see [below](#refusals)). **Nothing is sent and nothing is queued**: fix the cause, or try again later.

Texts and emails use credits. The response's `credits` is what the send cost.

### Send a text {#send-text}

`POST /zapier/messages/sms` texts a contact from the CRM's phone number. Name the contact with the [lookup fields](#contact-lookup).

| Field | Type | Description |
|---|---|---|
| `text` (or `message`) | string | Required. The text. Longer than 1,600 characters is cut; more than 10 segments, including the opt-out footer the CRM adds when it's needed, is refused with `too_long`. |
| `media` | string[] | Optional. One image file already stored in SuperCool (its storage key), up to 1 MB, to send as MMS. Anything else, including a public URL, is refused with `media`. |

```bash
curl https://api.supercool.com/api/v1/crm-app/zapier/messages/sms \
  -H "Authorization: Bearer $SUPERCOOL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "contact": "+15555550123", "text": "Hi Jane, your order shipped today." }'
```

```json
{
  "ok": true,
  "contact_id": "ct_8f2a6c1e9b3d5f7a0c4e",
  "id": "cmsg_9b2c4d6e8f0a1b3c5d7e",
  "status": "accepted",
  "kind": "sms",
  "conversation_id": "cv_3c4d5e6f7a8b9c0d1e2f",
  "segments": 1,
  "credits": 10,
  "error": null,
  "created_at": "2026-10-23T14:10:02.551000Z"
}
```

`status` is `accepted` (handed to the carrier), `uncertain` (the carrier's answer didn't come back), `failed`, or `refused` (the carrier says the number opted out). Errors: `400` (`text is required`), `404` (`Contact not found`), `422` (refused), `503`.

### Send an email {#send-email}

`POST /zapier/messages/email` emails a contact from the CRM's default sender. Name the contact with the [lookup fields](#contact-lookup).

| Field | Type | Description |
|---|---|---|
| `subject` | string | Required. Longer subjects are cut to 300 characters. |
| `html` (or `body`) | string | The HTML body. `html`, `body` or `text` is required. |
| `text` | string | A plain-text body. Without `html`, it's also turned into the HTML body. |

```bash
curl https://api.supercool.com/api/v1/crm-app/zapier/messages/email \
  -H "Authorization: Bearer $SUPERCOOL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "email": "jane@example.com", "subject": "Your order shipped", "text": "Hi Jane,\nyour order shipped today." }'
```

Returns `201` with `ok`, `contact_id` and the message (`id`, `status`, `kind`, `conversation_id`, `credits`, `error`, `created_at`). `status` is `accepted`, `uncertain` or `failed`. Errors: `400` (`subject and html (or text) are required`), `404` (`Contact not found`), `422` (refused), `503`.

### Refusal reasons {#refusals}

| `reason` | Text | Email | Meaning |
|---|---|---|---|
| `no_consent` | ✓ | ✓ | No explicit consent for this channel. See [consent rules](#consent). |
| `dnd` | ✓ | ✓ | The contact is on Do Not Disturb. |
| `suppressed_owner` | ✓ | ✓ | The number replied STOP, or the address unsubscribed from your emails. |
| `suppressed_global` | ✓ | ✓ | The number or address can't receive messages (a permanent carrier rejection, a bounce or a complaint). |
| `paused` | ✓ | ✓ | Sending is paused on the account. |
| `cap` | ✓ | ✓ | Today's sending limit is reached. |
| `credits` | ✓ | ✓ | Not enough credits. |
| `no_phone` | ✓ | | The contact has no phone number. |
| `landline` | ✓ | | The number is a landline. |
| `quiet_hours` | ✓ | | Outside texting hours for the recipient. |
| `no_number` | ✓ | | The CRM has no phone number to text from. |
| `unregistered` | ✓ | | The number's carrier registration isn't approved yet. |
| `suspended`, `releasing`, `unpaid` | ✓ | | The number is suspended, being released, or its monthly charge didn't go through. |
| `kill_switch` | ✓ | | Texting is paused platform-wide. |
| `too_long` | ✓ | | The text is empty or longer than 10 segments. |
| `media` | ✓ | | The attachment isn't usable: one image file stored in SuperCool, up to 1 MB. |
| `no_email` | | ✓ | The contact has no email address. |
| `no_channel` | | ✓ | Email isn't set up yet (no From name and address). |
| `unsubscribed`, `bounced` | | ✓ | The contact unsubscribed, or their address bounced. |

## Lists for dropdowns {#lists}

These return what the Zapier app shows in its dropdowns, as `{"items": [...]}`:

| Endpoint | Items |
|---|---|
| `GET /zapier/pipelines` | `{id, name, stages: [{id, name, position}]}`, stages in order. `503` if pipelines aren't available. |
| `GET /zapier/workflows` | `{id, name, status}`, published workflows only. `503` if workflows aren't available. |
| `GET /zapier/tags` | `{id, name}` |
| `GET /zapier/team` | `{id, name, email, role}`: the team's members (or just you, in a personal CRM). |
| `GET /zapier/fields` | `{key, label, type}`: the contact custom fields. |
| `GET /zapier/triggers` | `{key, label, noun, types}`: the [triggers](#trigger-catalogue) and the CRM event types each fires on. |

```bash
curl https://api.supercool.com/api/v1/crm-app/zapier/pipelines \
  -H "Authorization: Bearer $SUPERCOOL_API_KEY"
```

```json
{
  "items": [{
    "id": "pl_2b3c4d5e6f7a8b9c0d1e",
    "name": "Sales",
    "stages": [
      { "id": "st_4d5e6f7a8b9c0d1e2f3a", "name": "New lead", "position": 0 },
      { "id": "st_5e6f7a8b9c0d1e2f3a4b", "name": "Qualified", "position": 1 }
    ]
  }]
}
```

## Triggers (REST hooks) {#triggers}

To hear about CRM events, subscribe a URL to a trigger. SuperCool POSTs each matching event to it, as it happens. This is how Zapier's instant triggers work, and it works the same for any URL.

### Trigger catalogue {#trigger-catalogue}

| Trigger key | Fires when | `data` has |
|---|---|---|
| `new_contact` | A contact is created (`contact.created`). | `source`, `tags` (ids), `owner_user_id` |
| `contact_updated` | A contact's fields change (`contact.updated`). | `changed` (field names), `source` |
| `tag_added` | A tag is added to a contact (`contact.tag_added`). | `tag_id`, `tag_name` |
| `tag_removed` | A tag is removed from a contact (`contact.tag_removed`). | `tag_id`, `tag_name` |
| `form_submitted` | A CRM form is submitted, or a lead is posted to the Lead API (`form.submitted`). | Forms: `form_id`, `form_name`, `source` (`form`), `created`, `fields`, `page_url`, `referrer`, `utm`. Lead API: `source` (`lead_api`), `lead_source`, `key_id`, `created`, `tags`, `fields`. |
| `lead_received` | A Facebook lead form is submitted (`facebook.lead_received`), or a lead is posted to the Lead API. | Facebook: `form_id`, `form_name`, `page_id`, `ad_id`, `adset_id`, `campaign_id`, `leadgen_id`, `is_test`, `is_organic` and the answers. Lead API: as above. |
| `appointment_booked` | An appointment is booked (`appointment.booked`). | `calendar_id`, `calendar_name`, `start`, `end`, `timezone`, `assigned_user_id`, `status`, `source` |
| `appointment_changed` | An appointment is rescheduled, cancelled or confirmed, or marked showed or no-show (`appointment.rescheduled`, `.cancelled`, `.confirmed`, `.showed`, `.no_show`). | As `appointment_booked`. A reschedule adds `previous_start` and `previous_end`. `type` says which change. |
| `opportunity_created` | An opportunity is created (`opportunity.created`). | `name`, `pipeline_id`, `pipeline_name`, `stage_id`, `stage_name`, `status`, `value` (minor units), `currency`, `owner_user_id`, `source`, `tags`, `expected_close_at` |
| `opportunity_stage_changed` | An opportunity moves to another stage (`opportunity.stage_changed`). | As `opportunity_created`, plus `previous_stage_id` and `previous_stage_name` |
| `payment_received` | A payment is received (`payment.received`). | `source` (`calendar`, `site` or `stripe`), `amount` (minor units), `currency`, and details from the source |
| `task_added` | A task is added (`task.added`). | `title`, `due_at`, `due_date`, `all_day`, `assignee_id`, `origin`, `contact_id`, `opportunity_id`, `recurring` |
| `task_completed` | A task is completed (`task.completed`). | As `task_added` |
| `inbound_webhook` | One of the CRM's inbound webhooks receives a request (`webhook.received`). | `webhook_id`, `webhook_name`, `method`, `payload` (the body, flattened), `mapped`, `created` |

A Lead API post fires both `form_submitted` and `lead_received`, and `new_contact` or `contact_updated` as well.

### Subscribe {#subscribe}

`POST /zapier/hooks` subscribes a URL to one trigger.

| Field | Type | Description |
|---|---|---|
| `event` | string | Required. A [trigger key](#trigger-catalogue). |
| `target_url` | string | Required. A public `http` or `https` URL, up to 2,048 characters. Private and local addresses are refused. |

```bash
curl https://api.supercool.com/api/v1/crm-app/zapier/hooks \
  -H "Authorization: Bearer $SUPERCOOL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "event": "new_contact", "target_url": "https://hooks.example.com/supercool/new-contact" }'
```

Returns `201` with the subscription:

```json
{
  "id": "zh_4c1d2e3f4a5b6c7d8e9f",
  "key_id": "pat:6a1b2c3d4e5f",
  "event": "new_contact",
  "label": "New Contact",
  "target_url": "https://hooks.example.com/supercool/new-contact",
  "target_host": "hooks.example.com",
  "key_name": "Zapier",
  "key_hint": "sc_key_9fK2…x7Qa",
  "deliveries": 0,
  "failures": 0,
  "last_delivery_at": null,
  "last_status": null,
  "last_error": null,
  "created_at": "2026-10-23T13:40:00Z",
  "updated_at": "2026-10-23T13:40:00Z"
}
```

- Subscribing the same URL to the same trigger again returns the existing subscription, so retrying a subscribe is safe.
- A CRM can have up to 100 subscriptions. Past that, the request gets `400`.
- Subscriptions belong to the CRM, not to the key: every key that reaches the CRM sees and can remove them, and they're listed on the Zapier card in **Settings → Integrations**, where an admin can remove them too.

Errors: `400` (`Unknown event '…'; one of: …`, `target_url must be a public http(s) URL`, the 100-subscription limit), `422` (a missing field).

### List subscriptions {#list-hooks}

`GET /zapier/hooks` returns the CRM's subscriptions, newest first, as `{"items": [...]}`. `deliveries` and `failures` count delivered and failed events; `last_status` and `last_error` describe the latest attempt.

```bash
curl https://api.supercool.com/api/v1/crm-app/zapier/hooks -H "Authorization: Bearer $SUPERCOOL_API_KEY"
```

### Unsubscribe {#unsubscribe}

`DELETE /zapier/hooks/{hook_id}` removes one subscription. Events still waiting to be delivered to it are dropped.

```bash
curl -X DELETE https://api.supercool.com/api/v1/crm-app/zapier/hooks/zh_4c1d2e3f4a5b6c7d8e9f \
  -H "Authorization: Bearer $SUPERCOOL_API_KEY"
```

```json
{ "ok": true }
```

`DELETE /zapier/hooks` with a JSON body does the same by `id`, or by `target_url` (every subscription to that URL, or only the one for `event` if you send it). It needs `id` or `target_url` (`400` otherwise).

```bash
curl -X DELETE https://api.supercool.com/api/v1/crm-app/zapier/hooks \
  -H "Authorization: Bearer $SUPERCOOL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "target_url": "https://hooks.example.com/supercool/new-contact", "event": "new_contact" }'
```

Both return `404` (`Hook not found`) when nothing matches.

### Sample events {#perform-list}

`GET /zapier/perform-list/{event}` returns up to 3 of the CRM's most recent events for a trigger, newest first, in the same [payload](#payload) a subscription receives. When the CRM has none yet, it returns one made-up sample (`"id": "ev_sample"`) so you can map fields before the first real event. An unknown trigger key gets `400`.

```bash
curl https://api.supercool.com/api/v1/crm-app/zapier/perform-list/new_contact \
  -H "Authorization: Bearer $SUPERCOOL_API_KEY"
```

### Payload {#payload}

Every delivery is a JSON POST of one event:

```json
{
  "id": "ev_3f9a1c2e7b8d4e5f6a7b",
  "trigger": "new_contact",
  "type": "contact.created",
  "label": "Contact created",
  "occurred_at": "2026-10-23T14:02:11.402000Z",
  "contact_id": "ct_8f2a6c1e9b3d5f7a0c4e",
  "contact": {
    "id": "ct_8f2a6c1e9b3d5f7a0c4e",
    "email": "jane@example.com",
    "phone": "+15555550123",
    "first_name": "Jane",
    "last_name": "Doe",
    "full_name": "Jane Doe",
    "company": "Example Co",
    "address": null,
    "city": "Austin",
    "state": "TX",
    "postal_code": "78701",
    "country": "US",
    "website": null,
    "source": "form",
    "owner_user_id": null,
    "fields": {},
    "tag_ids": ["tag_1a2b3c4d5e6f7a8b9c0d"],
    "tags": ["lead"],
    "dnd": { "email": false, "sms": false, "calls": false, "all": false, "reason": null, "at": null },
    "consent": { "email": "explicit", "sms": null },
    "created_at": "2026-10-23T14:02:11.402000Z",
    "updated_at": "2026-10-23T14:02:11.402000Z"
  },
  "data": { "source": "form", "tags": ["tag_1a2b3c4d5e6f7a8b9c0d"], "owner_user_id": null },
  "ref": {},
  "actor": { "kind": "system", "id": null }
}
```

| Field | Description |
|---|---|
| `id` | The event's id (`ev_…`). The same on every retry: use it to drop duplicates. |
| `trigger` | The [trigger key](#trigger-catalogue) the subscription is for. |
| `type`, `label` | The CRM event type and its name. |
| `occurred_at` | When it happened (ISO 8601 UTC). |
| `contact_id`, `contact` | The contact it's about, or `null`. `contact.tags` are names and `tag_ids` their ids; `consent` gives the email and SMS consent status. |
| `data` | What happened. Its fields depend on the trigger: see the [catalogue](#trigger-catalogue). |
| `ref` | Ids of related records, such as `opportunity_id`, `appointment_id`, `task_id` or `form_id`. |
| `actor` | Who did it: `{kind, id}`, where `kind` is `user`, `api_key`, `webhook` or `system`. |

### Delivery {#delivery}

The request carries these headers:

| Header | Value |
|---|---|
| `Content-Type` | `application/json` |
| `User-Agent` | `SuperCool-CRM-Zapier/1.0` |
| `X-SuperCool-Event` | The CRM event type, such as `contact.created`. |
| `X-SuperCool-Delivery` | This delivery's id (`zhd_…`). The same on every retry. |

Deliveries are not signed. Treat the `target_url` as a secret, and use a URL that's hard to guess.

- **Respond with any `2xx` within 10 seconds.** Redirects are not followed.
- **`410 Gone` unsubscribes.** The subscription is removed, with anything still waiting for it. This is how Zapier turns a hook off when a Zap is deleted.
- **Some failures are retried.** A timeout, a network error, `408`, `425`, `429` or any `5xx` is retried after 1 minute, 5 minutes, 15 minutes, 1 hour, 6 hours and 24 hours: up to 7 attempts in all. Then the delivery is marked failed.
- **Other `4xx` (and `3xx`) responses fail at once**, without retries.
- **Delivery is at least once.** Each event gets one delivery per subscription, but that delivery can reach you more than once: for example, when your server handled it but answered after 10 seconds, it's retried. Drop duplicates by `id`. Events can arrive out of order.
- **A revoked key stops its subscriptions.** If the API key that created a subscription is revoked, the subscription is removed the next time an event would go to it.

Failures show on the subscription (`failures`, `last_status`, `last_error`) and on the Zapier card in **Settings → Integrations**.

## Lead API {#leads}

`POST /leads` adds a lead to the CRM from your own form, landing page or backend. It creates the contact, or updates the one with the same email or phone, with source `lead_api`. It's like [create or update](#create-or-update-contact), plus attribution and an optional opportunity.

| Field | Type | Description |
|---|---|---|
| `email` | string | Email address. `email` or `phone` is required. |
| `phone` | string | Phone number. |
| `first_name`, `last_name`, `company`, `address`, `city`, `state`, `postal_code`, `country`, `website` | string | Contact details. |
| `tags` | string[] | Tag names to add (up to 50). Missing tags are created. Must be a list. |
| `fields` | object | Custom field values by key. Unknown keys are ignored. |
| `consent` | boolean | `true` records explicit email consent (source `lead_api`). Otherwise email consent is `imported_unknown`, unless it's already explicit. |
| `sms_consent` | boolean | `true` records explicit SMS consent, if the lead has a phone number. The only way the Lead API grants SMS consent. |
| `source` | string | Your label for where the lead came from, like `"pricing page"`. Kept on the contact's activity and the event as `lead_source`. |
| `attribution` | object | Optional: `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`, `gclid`, `fbclid`, `referrer`, `landing_url`. The first attributed lead becomes the contact's first touch; every one updates the last touch. |
| `opportunity` | object | Optional: add the lead to a pipeline. `pipeline_id` or `pipeline` (name), `stage_id` (default: the first stage), `value` (minor units if a number, major units if a string), `name` (default: the contact's name). If the opportunity can't be created (an unknown pipeline, say), the lead is still saved. |
| `workspace_id` | string | The team id, if you don't send `X-Workspace-Id`. |

```bash
curl https://api.supercool.com/api/v1/crm-app/leads \
  -H "Authorization: Bearer $SUPERCOOL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "jane@example.com",
    "first_name": "Jane",
    "phone": "+15555550123",
    "tags": ["webinar"],
    "consent": true,
    "sms_consent": true,
    "source": "pricing page",
    "attribution": { "utm_source": "google", "utm_campaign": "fall-promo" },
    "opportunity": { "pipeline": "Sales", "value": 50000 }
  }'
```

Returns `201`:

```json
{
  "ok": true,
  "created": true,
  "contact": { "id": "ct_8f2a6c1e9b3d5f7a0c4e", "email": "jane@example.com", "phone": "+15555550123", "first_name": "Jane", "source": "lead_api" }
}
```

`contact` is the full [contact](#contact) (shortened here). Every lead fires the [`form_submitted` and `lead_received` triggers](#trigger-catalogue).

Errors: `400` (`email or phone is required`, an invalid email or phone), `401`, `403`, `422` (wrong field types, like `tags` as a string), `429` (600 leads per minute per key).
