# REST API

> Integrate with Seedly CRM using the REST API for chatbot, automation, and third-party system integrations.

## Table of Contents

1. [System Overview](#1-system-overview)
2. [Authentication](#2-authentication)
3. [Quick Start: Chatbot Integration](#3-quick-start-chatbot-integration)
4. [Conversations API](#4-conversations-api)
5. [Contacts API](#5-contacts-api)
6. [Calendar & Booking API](#6-calendar--booking-api)
7. [Webhooks (Real-Time Events)](#7-webhooks-real-time-events)
8. [Workflow Triggers](#8-workflow-triggers)
9. [Pagination](#9-pagination)
10. [Error Examples](#10-error-examples)
11. [Rate Limits & Best Practices](#11-rate-limits--best-practices)
12. [Versioning & Stability](#12-versioning--stability)
13. [Future Roadmap](#13-future-roadmap)

---

## 1. System Overview

### Architecture

```
┌──────────────────────┐
│  Your System         │
│  (Chatbot / CRM /    │
│   Automation Tool)   │
└──────────┬───────────┘
           │
           │  Authorization: Bearer sk_live_...
           │
┌──────────▼───────────┐
│  REST API            │
│  /api/v1/*           │
│                      │
│  Contacts, Messages, │
│  Calendars, Webhooks │
└──────────┬───────────┘
           │
┌──────────▼───────────┐     ┌─────────────────────┐
│  Seedly CRM Backend  │────▶│  Messaging Providers │
│                      │     │                      │
│  Business logic,     │     │  SMS, Email, Gmail,  │
│  automation engine,  │     │  Messenger, Instagram│
│  event dispatch      │     │                      │
└──────────┬───────────┘     └─────────────────────┘
           │
           │  Outbound Webhooks (HTTPS POST)
           │
┌──────────▼───────────┐
│  Your Webhook Server │
│  (receives events)   │
└──────────────────────┘
```

### Key Concepts

- **Sub-Account**: Each customer or brand operates within a sub-account. All API calls are scoped to a single sub-account via the API key.
- **Channels**: Conversations span multiple channels - `sms`, `email`, `messenger`, `instagram`, `gmail`, `live_chat`, `social_dm`.
- **Contacts**: The central entity. Every conversation, appointment, and opportunity is linked to a contact.
- **Workflows**: Automation sequences triggered by events. External systems can trigger workflows and receive event notifications.

### Base URL

```
https://{YOUR_DEPLOYMENT_URL}
```

Your deployment URL is found in the Seedly CRM dashboard under **Settings**.

### Response Format

All REST API responses use a consistent JSON envelope:

**Success:**

```json
{
  "data": { ... },
  "meta": { "total": 100, "hasMore": true, "cursor": "..." }
}
```

**Error:**

```json
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "firstName and lastName are required"
  }
}
```

---

## 2. Authentication

### API Keys

The REST API uses **API key authentication**. Each key is scoped to a single sub-account and has configurable permissions.

**Header format:**

```
Authorization: Bearer sk_live_...
```

Your customer (the sub-account admin) creates the API key in their Seedly CRM dashboard and shares it with you.

> **CORS warning:** API key endpoints are server-to-server only. Do not embed API keys in client-side JavaScript.

### Obtaining an API Key

1. Navigate to **Settings > Integrations** in the Seedly CRM dashboard
2. Click the **API Keys** card
3. Click **Create Key**, enter a name, and select the permission scopes you need
4. Copy the key immediately - it is shown **once** and cannot be retrieved later

### Available Scopes

| Scope                 | Grants access to                                                    |
| --------------------- | ------------------------------------------------------------------- |
| `contacts:read`       | List and get contacts                                               |
| `contacts:write`      | Create, update, and delete contacts                                 |
| `conversations:read`  | List conversations and read messages                                |
| `conversations:write` | Send messages, update conversation status                           |
| `calendars:read`      | List calendars, appointment types, appointments, check availability |
| `calendars:write`     | Book and cancel appointments                                        |
| `webhooks:manage`     | Create and manage webhook subscriptions                             |

**Recommended scopes for a chatbot integration:** `contacts:read`, `contacts:write`, `conversations:read`, `conversations:write`, `calendars:read`.

> **Agency-scoped keys** use the same scopes. The key operates on whichever sub-account is specified via the `X-Sub-Account-Id` header.

### Key Lifecycle

- **Active** - Key is valid and accepting requests
- **Expired** - Key has passed its optional expiration date (returns `401 KEY_EXPIRED`)
- **Revoked** - Key was manually revoked (returns `401 KEY_REVOKED`)

Keys can be revoked at any time from the settings UI. Revocation is immediate.

### Agency-Scoped API Keys

Agency-scoped API keys work across **all sub-accounts** under an agency, rather than being tied to a single sub-account. They use the prefixes `sk_agency_live_` (production) or `sk_agency_test_` (sandbox).

Because an agency-scoped key can access any sub-account, every request **must** include the `X-Sub-Account-Id` header to specify which sub-account the call should operate on:

```
Authorization: Bearer sk_agency_live_...
X-Sub-Account-Id: sub_abc123
```

Omitting the header returns a `400` error:

```json
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "X-Sub-Account-Id header is required for agency-scoped API keys"
  }
}
```

#### Discovering Sub-Account IDs

Use the discovery endpoint to list all sub-accounts available to the agency key:

```
GET /api/v1/sub-accounts
Authorization: Bearer sk_agency_live_...
```

**Response:**

```json
{
  "data": [
    { "id": "sub_abc123", "name": "Acme Corp" },
    { "id": "sub_def456", "name": "Widget Co" }
  ]
}
```

> **Note:** This endpoint requires an agency-scoped key. Sub-account-scoped keys (`sk_live_`) cannot call it.

#### Typical Integration Flow

1. **Agency owner creates an agency-scoped API key** in **Settings > Integrations > API Keys** and selects "Agency-wide" scope.
2. **Discover sub-accounts** by calling `GET /api/v1/sub-accounts` to retrieve the list of sub-account IDs.
3. **Include `X-Sub-Account-Id` on all subsequent calls** to target the desired sub-account.

```
# Step 2 - Discover sub-accounts
GET /api/v1/sub-accounts
Authorization: Bearer sk_agency_live_...

# Step 3 - List contacts for a specific sub-account
GET /api/v1/contacts
Authorization: Bearer sk_agency_live_...
X-Sub-Account-Id: sub_abc123
```

> **Backward compatibility:** Sub-account-scoped keys (`sk_live_`, `sk_test_`) continue to work exactly as before. They do not require the `X-Sub-Account-Id` header - the sub-account is implicit in the key itself.

### Public Endpoints (No Key Required)

Some endpoints are public and do not require an API key:

| Capability                                              | Auth                           |
| ------------------------------------------------------- | ------------------------------ |
| Check calendar availability                             | None                           |
| Book appointments (public booking flow)                 | None                           |
| Live chat widget                                        | Rate-limited only              |
| Form submissions                                        | Rate-limited, optional CAPTCHA |
| Inbound workflow triggers (`/webhooks/workflow/{slug}`) | Optional HMAC signature        |

---

## 3. Quick Start: Chatbot Integration

This walkthrough shows how to build a chatbot integration that receives messages, processes them, and sends replies.

### Flow

```
1. Receive webhook  ──▶  2. Look up contact  ──▶  3. Get conversation context
       │                                                      │
       │                                                      ▼
6. Track delivery   ◀──  5. Send reply        ◀──  4. Process with AI/logic
```

### Step-by-Step

**Step 1 - Receive `message.received` webhook event**

Configure a webhook subscription (see [Webhooks](#7-webhooks-real-time-events)) to listen for `message.received`. When a message arrives, your server receives:

```json
{
  "event": "message.received",
  "timestamp": "2026-03-27T14:30:00.000Z",
  "data": {
    "subAccountId": "sa_001",
    "contactId": "contact_abc123",
    "messageId": "msg_abc123",
    "conversationId": "conv_xyz789",
    "channel": "email",
    "bodyText": "Hi, I'd like to book an appointment",
    "fromAddress": "jane@example.com",
    "direction": "inbound",
    "tags": ["website-lead"]
  }
}
```

**Step 2 - Look up the contact**

```
GET /api/v1/contacts?email=jane@example.com
Authorization: Bearer sk_live_...
```

This returns an array with the matching contact (or an empty array if not found).

**Step 3 - Get conversation messages for context**

```
GET /api/v1/conversations/{conversationId}/messages?limit=20
Authorization: Bearer sk_live_...
```

Retrieve recent messages to provide conversation history to your AI or processing logic.

**Step 4 - Process with your AI/logic**

Pass the conversation history and the new message to your chatbot engine, LLM, or business logic layer.

**Step 5 - Send reply**

```
POST /api/v1/conversations/{conversationId}/messages
Authorization: Bearer sk_live_...
Content-Type: application/json

{
  "bodyText": "I'd be happy to help you book an appointment! What day works best?",
  "channel": "email",
  "toAddress": "jane@example.com",
  "subject": "Re: Appointment Request"
}
```

**Step 6 - Track delivery**

Listen for `message.sent`, `message.delivered`, and `message.failed` webhook events to confirm your reply was delivered successfully.

---

## 4. Conversations API

### Data Model

**Conversation:**

| Field           | Type    | Description                                                                 |
| --------------- | ------- | --------------------------------------------------------------------------- |
| `id`            | string  | Unique identifier                                                           |
| `contactId`     | string  | Linked contact                                                              |
| `channel`       | string  | `sms`, `email`, `messenger`, `instagram`, `gmail`, `live_chat`, `social_dm` |
| `status`        | string  | `open`, `closed`, `snoozed`                                                 |
| `assignedTo`    | string? | Assigned team member ID                                                     |
| `subject`       | string? | Email subject line                                                          |
| `unreadCount`   | number  | Unread inbound messages                                                     |
| `isArchived`    | boolean | Whether archived                                                            |
| `lastMessageAt` | string? | Most recent message time (ISO 8601)                                         |
| `createdAt`     | string  | Creation time (ISO 8601)                                                    |
| `contact`       | object  | Embedded contact summary (name, email, phone)                               |

**Message:**

| Field            | Type    | Description                                                 |
| ---------------- | ------- | ----------------------------------------------------------- |
| `id`             | string  | Unique identifier                                           |
| `conversationId` | string  | Parent conversation                                         |
| `channel`        | string  | Channel this message was sent/received on                   |
| `direction`      | string  | `inbound` or `outbound`                                     |
| `bodyText`       | string? | Plain text content                                          |
| `bodyHtml`       | string? | HTML content (email)                                        |
| `status`         | string  | `pending`, `sent`, `delivered`, `read`, `failed`, `bounced` |
| `fromAddress`    | string? | Sender (phone/email)                                        |
| `toAddress`      | string? | Recipient (phone/email)                                     |
| `isInternal`     | boolean | Internal note (not sent to contact)                         |
| `createdAt`      | string  | Creation time (ISO 8601)                                    |

### Endpoints

#### Create Conversation

```
POST /api/v1/conversations
```

**Scope:** `conversations:write`

**Request body:**

```json
{
  "contactId": "contact_id_here",
  "channel": "email",
  "subject": "Welcome to our service"
}
```

| Field       | Required | Description                                                                 |
| ----------- | -------- | --------------------------------------------------------------------------- |
| `contactId` | Yes      | Contact to associate the conversation with                                  |
| `channel`   | Yes      | `sms`, `email`, `messenger`, `instagram`, `gmail`, `live_chat`, `social_dm` |
| `subject`   | No       | Subject line (email only)                                                   |

**Response:**

- `201 Created` - New conversation created. Response includes `"created": true`.
- `200 OK` - An existing open conversation was found for this contact and channel. Response includes `"created": false`.

```json
{
  "data": {
    "id": "conv_xyz789",
    "contactId": "contact_id_here",
    "channel": "email",
    "status": "open",
    "created": true
  }
}
```

#### List Conversations

```
GET /api/v1/conversations
```

**Scope:** `conversations:read`

**Query parameters:**

- `status` - `open`, `closed`, `snoozed`
- `channel` - Filter by channel type
- `contactId` - Filter by contact ID to find all conversations for a specific contact
- `assignedTo` - Filter by team member ID
- `limit` - Max results (default 50, max 200)

**Response:** Array of conversations with embedded contact summary.

#### Get Conversation

```
GET /api/v1/conversations/{id}
```

**Scope:** `conversations:read`

#### List Messages

```
GET /api/v1/conversations/{id}/messages
```

**Scope:** `conversations:read`

**Query parameters:**

- `limit` - Max results (default 50, max 100)
- `cursor` - Pagination cursor from previous response

**Response:**

```json
{
  "data": [
    /* messages, newest first */
  ],
  "meta": { "hasMore": true, "cursor": "..." }
}
```

#### Send Message

```
POST /api/v1/conversations/{id}/messages
```

**Scope:** `conversations:write`

**Request body:**

```json
{
  "bodyText": "Hello {{firstName}}, your appointment is confirmed.",
  "channel": "sms",
  "toAddress": "+15551234567",
  "subject": "Appointment Confirmation",
  "bodyHtml": "<p>Hello...</p>"
}
```

| Field       | Required      | Description                                                    |
| ----------- | ------------- | -------------------------------------------------------------- |
| `bodyText`  | Yes           | Message content (plain text)                                   |
| `channel`   | Yes           | `sms`, `email`, `gmail`, `messenger`, `instagram`, `social_dm` |
| `toAddress` | For email/SMS | Recipient address (derived from contact if omitted)            |
| `subject`   | For email     | Email subject line                                             |
| `bodyHtml`  | No            | HTML content (email only)                                      |

**Channel routing:** The system automatically sends through the configured provider for the channel (e.g., SMS goes via the configured SMS provider, email via the configured email provider).

**Merge fields:** `{{firstName}}`, `{{lastName}}`, `{{email}}`, `{{phone}}`, `{{company}}`, `{{fullName}}`, and `{{custom_values.key}}` are resolved at send time.

**DND enforcement:** If the contact has opted out of a channel, the send will be silently skipped.

#### Update Conversation

```
PATCH /api/v1/conversations/{id}
```

**Scope:** `conversations:write`

**Request body:**

```json
{
  "status": "closed",
  "assignedTo": "user_id_here"
}
```

Both fields are optional. Set `assignedTo` to `null` to unassign.

### Live Chat (Public, No Key Required)

| Endpoint                             | Method | Description                                                              |
| ------------------------------------ | ------ | ------------------------------------------------------------------------ |
| `/api/chat/config?subAccountId={id}` | GET    | Get widget configuration                                                 |
| `/api/chat/session`                  | POST   | Create chat session. Body: `{ subAccountId, visitorName, visitorEmail }` |
| `/api/chat/message`                  | POST   | Send message. Body: `{ sessionId, message }`                             |
| `/api/chat/messages?sessionId={id}`  | GET    | Fetch session messages                                                   |

---

## 5. Contacts API

### Data Model

| Field              | Type     | Description                                           |
| ------------------ | -------- | ----------------------------------------------------- |
| `id`               | string   | Unique identifier                                     |
| `firstName`        | string   | First name                                            |
| `lastName`         | string   | Last name                                             |
| `email`            | string?  | Primary email                                         |
| `phone`            | string?  | Primary phone (E.164 format: `+15551234567`)          |
| `company`          | string?  | Company name                                          |
| `title`            | string?  | Job title                                             |
| `source`           | string?  | Lead source                                           |
| `tags`             | string[] | Tag labels                                            |
| `lifecycleStage`   | string   | `lead`, `qualified`, `customer`, `repeat`, `inactive` |
| `engagementScore`  | number?  | Computed score (0-100) - **read-only**                |
| `assignedTo`       | string?  | Assigned team member ID                               |
| `additionalEmails` | string[] | Secondary email addresses                             |
| `additionalPhones` | string[] | Secondary phone numbers                               |
| `addressLine1`     | string?  | Street address                                        |
| `city`             | string?  | City                                                  |
| `state`            | string?  | State/province                                        |
| `postalCode`       | string?  | Postal/ZIP code                                       |
| `country`          | string?  | Country                                               |
| `dateOfBirth`      | string?  | Date of birth                                         |
| `customFields`     | object?  | Key-value custom field data                           |
| `createdAt`        | string   | ISO 8601 timestamp - **read-only**                   |
| `updatedAt`        | string   | ISO 8601 timestamp - **read-only**                   |

### Endpoints

#### List Contacts

```
GET /api/v1/contacts
```

**Scope:** `contacts:read`

**Query parameters:**

- `search` - Full-text search (name, email, company)
- `email` - Exact email match. Returns array with single contact or empty array. More efficient than `search` for programmatic lookups.
- `limit` - Max results (default 50, max 100)
- `cursor` - Pagination cursor
- `lifecycleStage` - Filter by stage
- `source` - Filter by lead source
- `tags` - Comma-separated tag filter (e.g., `tags=vip,enterprise`)
- `assignedTo` - Filter by team member ID
- `hasPhone` - `true` to only return contacts with phone numbers

**Response:**

```json
{
  "data": [
    /* contacts */
  ],
  "meta": { "total": 150, "hasMore": true, "cursor": "..." }
}
```

#### Get Contact

```
GET /api/v1/contacts/{id}
```

**Scope:** `contacts:read`

#### Get Custom Field Definitions

```
GET /api/v1/contacts/fields
```

**Scope:** `contacts:read`

Returns the list of custom field definitions configured for the sub-account.

**Response:**

```json
{
  "data": [
    {
      "name": "preferred_language",
      "label": "Preferred Language",
      "type": "select",
      "options": ["English", "Spanish", "French"],
      "isRequired": false
    },
    {
      "name": "company_size",
      "label": "Company Size",
      "type": "number",
      "options": null,
      "isRequired": true
    }
  ]
}
```

Each field definition includes:

| Field        | Type      | Description                                                            |
| ------------ | --------- | ---------------------------------------------------------------------- |
| `name`       | string    | Field key (used in `customFields` object on contacts)                  |
| `label`      | string    | Human-readable label                                                   |
| `type`       | string    | `text`, `number`, `select`, `date`, `boolean`, `url`, `email`, `phone` |
| `options`    | string[]? | Available options (for `select` type only)                             |
| `isRequired` | boolean   | Whether the field is required when creating/updating contacts          |

#### Create Contact

```
POST /api/v1/contacts
```

**Scope:** `contacts:write`

**Request body:**

```json
{
  "firstName": "Jane",
  "lastName": "Doe",
  "email": "jane@example.com",
  "phone": "+15551234567",
  "company": "Acme Corp",
  "lifecycleStage": "lead",
  "tags": ["website-lead"],
  "source": "api"
}
```

| Field            | Required | Description          |
| ---------------- | -------- | -------------------- |
| `firstName`      | Yes      | First name           |
| `lastName`       | Yes      | Last name            |
| All other fields | No       | See data model above |

**Side effects:**

- Fires `contact.created` webhook event
- Triggers matching workflow automations
- Validates custom field schemas if provided

**Response:** `201 Created` with the full contact object.

#### Update Contact

```
PATCH /api/v1/contacts/{id}
```

**Scope:** `contacts:write`

Include only the fields you want to change.

```json
{
  "lifecycleStage": "customer",
  "tags": ["vip", "enterprise"]
}
```

**Side effects:**

- Fires `contact.updated` webhook event
- If tags added: fires `contact.tag_added` event per new tag
- If `lifecycleStage` changed: fires `contact.lifecycle_changed` event

#### Delete Contact

```
DELETE /api/v1/contacts/{id}
```

**Scope:** `contacts:write`

Soft-deletes the contact. Data is retained before permanent deletion.

**Cascading effects:**

- Related conversations are soft-deleted
- Upcoming appointments are cancelled
- Running workflow automations for this contact are cancelled

---

## 6. Calendar & Booking API

### Data Model

**Appointment:**

| Field               | Type    | Description                                                   |
| ------------------- | ------- | ------------------------------------------------------------- |
| `id`                | string  | Unique identifier                                             |
| `calendarId`        | string  | Calendar                                                      |
| `appointmentTypeId` | string? | Type of appointment                                           |
| `contactId`         | string? | Linked contact                                                |
| `title`             | string? | Appointment title                                             |
| `startTime`         | string  | Start time (ISO 8601)                                         |
| `endTime`           | string  | End time (ISO 8601)                                           |
| `status`            | string  | `scheduled`, `confirmed`, `completed`, `cancelled`, `no_show` |
| `assignedTo`        | string? | Assigned team member                                          |
| `notes`             | string? | Appointment notes                                             |
| `createdAt`         | string  | Creation time (ISO 8601)                                      |
| `contact`           | object? | Embedded contact summary                                      |

### Endpoints

#### List Calendars

```
GET /api/v1/calendars
```

**Scope:** `calendars:read`

Returns all active calendars for the sub-account.

**Response:**

```json
{
  "data": [
    {
      "id": "cal_abc123",
      "name": "Main Calendar",
      "type": "personal",
      "timezone": "America/New_York"
    }
  ]
}
```

#### List Appointment Types

```
GET /api/v1/calendars/types
```

**Scope:** `calendars:read`

**Query parameters:**

- `calendarId` - Optionally filter by a specific calendar

**Response:**

```json
{
  "data": [
    {
      "id": "apt_type_001",
      "calendarId": "cal_abc123",
      "name": "30-Minute Consultation",
      "duration": 30,
      "slug": "30-min-consultation"
    }
  ]
}
```

#### List Appointments

```
GET /api/v1/calendars/appointments
```

**Scope:** `calendars:read`

**Query parameters:**

- `calendarId` - Filter by calendar
- `startDate` - Start of date range (Unix timestamp, milliseconds)
- `endDate` - End of date range (Unix timestamp, milliseconds)
- `status` - `scheduled`, `confirmed`, `completed`, `cancelled`, `no_show`
- `limit` - Max results (default 50, max 200)

#### Check Availability

```
GET /api/v1/calendars/availability?appointmentTypeId={id}&date=2026-04-01
```

**Scope:** `calendars:read`

**Query parameters:**

- `appointmentTypeId` - Required
- `date` - Date in `YYYY-MM-DD` format

**Response:**

```json
{
  "data": [
    { "start": "2026-04-01T09:00:00-04:00", "end": "2026-04-01T09:30:00-04:00" },
    {
      "start": "2026-04-01T09:30:00-04:00",
      "end": "2026-04-01T10:00:00-04:00",
      "seatsRemaining": 3
    }
  ]
}
```

Availability accounts for: day-of-week schedules, date overrides (blocked days), existing bookings, buffer times, minimum notice, maximum advance, and seat limits.

#### Book Appointment

```
POST /api/v1/calendars/appointments
```

**Scope:** `calendars:write`

**Request body:**

```json
{
  "appointmentTypeId": "...",
  "startTime": 1743508800000,
  "endTime": 1743510600000,
  "firstName": "Jane",
  "lastName": "Doe",
  "email": "jane@example.com",
  "phone": "+15551234567",
  "notes": "First consultation"
}
```

| Field               | Required | Description                               |
| ------------------- | -------- | ----------------------------------------- |
| `appointmentTypeId` | Yes      | Type of appointment to book               |
| `startTime`         | Yes      | Start time (Unix timestamp, milliseconds) |
| `endTime`           | Yes      | End time (Unix timestamp, milliseconds)   |
| `firstName`         | Yes      | Booker's first name                       |
| `lastName`          | Yes      | Booker's last name                        |
| `email`             | Yes      | Booker's email                            |
| `phone`             | No       | Booker's phone                            |
| `notes`             | No       | Additional notes                          |

**Behavior:**

- Creates or finds a contact by email
- Validates slot availability and conflicts
- Assigns based on calendar type (personal, round-robin, collective, class)
- Fires `appointment.booked` webhook event
- Sends confirmation email if configured

#### Cancel Appointment

```
DELETE /api/v1/calendars/appointments/{id}
```

**Scope:** `calendars:write`

**Query parameters:**

- `reason` - Optional cancellation reason (e.g., `?reason=Customer%20requested`)

**Response:** `200 OK`

```json
{
  "data": {
    "id": "appt_abc123",
    "cancelled": true
  }
}
```

### Public Booking (No Key Required)

The same availability check and booking flow is also available without an API key via the public booking page widget.

---

## 7. Webhooks (Real-Time Events)

### Overview

Seedly CRM pushes real-time event notifications to your server via HTTPS POST. Events are cryptographically signed, retried on failure, and logged for debugging.

### Managing Subscriptions

Webhook subscriptions can be managed via the REST API or the settings UI.

#### List Subscriptions

```
GET /api/v1/webhooks
```

**Scope:** `webhooks:manage`

#### Create Subscription

```
POST /api/v1/webhooks
```

**Scope:** `webhooks:manage`

**Request body:**

```json
{
  "url": "https://your-server.com/webhooks/seedly",
  "events": [
    "contact.created",
    "message.received",
    "message.sent",
    "message.delivered",
    "message.failed",
    "appointment.booked"
  ],
  "description": "Chatbot integration"
}
```

**Response:** Returns the subscription with a **signing secret**. Store this securely - it is shown once.

#### Update Subscription

```
PATCH /api/v1/webhooks/{id}
```

**Scope:** `webhooks:manage`

#### Delete Subscription

```
DELETE /api/v1/webhooks/{id}
```

**Scope:** `webhooks:manage`

#### Regenerate Signing Secret

```
POST /api/v1/webhooks/{id}/regenerate-secret
```

**Scope:** `webhooks:manage`

Regenerates the signing secret for a webhook subscription. The old secret is immediately invalidated.

**Response:**

```json
{
  "data": {
    "secret": "whsec_new_plaintext_secret_here"
  }
}
```

Store the new secret securely - it is shown once. Update your webhook verification code with the new secret immediately.

### Available Events

| Event Name                  | Fires when...                          |
| --------------------------- | -------------------------------------- |
| `contact.created`           | New contact created                    |
| `contact.updated`           | Contact record updated                 |
| `contact.lifecycle_changed` | Lifecycle stage changed                |
| `contact.tag_added`         | Tag added to contact                   |
| `opportunity.created`       | New opportunity created                |
| `opportunity.updated`       | Opportunity record updated             |
| `opportunity.stage_changed` | Opportunity pipeline stage changed     |
| `opportunity.won`           | Opportunity marked as won              |
| `opportunity.lost`          | Opportunity marked as lost             |
| `opportunity.deleted`       | Opportunity deleted                    |
| `message.received`          | Inbound message received (any channel) |
| `message.sent`              | Outbound message sent to provider      |
| `message.delivered`         | Outbound message confirmed delivered   |
| `message.failed`            | Outbound message delivery failed       |
| `appointment.booked`        | New appointment booked                 |
| `appointment.cancelled`     | Appointment cancelled                  |
| `appointment.rescheduled`   | Appointment rescheduled                |
| `appointment.confirmed`     | Appointment confirmed                  |
| `appointment.completed`     | Appointment marked completed           |
| `appointment.no_show`       | Appointment marked no-show             |
| `form.submitted`            | Public form submission received        |
| `invoice.paid`              | Invoice payment received               |
| `task.created`              | New task created                       |
| `task.updated`              | Task record updated                    |
| `task.completed`            | Task marked completed                  |
| `task.deleted`              | Task deleted                           |

### Payload Format

```json
{
  "event": "contact.created",
  "timestamp": "2026-03-27T14:30:00.000Z",
  "data": {
    "subAccountId": "sa_001",
    "contactId": "abc123",
    "firstName": "Jane",
    "lastName": "Doe",
    "email": "jane@example.com",
    "tags": ["website-lead"]
  }
}
```

**Common fields in `data`:**

- `subAccountId` - Always present. Identifies which sub-account the event belongs to.
- `contactId` - Present when the event is associated with a specific contact.
- `tags` - Present when the associated contact has tags.

See [Webhook Payloads](/docs/help/webhooks) for complete payload examples for all 26 events.

### Request Headers

| Header                | Value                                        |
| --------------------- | -------------------------------------------- |
| `Content-Type`        | `application/json`                           |
| `X-Webhook-Signature` | `sha256={hex}` - HMAC-SHA256 of the raw body |
| `X-Webhook-Event`     | Event name                                   |
| `X-Webhook-Delivery`  | Unique delivery ID (use as idempotency key)  |
| `User-Agent`          | `Seedly-CRM-Webhook/1.0`                     |

### Verifying Signatures

Always verify the `X-Webhook-Signature` header using timing-safe comparison.

**Node.js example:**

```javascript
const crypto = require('crypto');

function verifySignature(rawBody, signatureHeader, secret) {
  const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');

  return crypto.timingSafeEqual(Buffer.from(signatureHeader), Buffer.from(expected));
}
```

### Retry Policy

If your endpoint does not return a success response, the delivery is retried automatically several times with an increasing back-off between attempts. After the retries are exhausted, the delivery is marked as failed. Delivery logs are retained and can be reviewed in the dashboard.

Your server must respond with a **2xx status** within a reasonable time window to be considered successful.

### Security Notes

- Webhook URLs pointing to private or reserved IP ranges are blocked (SSRF protection)
- Always verify signatures before processing events
- Use the `X-Webhook-Delivery` header for idempotency - your server may receive duplicate deliveries

---

## 8. Workflow Triggers

### Overview

Workflows are automation sequences that execute when events occur. External systems can trigger workflows via HTTP and inject custom events into running executions.

Workflow triggers use the `/webhooks/workflow/{slug}` endpoint with optional HMAC signature, not API key authentication.

### Trigger a Workflow

Each workflow with an "Inbound Webhook" trigger type has a unique URL:

```
POST https://{YOUR_DEPLOYMENT_URL}/webhooks/workflow/{slug}
```

**Request body:**

```json
{
  "contactId": "optional_contact_id",
  "email": "jane@example.com",
  "customData": "any data your workflow needs"
}
```

**Optional header:** `X-Webhook-Signature: sha256={hex}` - HMAC-SHA256 of the body using the workflow's signing secret. If the workflow has a secret configured, the signature is **required**.

**Response:**

```json
{ "ok": true, "triggered": 1 }
```

### Inject Events into Running Workflows

Running workflows can wait for custom events at "goal" nodes. Inject events with:

```
POST https://{YOUR_DEPLOYMENT_URL}/webhooks/event/{slug}
```

**Request body:**

```json
{
  "eventType": "payment_completed",
  "contactId": "optional_contact_id",
  "data": { "amount": 99.99 }
}
```

### Rate Limits

Both workflow endpoints are rate-limited on a per-endpoint basis.

---

## 9. Pagination

The API uses cursor-based pagination. Cursors are opaque strings - do not parse or construct them.

### Walkthrough

**First request - no cursor:**

```
GET /api/v1/contacts?limit=50
Authorization: Bearer sk_live_...
```

**Response:**

```json
{
  "data": [
    /* first 50 contacts */
  ],
  "meta": {
    "total": 150,
    "hasMore": true,
    "cursor": "eyJpZCI6ImNvbnRhY3RfMDUwIn0"
  }
}
```

**Read the cursor from `meta.cursor` and pass it in the next request:**

```
GET /api/v1/contacts?limit=50&cursor=eyJpZCI6ImNvbnRhY3RfMDUwIn0
Authorization: Bearer sk_live_...
```

**Response:**

```json
{
  "data": [
    /* next 50 contacts */
  ],
  "meta": {
    "total": 150,
    "hasMore": true,
    "cursor": "eyJpZCI6ImNvbnRhY3RfMTAwIn0"
  }
}
```

**Continue until `hasMore` is `false`:**

```json
{
  "data": [
    /* final 50 contacts */
  ],
  "meta": {
    "total": 150,
    "hasMore": false,
    "cursor": null
  }
}
```

### Tips

- Cursors are stable even when data changes between requests (unlike offset-based pagination).
- Do not store cursors long-term - they may expire. Use them immediately for sequential page fetching.
- The `total` field reflects the total count at the time of the query and may change between pages.
- `meta.total` is the size of the whole result set, not the number of records in the page you are holding. Use `meta.hasMore` to decide whether to fetch again, and `total` to show a count or size a progress bar.

---

## 10. Error Examples

### 401 Unauthorized - Missing or invalid API key

```json
{
  "error": {
    "code": "UNAUTHORIZED",
    "message": "Missing or invalid API key. Provide a valid key in the Authorization header: Bearer sk_live_..."
  }
}
```

### 400 Validation Error - Invalid request body

```json
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "firstName and lastName are required"
  }
}
```

### 403 Forbidden - Missing required scope

```json
{
  "error": {
    "code": "FORBIDDEN",
    "message": "API key missing required scope: contacts:write. Update key permissions in Settings > Integrations > API Keys."
  }
}
```

### 429 Rate Limited - Too many requests

```json
{
  "error": {
    "code": "RATE_LIMITED",
    "message": "Rate limit exceeded. Retry after the period indicated in the Retry-After header."
  }
}
```

**Response headers:**

```
Retry-After: 12
X-RateLimit-Limit: ...
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1743091200
```

---

## 11. Rate Limits & Best Practices

### Rate Limits

Rate limits are applied per API key. Read operations have higher limits than write operations. Rate limit headers are included in every response so your integration can monitor usage and avoid exceeding limits:

```
X-RateLimit-Limit: ...
X-RateLimit-Remaining: ...
X-RateLimit-Reset: ...
```

When a limit is exceeded, the API returns HTTP `429` with a `Retry-After` header indicating how many seconds to wait before retrying.

Workflow trigger and event injection endpoints are rate-limited on a per-endpoint basis. Public endpoints such as live chat and form submissions are rate-limited by IP address.

For high-volume integrations requiring higher rate limits, contact the Seedly CRM team.

> **Server-side only:** All API key endpoints are designed for server-to-server use. Do not call the REST API from client-side JavaScript - API keys would be exposed.

### Best Practices

1. **Verify webhook signatures** - Always use timing-safe comparison. Never skip verification in production.

2. **Use idempotency keys** - Use `X-Webhook-Delivery` to deduplicate webhook events. Track message IDs to prevent duplicate sends.

3. **Handle retries gracefully** - Respond to webhooks promptly. Return `200` even for events you don't process. Do heavy work asynchronously.

4. **Use cursor-based pagination** - Use `cursor` from the `meta` object, not offset-based `page` numbers. Cursors are stable when data changes between requests.

5. **Respect DND** - The system enforces Do-Not-Disturb server-side, but checking DND status before sending prevents silent failures.

6. **Use merge fields** - Use `{{firstName}}`, `{{email}}`, etc. in message bodies. The system resolves them at send time using current contact data.

7. **Channel constraints:**
   - **SMS:** Phone numbers must be E.164 format (`+1XXXXXXXXXX`). Messages over 160 characters are segmented.
   - **Email:** Provide both `bodyText` and `bodyHtml` for maximum compatibility.
   - **Messenger/Instagram:** Subject to Meta's 24-hour messaging window policy.

### Error Codes

| Status | Code               | Meaning                           |
| ------ | ------------------ | --------------------------------- |
| `200`  | -                 | Success                           |
| `201`  | -                 | Created                           |
| `400`  | `VALIDATION_ERROR` | Invalid request parameters        |
| `401`  | `UNAUTHORIZED`     | Missing or invalid API key        |
| `401`  | `KEY_EXPIRED`      | API key has expired               |
| `401`  | `KEY_REVOKED`      | API key was revoked               |
| `403`  | `FORBIDDEN`        | API key missing required scope    |
| `404`  | `NOT_FOUND`        | Resource not found                |
| `429`  | `RATE_LIMITED`     | Too many requests                 |
| `500`  | `INTERNAL_ERROR`   | Server error - retry with backoff |

---

## 12. Versioning & Stability

### Current Version

All endpoints are served under `/api/v1/`. The `X-API-Version: v1` header is included in every response.

### Backwards Compatibility

We will **not** make breaking changes to `v1` without a deprecation period:

- **Not breaking (may happen at any time):** New fields in responses, new optional query parameters, new endpoints, new webhook events, new error codes
- **Breaking (requires deprecation):** Removing response fields, changing field types, removing endpoints, changing endpoint behavior, renaming fields

### Deprecation Process

1. A `Deprecation: true` response header is added to affected endpoints (minimum **90 days** before removal)
2. A changelog entry is published in the [API Changelog](/docs/help/api-changelog)
3. When `v2` is introduced, `v1` will remain supported for at least **12 months**

### Recommendations

- Parse responses tolerantly - ignore unknown fields
- Do not depend on field ordering in JSON responses
- Check the [API Changelog](/docs/help/api-changelog) periodically for updates

---

## 13. Future Roadmap

| Feature                 | Status  | Description                                                                     |
| ----------------------- | ------- | ------------------------------------------------------------------------------- |
| Webhook event filtering | Planned | Filter subscriptions by criteria (e.g., only `message.received` on SMS channel) |
| Batch API               | Planned | Create/update multiple records in a single call                                 |
| OAuth 2.0 provider      | Future  | Authorization code flow for marketplace app integrations                        |

---

## Full Endpoint Reference

### REST API (Requires API Key)

| Method   | Path                                      | Scope                      | Description                                                         |
| -------- | ----------------------------------------- | -------------------------- | ------------------------------------------------------------------- |
| `GET`    | `/api/v1/contacts`                        | `contacts:read`            | List/search contacts (supports `email` query param for exact match) |
| `GET`    | `/api/v1/contacts/{id}`                   | `contacts:read`            | Get contact                                                         |
| `GET`    | `/api/v1/contacts/fields`                 | `contacts:read`            | Get custom field definitions                                        |
| `POST`   | `/api/v1/contacts`                        | `contacts:write`           | Create contact                                                      |
| `PATCH`  | `/api/v1/contacts/{id}`                   | `contacts:write`           | Update contact                                                      |
| `DELETE` | `/api/v1/contacts/{id}`                   | `contacts:write`           | Delete contact                                                      |
| `GET`    | `/api/v1/conversations`                   | `conversations:read`       | List conversations (supports `contactId` query param)               |
| `GET`    | `/api/v1/conversations/{id}`              | `conversations:read`       | Get conversation                                                    |
| `POST`   | `/api/v1/conversations`                   | `conversations:write`      | Create conversation                                                 |
| `GET`    | `/api/v1/conversations/{id}/messages`     | `conversations:read`       | List messages                                                       |
| `POST`   | `/api/v1/conversations/{id}/messages`     | `conversations:write`      | Send message                                                        |
| `PATCH`  | `/api/v1/conversations/{id}`              | `conversations:write`      | Update status/assignment                                            |
| `GET`    | `/api/v1/calendars`                       | `calendars:read`           | List active calendars                                               |
| `GET`    | `/api/v1/calendars/types`                 | `calendars:read`           | List appointment types (optional `calendarId` filter)               |
| `GET`    | `/api/v1/calendars/availability`          | `calendars:read`           | Check available slots                                               |
| `GET`    | `/api/v1/calendars/appointments`          | `calendars:read`           | List appointments                                                   |
| `POST`   | `/api/v1/calendars/appointments`          | `calendars:write`          | Book appointment                                                    |
| `DELETE` | `/api/v1/calendars/appointments/{id}`     | `calendars:write`          | Cancel appointment                                                  |
| `GET`    | `/api/v1/webhooks`                        | `webhooks:manage`          | List webhook subscriptions                                          |
| `POST`   | `/api/v1/webhooks`                        | `webhooks:manage`          | Create subscription                                                 |
| `PATCH`  | `/api/v1/webhooks/{id}`                   | `webhooks:manage`          | Update subscription                                                 |
| `DELETE` | `/api/v1/webhooks/{id}`                   | `webhooks:manage`          | Delete subscription                                                 |
| `POST`   | `/api/v1/webhooks/{id}/regenerate-secret` | `webhooks:manage`          | Regenerate signing secret                                           |
| `GET`    | `/api/v1/sub-accounts`                    | Agency-scoped key required | List all sub-accounts under the agency                              |

### Public Endpoints (No Key Required)

| Method | Path                        | Description             |
| ------ | --------------------------- | ----------------------- |
| `GET`  | `/api/chat/config`          | Live chat widget config |
| `POST` | `/api/chat/session`         | Create chat session     |
| `POST` | `/api/chat/message`         | Send chat message       |
| `GET`  | `/api/chat/messages`        | Get chat messages       |
| `POST` | `/api/forms/submit`         | Submit public form      |
| `POST` | `/webhooks/workflow/{slug}` | Trigger workflow        |
| `POST` | `/webhooks/event/{slug}`    | Inject workflow event   |

---

_This document is intended for engineering teams building integrations with Seedly CRM. For questions, contact the Seedly CRM team._

---
Source: https://seedlycrm.com/docs/help/api
