Help Center
REST API
Integrate with Seedly CRM using the REST API for chatbot, automation, and third-party system integrations.
Last updated
Table of Contents#
- System Overview
- Authentication
- Quick Start: Chatbot Integration
- Conversations API
- Contacts API
- Calendar & Booking API
- Opportunities, Tasks & Pipelines API
- Campaigns API
- Webhooks (Real-Time Events)
- Workflow Triggers
- Pagination
- Error Examples
- Rate Limits & Best Practices
- Versioning & Stability
- Add-On Endpoints
1. System Overview#
Architecture#
┌──────────────────────┐
│ Your System │
│ (Chatbot / CRM / │
│ Automation Tool) │
└──────────┬───────────┘
│
│ Authorization: Bearer sk_live_...
│
┌──────────▼───────────┐
│ REST API │
│ /api/v1/* │
│ │
│ Contacts, Messages, │
│ Calendars, Deals, │
│ Tasks, 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:
{
"data": { ... },
"meta": { "total": 100, "hasMore": true, "cursor": "..." }
}Error:
{
"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#
- Navigate to Settings > Integrations in the Seedly CRM dashboard
- Click the API Keys card
- Click Create Key, enter a name, and select the permission scopes you need
- 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 |
availability:read | List calendars, list appointment types, check available time slots. No customer details appear in any of these responses |
appointments:read | List booked appointments, including each linked customer's name, email, and phone |
appointments:write | Book and cancel appointments |
opportunities:read | List and get opportunities, list pipelines and their stages |
opportunities:write | Create and update opportunities, move stage, set status, delete |
tasks:read | List and get tasks |
tasks:write | Create, update, complete, and delete tasks |
campaigns:write | Create draft campaigns via API and send or schedule them (there is deliberately no campaigns:read: no GET endpoint enforces one) |
webhooks:manage | Create and manage webhook subscriptions |
service-types:read | The published price list of services this location sells |
booking-estimates:read | Quotes belonging to the one contact a conversation is already with |
booking-appointments:read | One verified customer's own appointments, after they prove who they are |
booking-appointments:write | Cancelling or rescheduling that same verified customer's appointment |
The calendar scopes changed in 5.8.0.
calendars:readandcalendars:writeno longer exist. The oldcalendars:readgranted four unrelated things at once: the calendar list, the appointment types, the open time slots, and the booked appointments - and that last one returns each customer's name, email, and phone with no date range required. The replacement splits the reads by what they expose:availability:readcovers the business reads (calendars, appointment types, open slots) and never exposes contact details, whileappointments:readgoverns the booked-appointment roster and nothing else.appointments:writeis the oldcalendars:writeunder its new name, unchanged in what it grants. No old names are kept alive: a key still holding one is refused by the endpoints it used to reach, so re-tick the scopes on any existing key that touches calendars (scopes can be edited in place, see Key Lifecycle).
Recommended scopes for a chatbot integration: contacts:read, contacts:write, conversations:read, conversations:write, availability:read - plus appointments:write if it books. For a third-party booking assistant that quotes prices and manages a caller's own booking, prefer the narrower service-types:read, booking-estimates:read, booking-appointments:read, and booking-appointments:write over the roster-level appointment scopes (see Conversational Booking).
Agency-scoped keys use the same scopes. The key operates on whichever sub-account is specified via the
X-Sub-Account-Idheader.
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. A key's scopes can also be edited in place from the same screen without issuing a new key: saving replaces the scope list in one step, the change takes effect immediately, and the key value itself never changes, so nothing has to be re-installed at the integration's end.
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_abc123Omitting the header returns a 400 error:
{
"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:
{
"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#
- Agency owner creates an agency-scoped API key in Settings > Integrations > API Keys and selects "Agency-wide" scope.
- Discover sub-accounts by calling
GET /api/v1/sub-accountsto retrieve the list of sub-account IDs. - Include
X-Sub-Account-Idon 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_abc123Backward compatibility: Sub-account-scoped keys (
sk_live_,sk_test_) continue to work exactly as before. They do not require theX-Sub-Account-Idheader - 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}) | Required 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/logicStep-by-Step#
Step 1 - Receive message.received webhook event
Configure a webhook subscription (see Webhooks) to listen for message.received. When a message arrives, your server receives:
{
"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": "[email protected]",
"direction": "inbound",
"tags": ["website-lead"]
}
}Step 2 - Look up the contact
GET /api/v1/[email protected]
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": "[email protected]",
"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/conversationsScope: conversations:write
Request body:
{
"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. Forsms, this is the contact's one conversation for calls and texts, reopened if it was closed, so an integration does not start a second thread beside it.
{
"data": {
"id": "conv_xyz789",
"contactId": "contact_id_here",
"channel": "email",
"status": "open",
"created": true
}
}List Conversations#
GET /api/v1/conversationsScope: conversations:read
Query parameters:
status-open,closed,snoozedchannel- Filter by channel typecontactId- Filter by contact ID to find all conversations for a specific contactassignedTo- Filter by team member IDlimit- Max results (default 50, max 100)
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}/messagesScope: conversations:read
Query parameters:
limit- Max results (default 50, max 100)cursor- Pagination cursor from previous response
Response:
{
"data": [
/* messages, newest first */
],
"meta": { "hasMore": true, "cursor": "..." }
}Send Message#
POST /api/v1/conversations/{id}/messagesScope: conversations:write
Request body:
{
"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:
{
"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 |
timezone | string? | The contact's IANA timezone (e.g. America/New_York) - when set, scheduling uses it instead of the sub-account default |
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/contactsScope: 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 thansearchfor programmatic lookups.limit- Max results (default 50, max 100)cursor- Pagination cursorlifecycleStage- Filter by stagesource- Filter by lead sourcetags- Comma-separated tag filter (e.g.,tags=vip,enterprise)assignedTo- Filter by team member IDhasPhone-trueto only return contacts with phone numbers
Response:
{
"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/fieldsScope: contacts:read
Returns the list of custom field definitions configured for the sub-account.
Response:
{
"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/contactsScope: contacts:write
Request body:
{
"firstName": "Jane",
"lastName": "Doe",
"email": "[email protected]",
"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.createdwebhook 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.
{
"lifecycleStage": "customer",
"tags": ["vip", "enterprise"]
}Side effects:
- Fires
contact.updatedwebhook event - If tags added: fires
contact.tag_addedevent per new tag - If
lifecycleStagechanged: firescontact.lifecycle_changedevent
Bot Update Contact (scalar-only companion)#
PUT /api/v1/contacts/{id}/bot-updateScope: contacts:write
Some chatbot custom-tool builders can only substitute scalar values into a request body, so they cannot send a nested object - a template like {"customFields": {{updates_json}}} is rejected as invalid JSON and the bot ends up sending an empty update that succeeds and writes nothing. This companion endpoint takes one instruction at a time, using only single values, and does the shaping server-side:
{ "operation": "set_field", "field": "phone", "value": "+15551234567" }| Operation | What it does |
|---|---|
set_field | Set one standard field (field names it, value is the new value) |
set_custom_field | Set one custom field (field is the exact custom-field key) |
add_tag | Add one tag (value) - merged against the stored list for you; idempotent and case-insensitive |
remove_tag | Remove one tag (value) |
An empty-string value clears a field or custom field, and the bot never has to send or preserve the whole tag list. Deliberate boundary: source and assignedTo are not settable by a bot - anything outside the allowlisted fields is a 400. Same authentication, rate-limit bucket, and sub-account isolation as the JSON endpoints; returns the updated contact.
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/calendarsScope: availability:read
Returns all active calendars for the sub-account. The list carries no customer data of its own, so a booking integration no longer has to hold the appointment roster just to discover which calendars exist.
Response:
{
"data": [
{
"id": "cal_abc123",
"name": "Main Calendar",
"type": "personal",
"timezone": "America/New_York"
}
]
}List Appointment Types#
GET /api/v1/calendars/typesScope: availability:read
Query parameters:
calendarId- Optionally filter by a specific calendar
Response:
{
"data": [
{
"id": "apt_type_001",
"calendarId": "cal_abc123",
"name": "30-Minute Consultation",
"duration": 30,
"slug": "30-min-consultation"
}
]
}List Appointments#
GET /api/v1/calendars/appointmentsScope: appointments:read
Each returned appointment includes the linked contact's first name, last name, email, and phone, and no date range is required. Grant this scope only where the caller is meant to see customer details; a booking integration that answers "when are you free?" wants availability:read instead.
Query parameters:
calendarId- Filter by calendarstartDate- Start of date range (Unix timestamp, milliseconds)endDate- End of date range (Unix timestamp, milliseconds)status-scheduled,confirmed,completed,cancelled,no_showlimit- Max results (default 50, max 100)
Check Availability#
GET /api/v1/calendars/availability?appointmentTypeId={id}&date=2026-04-01Scope: availability:read
Query parameters:
appointmentTypeId- The appointment type'sidfromGET /api/v1/calendars/types. Required unlessappointmentTypeis givenappointmentType- The appointment type's name or slug (case-insensitive), as an alternative toappointmentTypeId. A name that matches more than one bookable active type is refused with a400listing the candidates and their IDs rather than one being pickeddate- Date inYYYY-MM-DDformat. Must be a real calendar date, written with two-digit month and dayendDate- Optional last day of a range, inclusive, so a whole week fits in one request. Must be on or afterdateand span at most 31 days. A range spends one request's worth of the key's rate-limit allowance per day, because each day is a separate availability computation
Response:
{
"data": [
{
"start": "2026-04-01T13:00:00.000Z",
"end": "2026-04-01T13:30:00.000Z",
"startTime": 1775048400000,
"endTime": 1775050200000,
"date": "2026-04-01",
"label": "Wednesday, April 1, 2026 at 9:00 AM EDT",
"timezone": "America/New_York",
"available": true
}
],
"meta": {
"timezone": "America/New_York",
"appointmentTypeId": "apt_type_001",
"startDate": "2026-04-01",
"endDate": "2026-04-01",
"days": 1,
"daysReturned": 1,
"truncated": false
}
}Every slot carries the calendar's IANA timezone, its local date, and a preformatted label in that timezone, so a caller never has to turn a raw timestamp into a day and time itself. The startTime / endTime pair is in the exact form the booking endpoint wants back, so a slot can go straight into a booking without conversion. Class calendars that show seat counts also include seatsRemaining per slot.
Only bookable slots are returned; a taken or blocked time is left out entirely, never returned with available: false. An empty data array is not always "nothing free": it is also the answer when nothing resolved (an unknown, inactive, or out-of-scope appointment type), and meta.appointmentTypeId is null in that case. If a range produces more slots than one response carries, it is cut off after whole days and meta.truncated is true.
Availability accounts for: day-of-week schedules, date overrides (blocked days), existing bookings, buffer times, minimum notice, maximum advance, and seat limits. Every slot returned here can be booked; a booking that still fails with slot_unavailable means someone else took the time between your two calls, so read availability again and offer another slot.
Book Appointment#
POST /api/v1/calendars/appointmentsScope: appointments:write
Request body:
{
"appointmentTypeId": "...",
"startTime": 1743508800000,
"endTime": 1743510600000,
"firstName": "Jane",
"lastName": "Doe",
"email": "[email protected]",
"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.bookedwebhook event - Sends confirmation email if configured
Send startTime and endTime exactly as the availability endpoint returned them. When a booking is refused, error.details.reason carries a stable token you can branch on (slot_unavailable, invalid_duration, outside_hours, day_unavailable, date_unavailable, day_full, slot_in_past, too_soon, too_far_ahead); read that rather than the message, which is written for a person.
Store the id from the response. Reading a booking back from GET /api/v1/calendars/appointments needs appointments:read, the scope that also returns every customer's contact details, so a key scoped only for booking deliberately cannot list its own appointments. Keep the id (and the startTime / endTime you sent) at your end; cancelling later needs that id too.
Cancel Appointment#
DELETE /api/v1/calendars/appointments/{id}Scope: appointments:write
Query parameters:
reason- Optional cancellation reason (e.g.,?reason=Customer%20requested)
Response: 200 OK
{
"data": {
"id": "appt_abc123",
"cancelled": true
}
}Conversational Booking (Chatbots and AI Setters)#
The /api/v1/booking/* family exists for keys handed to third-party booking assistants: an AI phone answerer or web chat setter that quotes prices, checks a caller's existing quote, and manages that one caller's own appointment. Every scope in this family is deliberately narrower than the operator-grade scopes above; none of them can reach the appointment roster or the contact list.
List Service Types (Price List)#
GET /api/v1/booking/service-typesScope: service-types:read
Query parameters:
search- Optional match against the service name (up to 100 characters)limit- How many services to return (1 to 100, default 50)
Returns the published price list: name, description, category, folder, price, and currency per service. Only services currently on sale are returned; a retired service is absent rather than flagged, so a bot cannot quote work the business stopped selling. hasVariablePricing: true means the real price depends on quantity or on options the customer picks, so quote price as a starting point and offer a proper estimate. SKUs, images, tax categories, and payment-provider details are deliberately absent.
Response:
{
"data": [
{
"id": "svc_abc123",
"name": "Drain clean",
"description": "Up to 50ft of line, includes camera check",
"category": "Plumbing",
"folder": "Emergency call-outs",
"price": 189,
"currency": "USD",
"hasVariablePricing": false
}
],
"meta": { "total": 1, "hasMore": false, "limit": 50, "search": null }
}Read meta.search before treating an empty data as an answer: null with total: 0 means this location has published no price list at all, while a search string with total: 0 means nothing matched that word.
This is a business read - the same answer whoever asks - so it is safe to serve behind a chat widget that identifies nobody. Make the call from the widget's server, never the browser: a key is not scoped to a single endpoint, and /api/v1/* sends no CORS headers.
List a Contact's Quotes#
GET /api/v1/booking/estimates?contactId={id}Scope: booking-estimates:read
Answers one question for the contact a conversation is already with: does an approved quote exist, what does it total, and is it still valid. Returns up to five quotes, newest first with any approved one first of all. Drafts, and quotes that were declined, expired, or already turned into an invoice, are never returned.
contactId is the only parameter this endpoint accepts, and it must be the CRM contact id your bot platform already holds for the conversation, wired through as a source field variable. Never wire it as a value the AI collects: the AI would fill it from the conversation, and the request would be perfectly valid and about the wrong person. Any value that is not a contact id this CRM could have issued is refused with a 400 that says so, and lookups by email, phone, name, or address are refused outright. The endpoint also declines to answer for a contact who has exchanged no message in the last 24 hours.
Response:
{
"data": [
{
"id": "est_abc123",
"number": "EST-0104",
"status": "accepted",
"isApproved": true,
"total": 2400,
"currency": "USD",
"validUntil": "2026-10-01T00:00:00.000Z",
"isExpired": false,
"approvedAt": "2026-09-01T15:20:00.000Z",
"createdAt": "2026-08-28T09:00:00.000Z"
}
],
"meta": { "total": 1 }
}The response deliberately carries no line items, notes, terms, public link, or signature and payment details - and no title, because quote titles are free text a team types and routinely carry the customer's name and address. The quote is identified by its number instead; the customer already knows what they asked you to quote for.
An unknown contact, a contact belonging to a different location, a deleted contact, and a contact with no live conversation all return a byte-identical 404, deliberately, so the endpoint cannot be used to work out which contacts exist. A known, mid-conversation contact with no quote is a 200 with an empty list, which is how you tell "no quote yet" from a wiring problem.
Verified Customer Appointments#
A caller proves it is already talking to a specific customer, and may then see and change only that customer's appointments. Identify the customer with either the CRM contactId or two matching identifiers from email, phone (E.164), and lastName. Both identifiers must belong to the same contact record, and a last name alone is never enough. Every endpoint here is a POST, including the reads, because the identifiers are personal data and do not belong in URLs, where request logs and proxy logs keep things.
Every wrong combination gets the same answer, on purpose. An unknown contact, one identifier right and the other wrong, two identifiers naming different customers, and a verified customer with nothing booked all return a byte-identical 404. Telling a caller "the email was right, the phone was wrong" confirms the email, and confirming details one request at a time is how a customer list gets guessed through a surface that never returns one. Do not try to give the caller a more specific reason.
Look Up a Customer's Appointments#
POST /api/v1/booking/appointmentsScope: booking-appointments:read
Request body:
{ "email": "[email protected]", "phone": "+15555550123" }| Field | Required | Description |
|---|---|---|
contactId | Alone, or two of the fields below | CRM contact id |
email | Two of these three when no contactId | Matched exactly, including case |
phone | Two of these three when no contactId | E.164 format; normalization does not infer a country code |
lastName | Two of these three when no contactId | Never sufficient on its own |
Returns the customer's full history, including cancelled and past appointments, capped at 20 (meta.hasMore is true when the cap is hit; never read a capped list as "you have 20 visits"). partOfSeries: true on a row means both write endpoints will refuse it and notify an operator instead, so handle it before offering. If you are about to cancel or move something, use the actionable read below instead: an assistant handed the whole history has been observed picking a cancelled record out of it.
Look Up Only Actionable Appointments#
POST /api/v1/booking/appointments/actionableScope: booking-appointments:read (same scope as the full lookup, because it returns strictly less)
Same identification as the full lookup, filtered to the appointments the customer can still cancel or move: cancelled, past, and recurring occurrences are excluded, because those are exactly what the write endpoints refuse. The shape is fixed: data.appointment is non-null only when exactly one appointment is actionable, which is the case you may act on without asking; when several are, it is null and meta.ambiguous is true, so ask the customer which time they mean, by the human-readable label, before calling a write endpoint. Point a booking assistant here before any cancel or reschedule.
Cancel a Verified Customer's Appointment#
POST /api/v1/booking/appointments/cancelScope: booking-appointments:write
Request body: the identification fields above, plus:
| Field | Required | Description |
|---|---|---|
appointmentId | Yes | The appointment to cancel |
confirm | Yes | Must be true |
reason | No | Optional cancellation reason |
confirm: true is required so an assistant that mishears "no, don't cancel" fails the schema rather than the customer. Cancelling frees the time the appointment was holding, including the hold on a connected Google Calendar. The call is idempotent: cancelling an already-cancelled appointment returns 200 with alreadyCancelled: true, so a retry after a timeout is safe. A recurring occurrence is refused with a 400 and the sub-account's admins are notified so a person decides; "cancel my appointment" against a weekly service could mean this Thursday or the whole arrangement.
Reschedule a Verified Customer's Appointment#
POST /api/v1/booking/appointments/rescheduleScope: booking-appointments:write
Request body: the identification fields above, plus:
| Field | Required | Description |
|---|---|---|
appointmentId | Yes | The appointment to move |
newStartTime | Yes | Unix ms, taken verbatim from an availability answer |
newEndTime | Yes | Unix ms, taken verbatim from the same slot |
Take the slot verbatim from the availability answer: the epoch startTime / endTime pair, not the ISO strings and not a reconstructed time. The slot is revalidated server-side, so a time taken since you read it is refused rather than double-booked, and the old slot is released. Refused with a 400 when the slot is taken, when the calendar does not allow customer reschedules, when the appointment has already started, and when it belongs to a recurring series.
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. Opportunities, Tasks & Pipelines API#
Opportunities and tasks are full read and write resources, not webhook events only. You can create, read, update, and delete them the same way you do contacts. Pipelines are read-only, and exist so you can resolve the pipelineId and stageId an opportunity needs.
Data Model#
Opportunity:
| Field | Type | Description |
|---|---|---|
id | string | Unique identifier |
pipelineId | string | Pipeline the opportunity sits in |
stageId | string | Current stage |
contactId | string | Linked contact |
name | string | Opportunity name |
value | number? | Monetary value |
currency | string | ISO 4217 currency code, for example USD |
status | string | open, won, lost, abandoned |
assignedTo | string? | Assigned team member |
tags | array | Tag names |
source | string? | Where the opportunity came from |
expectedCloseDate | number? | Epoch milliseconds |
lostReason | string? | Set when status is lost |
customFields | object | Custom field values |
closedAt | number? | Epoch milliseconds |
Task:
| Field | Type | Description |
|---|---|---|
id | string | Unique identifier |
title | string | Task title (max 500 characters) |
description | string? | Longer detail |
status | string | todo, in_review, completed, cancelled |
priority | string | low, medium, high, urgent |
dueDate | number? | Epoch milliseconds |
completedAt | number? | Epoch milliseconds |
assignedTo | string? | Assigned team member |
contactId | string? | Linked contact |
dealId | string? | Linked opportunity |
Two timestamp formats, split by purpose. Record timestamps (
createdAt,updatedAt) are ISO 8601 everywhere in the API, these resources included. The scheduling fields on opportunities and tasks (dueDate,completedAt,expectedCloseDate,closedAt) are epoch milliseconds, and thedueBefore/dueAfterfilters take epoch milliseconds to match. Read the field type rather than assuming one format.
Cancellation surfaces via
task.updated, not a new event.PATCH /api/v1/tasks/{id}acceptsstatus: "cancelled", and deleting a linked contact cancels the contact's open tasks (the older behavior of marking them completed changed with 5.7.0). Both paths fire atask.updatedwebhook with thecancelledstatus andchangedFields: ["status"]; there is no dedicatedtask.cancelledevent.
Opportunity Endpoints#
| Method | Path | Scope | Description |
|---|---|---|---|
GET | /api/v1/opportunities | opportunities:read | List opportunities |
GET | /api/v1/opportunities/{id} | opportunities:read | Get an opportunity |
POST | /api/v1/opportunities | opportunities:write | Create an opportunity |
PATCH | /api/v1/opportunities/{id} | opportunities:write | Update an opportunity |
PUT | /api/v1/opportunities/{id}/stage | opportunities:write | Move to a different stage |
PUT | /api/v1/opportunities/{id}/status | opportunities:write | Set status (won, lost, and so on) |
DELETE | /api/v1/opportunities/{id} | opportunities:write | Soft-delete an opportunity |
Stage moves and status changes have their own endpoints so that a pipeline move fires the correct automation.
They do not emit the same event, and it is worth knowing which is which before you build a listener:
PUT .../stageemitsopportunity.stage_changed, and only when the stage actually changes. Setting an opportunity to the stage it is already in emits nothing.PUT .../statusemitsopportunity.updated.opportunity.wonandopportunity.lostare not emitted by this endpoint. They exist in the event catalog, but the REST status change routes won and lost into pipeline automation rather than the outbound webhook dispatcher. Listen foropportunity.updatedand read thestatusfield instead.
Move an Opportunity to a New Stage#
PUT /api/v1/opportunities/{id}/stageScope: opportunities:write
Request:
{ "stageId": "stg_abc123" }Task Endpoints#
| Method | Path | Scope | Description |
|---|---|---|---|
GET | /api/v1/tasks | tasks:read | List tasks |
GET | /api/v1/tasks/{id} | tasks:read | Get a task |
POST | /api/v1/tasks | tasks:write | Create a task |
PATCH | /api/v1/tasks/{id} | tasks:write | Update a task |
PUT | /api/v1/tasks/{id}/complete | tasks:write | Mark a task completed |
DELETE | /api/v1/tasks/{id} | tasks:write | Delete a task |
List filters: status, assignedTo, contactId, dealId, dueBefore, dueAfter. The two date filters take epoch milliseconds and match strictly before and strictly after.
Pipeline Endpoints#
| Method | Path | Scope | Description |
|---|---|---|---|
GET | /api/v1/pipelines | opportunities:read | List pipelines and their ordered stages |
Read pipelines first to get the pipelineId and stageId values you need when creating or moving an opportunity.
What Is Not Exposed#
Invoices are not available as REST endpoints. You can subscribe to the invoice.paid webhook event to react to a payment, but there is no /api/v1/invoices resource to read or create invoices. Because you own the source, you can add invoice endpoints yourself. See Adding Your Own Endpoints below.
Adding Your Own Endpoints#
The API is not a closed surface. Every endpoint above is registered in one backend file and built on the same small set of shared helpers, and the developer notes inside your download name both. Adding a public endpoint of your own follows the existing pattern:
- Register the route. Add an
http.route({ path, method, handler })entry. UsepathPrefixwhen the route addresses a record by id or has a nested sub-resource. - Authenticate. Call the shared API key authenticator at the top of the handler. It resolves the key, the agency, and the sub-account, and it handles agency-scoped keys and the
X-Sub-Account-Idheader for you. - Check a scope. Require a scope before doing any work. You can reuse an existing scope or add your own to the valid scope list, after which it becomes selectable when creating a key.
- Rate limit and log. The same helpers that bucket and log every built-in endpoint are available to yours, so your endpoint shows up in API usage reporting alongside the rest.
- Return the standard envelope. Use the shared success and error helpers so your endpoint returns the same
{ data, meta }and{ error }shapes documented above.
Your endpoints live alongside the built-in ones and survive updates as ordinary source changes. Keep them in their own file where practical, so a future update to http.ts is a clean merge.
8. Campaigns API#
Campaigns lets you push a finished HTML email into the CRM and send it (or schedule it) without opening the block-canvas builder. It is a write-only surface - there is no GET /api/v1/campaigns endpoint and no campaigns:read scope. A campaign created this way still shows in the CRM's campaign list, where operators can review it like any other send.
The flow is deliberately two steps: create first (safe to retry, mails no one), then send or schedule (a real fire, not safe to retry). An accidental duplicate POST /api/v1/campaigns costs you one extra draft; an accidental duplicate send would cost you a real inbox delivery.
Data Model#
Campaign (create request):
| Field | Type | Description |
|---|---|---|
name | string | Internal name for the campaign, shown in the CRM's campaign list. Not sent to recipients. |
subject | string | Email subject line. |
preheader | string? | Preview text most inboxes show next to the subject. Optional but strongly recommended. |
contentHtml | string | The BODY HTML for the email. This is the section-specific content that will be placed inside the chosen layout, not a full document. |
layout | string | Name of a saved email template in the same sub-account. The layout provides the header, footer, and branding shell; your contentHtml is spliced into a single {{content}} slot inside it. |
listId | string? | Optional list id the send will target. If omitted, the layout's own default list is used. Whichever wins, it must exist in this sub-account and be active. Sending later fails if no list is resolved by then. |
Campaign (response):
| Field | Type | Description |
|---|---|---|
id | string | New campaign id. Use it in the send / schedule endpoints. |
status | string | draft after create, sending after send, scheduled after schedule. |
HTML-authored campaigns open in the CRM as read-only. A campaign created via the API renders as a preview in the app, not the drag-and-drop block canvas - the HTML is yours, and opening it in the block editor would rewrite it. Operators can still test-send, review recipients, and hit send from the UI.
The layout is resolved by name, not id. If two saved templates in the sub-account share a name, the most recently updated one wins. A layout with no
{{content}}placeholder (or more than one) is rejected aslayout_invalidat create time.
Email only. The send and schedule endpoints refuse anything other than an email campaign - SMS broadcasts require a role capability the API cannot evaluate, and creating an SMS campaign this way is not supported.
Endpoints#
| Method | Path | Scope | Description |
|---|---|---|---|
POST | /api/v1/campaigns | campaigns:write | Create a DRAFT email campaign from a layout plus content HTML |
POST | /api/v1/campaigns/{id}/send | campaigns:write | Send a draft or scheduled campaign immediately |
POST | /api/v1/campaigns/{id}/schedule | campaigns:write | Schedule a draft for a future scheduledAt (epoch milliseconds) |
Create a Draft Campaign#
POST /api/v1/campaignsScope: campaigns:write
Request:
{
"name": "November newsletter",
"subject": "What's new this month",
"preheader": "Product updates, upcoming events, and a special offer inside.",
"contentHtml": "<h1>Hi {{contact.firstName}}!</h1><p>Here's what shipped this month...</p>",
"layout": "Default newsletter layout",
"listId": "el_abc123"
}Response:
{
"data": {
"id": "cmp_xyz789",
"status": "draft"
}
}Errors specific to this endpoint:
layout_not_found: no template named "..."- nothing in this sub-account's saved email templates matches thelayoutname.layout_invalid: template "..." has no bodyHtml- the layout row exists but has an empty body.layout_invalid: layout must contain exactly one {{content}} placeholder- the layout is missing the merge slot the API needs to injectcontentHtml, or it has more than one.list_required: ...- nolistIdwas provided AND the layout has no default list to fall back on.feature_unavailable: campaigns is not enabled on this plan- the sub-account's plan does not include the campaigns module.
Send a Draft Immediately#
POST /api/v1/campaigns/{id}/sendScope: campaigns:write
Request: no body.
Response:
{
"data": {
"id": "cmp_xyz789",
"status": "sending"
}
}The audience is resolved BEFORE the campaign flips to sending, so an empty list is surfaced right here as a 400 rather than moving the campaign to sending and reverting it moments later.
Errors specific to this endpoint:
invalid_status: ...- the campaign is not in a status you can send from (already sending, already sent, cancelled, and so on).channel_unsupported: the API can only send email campaigns (this one is sms)- SMS broadcasts are gated by a role capability the API cannot check.list_required: ...- the campaign has no list attached, OR the list was archived / moved out of this sub-account after create.empty_audience: ...- the resolved audience is zero recipients (for example, every contact on the list is opted out or suppressed).
Schedule a Draft#
POST /api/v1/campaigns/{id}/scheduleScope: campaigns:write
Request:
{ "scheduledAt": 1735689600000 }scheduledAt is epoch milliseconds, not seconds. A 10-digit seconds value is rejected - otherwise it would parse as a date in 1970 and the campaign would be treated as overdue and sent immediately.
Response:
{
"data": {
"id": "cmp_xyz789",
"status": "scheduled"
}
}The same invalid_status / channel_unsupported / list_required errors apply as with send.
What Is Not Exposed#
There is no GET /api/v1/campaigns, and campaigns:read is deliberately NOT a grantable scope - a scope that authorises nothing would be a promise the API cannot keep. If reading a campaign back matters for your integration, you own the source: add the read endpoint and the scope beside it yourself, following Adding Your Own Endpoints.
9. 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/webhooksScope: webhooks:manage
Create Subscription#
POST /api/v1/webhooksScope: webhooks:manage
Request body:
{
"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-secretScope: webhooks:manage
Regenerates the signing secret for a webhook subscription. The old secret is immediately invalidated.
Response:
{
"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#
{
"event": "contact.created",
"timestamp": "2026-03-27T14:30:00.000Z",
"data": {
"subAccountId": "sa_001",
"contactId": "abc123",
"firstName": "Jane",
"lastName": "Doe",
"email": "[email protected]",
"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 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 | Built from the sub-account's display brand, so the receiving system sees your brand rather than the platform name |
Verifying Signatures#
Always verify the X-Webhook-Signature header using timing-safe comparison.
Node.js example:
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-Deliveryheader for idempotency - your server may receive duplicate deliveries
10. 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 a required 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:
{
"contactId": "optional_contact_id",
"email": "[email protected]",
"customData": "any data your workflow needs"
}Required header: X-Webhook-Signature: sha256={hex} - HMAC-SHA256 of the body using the workflow's signing secret. Both workflow endpoints answer 401 and start nothing when the header is missing or wrong, and also when the workflow has no signing secret yet, so create one with the regenerate button next to Signing Secret on the Inbound Webhook trigger before you send.
Response:
{ "ok": true, "triggered": 1 }If more than one active workflow with an Inbound Webhook trigger holds the same URL, the URL is refused: this endpoint and the event endpoint below both answer 404 and start nothing.
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:
{
"eventType": "payment_completed",
"contactId": "optional_contact_id",
"data": { "amount": 99.99 }
}Rate Limits#
Both workflow endpoints are rate-limited on a per-endpoint basis.
11. 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:
{
"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:
{
"data": [
/* next 50 contacts */
],
"meta": {
"total": 150,
"hasMore": true,
"cursor": "eyJpZCI6ImNvbnRhY3RfMTAwIn0"
}
}Continue until hasMore is false:
{
"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
totalfield reflects the total count at the time of the query and may change between pages. meta.totalis the size of the whole result set, not the number of records in the page you are holding. Usemeta.hasMoreto decide whether to fetch again, andtotalto show a count or size a progress bar.
12. Error Examples#
401 Unauthorized - Missing or invalid API key#
{
"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#
{
"error": {
"code": "VALIDATION_ERROR",
"message": "firstName and lastName are required"
}
}403 Forbidden - Missing required scope#
{
"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#
{
"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: 174309120013. 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.
The limits live in source you own. If a high-volume integration genuinely needs more headroom, raise the limits in your own deployment's configuration; they are yours to change, the same way the endpoints are.
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#
-
Verify webhook signatures - Always use timing-safe comparison. Never skip verification in production.
-
Use idempotency keys - Use
X-Webhook-Deliveryto deduplicate webhook events. Track message IDs to prevent duplicate sends. -
Handle retries gracefully - Respond to webhooks promptly. Return
200even for events you don't process. Do heavy work asynchronously. -
Use cursor-based pagination - Use
cursorfrom themetaobject, not offset-basedpagenumbers. Cursors are stable when data changes between requests. -
Respect DND - The system enforces Do-Not-Disturb server-side, but checking DND status before sending prevents silent failures.
-
Use merge fields - Use
{{firstName}},{{email}}, etc. in message bodies. The system resolves them at send time using current contact data. -
Channel constraints:
- SMS: Phone numbers must be E.164 format (
+1XXXXXXXXXX). Messages over 160 characters are segmented. - Email: Provide both
bodyTextandbodyHtmlfor maximum compatibility. - Messenger/Instagram: Subject to Meta's 24-hour messaging window policy.
- SMS: Phone numbers must be E.164 format (
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 |
14. Versioning & Stability#
Current Version#
All endpoints are served under /api/v1/. The X-API-Version: v1 header is included in every response.
Stability#
v1 is the stable, final API surface. There is no v2 coming, and nothing will be removed out from under you: you own the source and you run the deployment, so your copy of the API never changes unless you change it. An update you choose to apply is the only way behavior can move, and when a release does change something a caller can observe (like the 5.8.0 scope rename), the change is listed in the API Changelog and in the CHANGELOG.md that ships in your download, so you can read it before you deploy it.
Recommendations#
- Parse responses tolerantly - ignore unknown fields
- Do not depend on field ordering in JSON responses
- Check the API Changelog periodically for updates
15. Add-On Endpoints#
Installed add-ons can contribute endpoints of their own to your CRM's API, under /api/v1/ext/. They use the same API keys you already issue, obey the same rate limits, and answer in the same response envelope, so nothing new needs setting up. If you have not installed an add-on that uses this, the list below comes back empty and every path under /api/v1/ext/ returns 404.
Discover Contributed Routes#
GET /api/v1/extScope: any valid API key; no particular scope is required.
Returns every route your installed add-ons have contributed, with the scope each one requires and whether the calling key holds it (granted). Routes the calling key cannot reach are still listed with granted: false, so an integrator can tell an operator which permission to tick on the key. This listing is the documentation for contributed endpoints, because the CRM cannot describe an add-on's endpoints in advance: only routes the CRM will actually serve are listed, at the path they are served on, so a path taken off the list can be called as written.
meta.total is the number of routes this install will serve. meta.refused counts add-on routes the CRM refused to serve because their declaration was rejected (a collision with another add-on's route, an attempt to claim one of the CRM's own scopes, an unsupported method). Normally 0; when it is not, the reasons are in your Convex logs and the affected routes return 404, so a short list is never a silent one.
Call a Contributed Endpoint#
GET /api/v1/ext/{namespace}/{path}
POST /api/v1/ext/{namespace}/{path}Scope: the route's own contributed scope, as listed by GET /api/v1/ext. Scopes an add-on contributes appear on the create-key form beside the built-in ones, and an add-on can never claim one of the CRM's own scopes under a name of its own.
Authentication, rate limiting, and the response envelope are identical to every other endpoint on this page; the add-on decides only the shape of data. Paths are matched exactly, and a path that exists only as a GET returns 404 for a POST (and the reverse), so a key holding only a read scope can never reach a write handler. A POST requires Content-Type: application/json and the body is capped at 256 KB (413 over the cap).
Errors are isolated to the add-on. Two statuses are specific to this surface: 502 means the add-on's own handler failed, and 504 means it did not answer in time. Neither indicates a problem with the CRM or with any other endpoint, and neither carries detail about the failure - a crashed handler's message, file paths, and data all go to your Convex logs, never to the caller. The only add-on-written text an error body can carry is the message on a 400, 403, 404, 409, or 422 the handler chose to return, capped at 500 characters. A 500 on this surface always means the CRM, never the add-on.
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 | availability:read | List active calendars |
GET | /api/v1/calendars/types | availability:read | List appointment types (optional calendarId filter) |
GET | /api/v1/calendars/availability | availability:read | Check available slots (single day or a range via endDate) |
GET | /api/v1/calendars/appointments | appointments:read | List appointments (includes customer contact details) |
POST | /api/v1/calendars/appointments | appointments:write | Book appointment |
DELETE | /api/v1/calendars/appointments/{id} | appointments:write | Cancel appointment |
GET | /api/v1/booking/service-types | service-types:read | List the services this location sells, with prices |
GET | /api/v1/booking/estimates | booking-estimates:read | Quotes for the contact a conversation is already with |
POST | /api/v1/booking/appointments | booking-appointments:read | Look up one verified customer's appointments |
POST | /api/v1/booking/appointments/actionable | booking-appointments:read | Look up only what that customer can still act on |
POST | /api/v1/booking/appointments/cancel | booking-appointments:write | Cancel one verified customer's appointment |
POST | /api/v1/booking/appointments/reschedule | booking-appointments:write | Move one verified customer's appointment |
GET | /api/v1/opportunities | opportunities:read | List opportunities |
GET | /api/v1/opportunities/{id} | opportunities:read | Get opportunity |
POST | /api/v1/opportunities | opportunities:write | Create opportunity |
PATCH | /api/v1/opportunities/{id} | opportunities:write | Update opportunity |
PUT | /api/v1/opportunities/{id}/stage | opportunities:write | Move opportunity to another stage |
PUT | /api/v1/opportunities/{id}/status | opportunities:write | Set opportunity status |
DELETE | /api/v1/opportunities/{id} | opportunities:write | Soft-delete opportunity |
GET | /api/v1/pipelines | opportunities:read | List pipelines and their stages |
GET | /api/v1/tasks | tasks:read | List tasks |
GET | /api/v1/tasks/{id} | tasks:read | Get task |
POST | /api/v1/tasks | tasks:write | Create task |
PATCH | /api/v1/tasks/{id} | tasks:write | Update task |
PUT | /api/v1/tasks/{id}/complete | tasks:write | Mark task completed |
DELETE | /api/v1/tasks/{id} | tasks:write | Delete task |
POST | /api/v1/campaigns | campaigns:write | Create a DRAFT email campaign from a saved layout + content HTML |
POST | /api/v1/campaigns/{id}/send | campaigns:write | Send a draft email campaign immediately |
POST | /api/v1/campaigns/{id}/schedule | campaigns:write | Schedule a draft email campaign (scheduledAt, epoch milliseconds) |
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 |
GET | /api/v1/ext | Any valid key | List the routes contributed by installed add-ons |
GET | /api/v1/ext/{namespace}/{path} | Contributed scope (per route) | Call a contributed read endpoint |
POST | /api/v1/ext/{namespace}/{path} | Contributed scope (per route) | Call a contributed write endpoint |
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.
