J
API Documentation
Dashboard

External API Reference

REST API for integrating external systems, websites, and automation tools.

Authentication

All API requests require a Bearer token in the Authorization header:

Authorization: Bearer YOUR_PUBLIC_API_TOKEN

Log into see and pre-fill your workspace's token. You can also find it in Settings → API Settings.

Public API TokenAuthenticates every endpoint on this page
Domain Whitelist

Configure allowed origins in Settings → API Settings to restrict API access to specific domains. Requests from non-whitelisted origins will be rejected with 403.

Leaving the whitelist empty allows any origin. Once you add even one domain, requests without an Origin header — which is what curl sends by default — are also rejected. Add the header when testing from the terminal:

-H 'Origin: https://yourdomain.com'
Webhooks

Configure webhook URLs in Settings to receive real-time event notifications. Payloads are sent as JSON POST requests.

booking.createdFires when a booking is created via API
booking.status_changedFires when booking status changes
warranty.createdFires when a warranty is activated via API

Endpoints

POST/api/client/contactsPublic API Token
Create or Update Contact

Create a new contact or update an existing one by phone number (upsert).

  • name and phone are required. All other fields are optional.
  • status: dormant | very_cold | cold | warm | hot | customer (default: cold)
  • source: manual | api | n8n | form (default: api)
  • Deduplication is by phone number within the client account.

Fields

FieldTypeRequiredDescription
namestringrequiredContact full name.
phonestringrequiredPhone number (digits). Used as the upsert/dedupe key.
emailstringoptionalContact email address.
ic_numberstringoptionalNational ID / IC number.
statusenumoptionaldormant | very_cold | cold | warm | hot | customer (default: cold).
branch_slugstringoptionalSlug of the branch to route the contact to.
sourceenumoptionalmanual | api | n8n | form (default: api).

Example Request

curl -X POST 'https://your-crm-domain.com/api/client/contacts' \
  -H 'Authorization: Bearer YOUR_PUBLIC_API_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{"name":"John Doe","phone":"60123456789","email":"[email protected]","ic_number":"901231-14-5555","status":"cold","branch_slug":"kl-branch","source":"api"}'

Request Body

{
  "name": "John Doe",
  "phone": "60123456789",
  "email": "[email protected]",
  "ic_number": "901231-14-5555",
  "status": "cold",
  "branch_slug": "kl-branch",
  "source": "api"
}

Response

{
  "success": true,
  "contact_id": "uuid",
  "status": "cold"
}

Error Responses

422Validation failed
POST/api/client/appointmentsPublic API Token
Create Booking

Create a new appointment/booking. Validates blocked dates and operational hours.

  • phone is required. name is optional (creates contact if provided and phone not found).
  • appointment_date: YYYY-MM-DD. appointment_time: HH:MM (24-hour).
  • duration_minutes defaults to 30 if not provided.
  • source: manual | api | n8n | form
  • Fires booking.created webhook on success.
  • When "Auto block public holidays" is enabled in Settings, bookings on public holidays are rejected.

Fields

FieldTypeRequiredDescription
phonestringrequiredPhone used to find or create the contact.
namestringoptionalContact name — used to create a contact when phone is not found.
emailstringoptionalContact email address.
appointment_datestringrequiredYYYY-MM-DD.
appointment_timestringrequiredHH:MM (24-hour).
duration_minutesnumberoptionalBooking length in minutes (min 15, default 30).
notesstringoptionalInternal note for the booking.
branch_slugstringoptionalSlug of the branch to route the booking to.
sourceenumoptionalmanual | api | n8n | form.

Example Request

curl -X POST 'https://your-crm-domain.com/api/client/appointments' \
  -H 'Authorization: Bearer YOUR_PUBLIC_API_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{"phone":"60123456789","name":"John Doe","email":"[email protected]","appointment_date":"2026-08-15","appointment_time":"10:00","duration_minutes":60,"notes":"Service request","branch_slug":"kl-branch","source":"api"}'

Request Body

{
  "phone": "60123456789",
  "name": "John Doe",
  "email": "[email protected]",
  "appointment_date": "2026-08-15",
  "appointment_time": "10:00",
  "duration_minutes": 60,
  "notes": "Service request",
  "branch_slug": "kl-branch",
  "source": "api"
}

Response

{
  "success": true,
  "appointment_id": "uuid",
  "appointment_code": "APT-2026-0001"
}

Error Responses

409Date is blocked or not within operational hours
409Date is a public holiday (when auto-block is enabled)
409Booking must end before operational closing time
422Validation failed
GET/api/client/appointments/availabilityPublic API Token
Check Availability

Returns available time slots for a given date. Checks blocked days, public holidays, operational hours, and existing bookings.

  • Returns 200 even when the date is unavailable — check the available field.
  • When a date is blocked, a public holiday, or a closed day, slots is an empty array and reason explains why.
  • Slots marked available: false overlap with an existing booking on that date.
  • slot_minutes controls the interval between slots (default 30). Minimum 15, maximum 120.

Fields

FieldTypeRequiredDescription
datequeryrequiredDate to check (YYYY-MM-DD).
branch_slugqueryoptionalFilter by branch slug. Omit to check HQ-level availability.
slot_minutesqueryoptionalSlot duration in minutes (15–120, default 30).

Example Request

curl -X GET 'https://your-crm-domain.com/api/client/appointments/availability?date=2026-08-21' \
  -H 'Authorization: Bearer YOUR_PUBLIC_API_TOKEN'

Response

{
  "date": "2026-08-21",
  "available": true,
  "operational_hours": {
    "start": "09:00",
    "end": "18:00"
  },
  "slot_minutes": 30,
  "slots": [
    { "time": "09:00", "available": true },
    { "time": "09:30", "available": true },
    { "time": "10:00", "available": false },
    { "time": "10:30", "available": true },
    { "time": "11:00", "available": true }
  ]
}

Error Responses

422date query param required (YYYY-MM-DD)
POST/api/client/inquiriesPublic API Token
Create Inquiry

Create a new inquiry or lead linked to a contact.

  • phone is required. All other fields are optional.
  • source: manual | api | n8n | form
  • Contact is auto-created from phone + name if not found.

Fields

FieldTypeRequiredDescription
phonestringrequiredPhone used to find or create the contact.
namestringoptionalContact name — used to create a contact when phone is not found.
emailstringoptionalContact email address.
subjectstringoptionalInquiry subject.
notesstringoptionalInquiry notes / details.
branch_slugstringoptionalSlug of the branch to route the inquiry to.
sourceenumoptionalmanual | api | n8n | form.

Example Request

curl -X POST 'https://your-crm-domain.com/api/client/inquiries' \
  -H 'Authorization: Bearer YOUR_PUBLIC_API_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{"phone":"60123456789","name":"John Doe","email":"[email protected]","subject":"Interested in Jet Ski rental","notes":"Looking for weekend packages","branch_slug":"kl-branch","source":"api"}'

Request Body

{
  "phone": "60123456789",
  "name": "John Doe",
  "email": "[email protected]",
  "subject": "Interested in Jet Ski rental",
  "notes": "Looking for weekend packages",
  "branch_slug": "kl-branch",
  "source": "api"
}

Response

{
  "success": true,
  "inquiry_id": "uuid",
  "inquiry_code": "INQ-2026-0001"
}

Error Responses

422Validation failed
POST/api/client/quotationsPublic API Token
Create Quotation

Create a new quotation with line items. Contact is auto-created or matched by billing phone.

  • billing.name and billing.phone are required. All other billing/shipping fields are optional.
  • title is optional — shown on quotation detail, print, and CSV export.
  • line_items: item_name, quantity, unit_price, line_total are required per item.
  • item_type: product | service | package | custom (optional)
  • Contact is matched by billing.phone; auto-created if not found.
  • Quotation is created with status draft → pending_review.

Fields

FieldTypeRequiredDescription
titlestringoptionalShown on quotation detail, print, and CSV export.
billing.namestringrequiredBilling contact name.
billing.phonestringrequiredBilling phone — used to match or create the contact.
billing.emailstringoptionalBilling email address.
billing.company_namestringoptionalBilling company name.
billing.address_line_1stringoptionalBilling address line 1.
billing.address_line_2stringoptionalBilling address line 2.
billing.citystringoptionalBilling city.
billing.statestringoptionalBilling state.
billing.postcodestringoptionalBilling postcode.
billing.countrystringoptionalBilling country.
shipping.namestringrequiredShipping contact name.
shipping.phonestringrequiredShipping phone.
shipping.addressstringoptionalShipping address (single line).
shipping.address_line_1stringoptionalShipping address line 1.
shipping.address_line_2stringoptionalShipping address line 2.
shipping.citystringoptionalShipping city.
shipping.statestringoptionalShipping state.
shipping.postcodestringoptionalShipping postcode.
shipping.countrystringoptionalShipping country.
shipping.date_timestringoptionalPreferred shipping/delivery date & time.
line_items[].item_typeenumoptionalproduct | service | package | custom.
line_items[].item_namestringrequiredLine item name.
line_items[].descriptionstringoptionalLine item description.
line_items[].quantitynumberrequiredQuantity (>= 0).
line_items[].unitstringoptionalUnit of measure (e.g. hour, unit).
line_items[].unit_pricenumberrequiredUnit price (>= 0).
line_items[].line_totalnumberrequiredTotal for the line (>= 0).
line_items[].metadataobjectoptionalFree-form key/value metadata.
subtotalnumberrequiredSum of line totals (>= 0).
other_charges[].charge_typestringoptionalCharge type/label.
other_charges[].descriptionstringoptionalCharge description.
other_charges[].amountnumberoptionalCharge amount (>= 0).
total_amountnumberrequiredGrand total including charges (>= 0).
notesstringoptionalQuotation notes.
termsstringoptionalQuotation terms & conditions.
valid_untilstringoptionalExpiry date (YYYY-MM-DD).
discountnumberoptionalTotal discount amount (>= 0, default 0).
taxnumberoptionalTotal tax amount (>= 0, default 0).
branch_slugstringoptionalSlug of the branch to route the quotation to.
custom_fieldsobjectoptionalFree-form custom field values.

Example Request

curl -X POST 'https://your-crm-domain.com/api/client/quotations' \
  -H 'Authorization: Bearer YOUR_PUBLIC_API_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{"title":"Jet Ski Rental Package","billing":{"name":"John Doe","phone":"60123456789","email":"[email protected]","company_name":"Acme Sdn Bhd","address_line_1":"123 Main St","address_line_2":"Unit 2","city":"Kuala Lumpur","state":"WP","postcode":"50000","country":"Malaysia"},"shipping":{"name":"John Doe","phone":"60123456789","address":"123 Main St","address_line_1":"123 Main St","address_line_2":"Unit 2","city":"Kuala Lumpur","state":"WP","postcode":"50000","country":"Malaysia","date_time":"2026-08-15 10:00"},"line_items":[{"item_type":"service","item_name":"Jet Ski Rental – 1 Hour","description":"Includes life vest and briefing","quantity":2,"unit":"hour","unit_price":150,"line_total":300,"metadata":{}}],"subtotal":300,"other_charges":[{"charge_type":"Delivery","description":"On-site delivery","amount":18}],"total_amount":318,"discount":0,"tax":18,"notes":"Please arrive 15 minutes early.","terms":"Full payment due before delivery.","valid_until":"2026-09-01","branch_slug":"kl-branch","custom_fields":{}}'

Request Body

{
  "title": "Jet Ski Rental Package",
  "billing": {
    "name": "John Doe",
    "phone": "60123456789",
    "email": "[email protected]",
    "company_name": "Acme Sdn Bhd",
    "address_line_1": "123 Main St",
    "address_line_2": "Unit 2",
    "city": "Kuala Lumpur",
    "state": "WP",
    "postcode": "50000",
    "country": "Malaysia"
  },
  "shipping": {
    "name": "John Doe",
    "phone": "60123456789",
    "address": "123 Main St",
    "address_line_1": "123 Main St",
    "address_line_2": "Unit 2",
    "city": "Kuala Lumpur",
    "state": "WP",
    "postcode": "50000",
    "country": "Malaysia",
    "date_time": "2026-08-15 10:00"
  },
  "line_items": [
    {
      "item_type": "service",
      "item_name": "Jet Ski Rental – 1 Hour",
      "description": "Includes life vest and briefing",
      "quantity": 2,
      "unit": "hour",
      "unit_price": 150.00,
      "line_total": 300.00,
      "metadata": {}
    }
  ],
  "subtotal": 300.00,
  "other_charges": [
    {
      "charge_type": "Delivery",
      "description": "On-site delivery",
      "amount": 18.00
    }
  ],
  "total_amount": 318.00,
  "discount": 0,
  "tax": 18.00,
  "notes": "Please arrive 15 minutes early.",
  "terms": "Full payment due before delivery.",
  "valid_until": "2026-09-01",
  "branch_slug": "kl-branch",
  "custom_fields": {}
}

Response

{
  "success": true,
  "quotation_id": "uuid",
  "quotation_number": "QT-2026-0001",
  "status": "pending_review",
  "saved_to": "branch",
  "branch_slug": "kl-branch",
  "message": "Quotation created and is ready for admin review."
}

Error Responses

422Validation failed
POST/api/client/warranty/lookupPublic API Token
Warranty Lookup

Look up warranties by serial number or by the registered email address.

  • type: serial_number | email. value holds the term to search for.
  • Looking up by email returns every warranty registered to that contact, so the response is always an array.
  • No match returns 200 with an empty warranties array and a message field — not a 404.
  • status: active | expired | claimed | voided
  • Only public-facing fields are returned; internal notes are never exposed.

Fields

FieldTypeRequiredDescription
typeenumrequiredserial_number | email.
valuestringrequiredSerial number or email address to search for.

Example Request

curl -X POST 'https://your-crm-domain.com/api/client/warranty/lookup' \
  -H 'Authorization: Bearer YOUR_PUBLIC_API_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{"type":"serial_number","value":"SKI-2025-0001"}'

Request Body

{
  "type": "serial_number",
  "value": "SKI-2025-0001"
}

Response

{
  "warranties": [
    {
      "warranty_code": "WRN-2025-0001",
      "product_name": "Jet Ski Ultra 310",
      "serial_number": "SKI-2025-0001",
      "sku_number": "SKU-310-BLU",
      "purchase_date": "2025-06-15",
      "warranty_start": "2025-06-15",
      "warranty_end": "2027-06-15",
      "status": "active",
      "contact_name": "John Doe",
      "contact_email": "[email protected]"
    }
  ]
}

Error Responses

422type must be "serial_number" or "email", and value is required
POST/api/client/warranty/activatePublic API Token
Activate Warranty

Register a new warranty for a product by serial number.

  • name, phone, serial_number, and purchase_date are required.
  • purchase_date: YYYY-MM-DD format.
  • product_name, product_id, sku_number, and ic_number are optional.
  • warranty_start is set to purchase_date; HQ sets warranty_end on review.
  • Contact is matched by phone or auto-created, and routed by branch_slug.
  • Fires warranty.created webhook on success.

Fields

FieldTypeRequiredDescription
namestringrequiredCustomer full name.
phonestringrequiredCustomer phone — used to match or create the contact.
emailstringoptionalCustomer email address.
ic_numberstringoptionalCustomer IC number.
product_namestringoptionalProduct name (defaults to "Self-registered product").
product_idstringoptionalProduct identifier / SKU model code.
serial_numberstringrequiredUnique product serial number.
sku_numberstringoptionalProduct SKU number.
purchase_datestringrequiredPurchase date (YYYY-MM-DD).
notesstringoptionalWarranty notes.
branch_slugstringoptionalSlug of the branch to route the warranty to.

Example Request

curl -X POST 'https://your-crm-domain.com/api/client/warranty/activate' \
  -H 'Authorization: Bearer YOUR_PUBLIC_API_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{"name":"John Doe","phone":"60123456789","email":"[email protected]","ic_number":"901231-14-5555","product_name":"Jet Ski Ultra 310","product_id":"JS-ULTRA-310","serial_number":"SKI-2025-0001","sku_number":"SKU-310-BLU","purchase_date":"2025-06-15","notes":"Purchased at KL showroom.","branch_slug":"kl-branch"}'

Request Body

{
  "name": "John Doe",
  "phone": "60123456789",
  "email": "[email protected]",
  "ic_number": "901231-14-5555",
  "product_name": "Jet Ski Ultra 310",
  "product_id": "JS-ULTRA-310",
  "serial_number": "SKI-2025-0001",
  "sku_number": "SKU-310-BLU",
  "purchase_date": "2025-06-15",
  "notes": "Purchased at KL showroom.",
  "branch_slug": "kl-branch"
}

Response

{
  "success": true,
  "warranty_code": "WRN-2026-0001"
}

Error Responses

409A warranty for this serial number is already registered
422Validation failed
GET/api/client/whatsapp/messagesPublic API Token
Read WhatsApp Messages

Returns a chat's messages newest-first, with voice notes resolved to text. Address the chat by sender — the same identifiers the inbound webhook gives you. Use this to give an AI agent the conversation history before it replies.

  • Provide at least one selector. They are tried strongest-first — from_user_id, then phone, then username, then conversation_id — and the first that matches wins. matched_by in the response tells you which one did, so you can spot a fall-through to a weaker match.
  • Prefer from_user_id. The BSUID is issued by the platform and never changes. A phone number matches on its last 9 digits and can be reassigned to a different person; a username is user-chosen and editable.
  • conversation_id is deprecated because it is not stable — the provider may open a second conversation when a manual reply is sent, so an id held by an integration can stop being the current one. It still resolves: every conversation folded into a chat is kept, so an old cnv_ id keeps working.
  • All three preferred selectors arrive on the inbound webhook, so n8n can pass them straight through from the trigger payload.
  • Audio messages arrive from WhatsApp with an empty content and a media_url. This endpoint transcribes them and returns the text in content, with transcribed true — an agent cannot reason over an audio URL.
  • Transcripts are stored after the first call, so polling does not re-bill the same voice note.
  • A voice note that could not be transcribed comes back with transcribed false and a transcription_error, rather than failing the whole page.
  • sent_by names the staff member who sent an outbound message, or "AI" when the agent sent it. It is null on inbound messages.
  • Messages are read from the CRM database, not from the provider, so this keeps working if the provider is unreachable.
  • Requests from server-side automation send no Origin header. Leave whitelisted_domains empty or include "*", or these calls are rejected with 403.

Fields

FieldTypeRequiredDescription
from_user_idqueryoptionalPreferred. The sender's BSUID, exactly as it appears on the webhook at messages[].from_user_id (e.g. MY.1513614423794111).
phonequeryoptionalPhone number in any format. Matched on the last 9 digits.
usernamequeryoptionalFallback. The sender's WhatsApp username, from messages[].username.
conversation_idqueryoptionalDeprecated. A cnv_… id or the CRM thread uuid. Still accepted, but see the notes.
limitqueryoptionalMessages per page, 1–100. Defaults to 10.
beforequeryoptionalCursor from a previous response's next_cursor, to page further back in history.

Example Request

curl -X GET 'https://your-crm-domain.com/api/client/whatsapp/messages?from_user_id=MY.1513614423794111&limit=10' \
  -H 'Authorization: Bearer YOUR_PUBLIC_API_TOKEN'

Response

{
  "matched_by": "from_user_id",
  "data": [
    {
      "id": "msg_abc123",
      "conversation_id": "cnv_abc123",
      "direction": "inbound",
      "type": "audio",
      "content": "Hi, is the jet ski available this Saturday?",
      "media_url": "https://…/voice.ogg",
      "status": "read",
      "sent_by": null,
      "created_at": "2026-08-21T09:12:04Z",
      "transcribed": true
    }
  ],
  "has_more": false,
  "next_cursor": null
}

Error Responses

404Conversation not found
422Provide one of from_user_id, phone, username, or conversation_id
GET/api/client/whatsapp/{phoneNumberId}/modePublic API Token
Check WhatsApp Mode

Returns whether a WhatsApp contact is handled by the AI agent or by a human in the CRM inbox. Call this before generating a reply.

  • Provide one of phone or recipient_id. recipient_id wins if both are sent.
  • mode: ai | human.
  • An unknown number returns mode "ai" with exists false — it is not a 404. New chats default to AI, so there is always an answer.
  • Requests from server-side automation send no Origin header. Leave whitelisted_domains empty or include "*", or these calls are rejected with 403.

Fields

FieldTypeRequiredDescription
phoneNumberIdpathrequiredThe WhatsApp Business phone number ID (kirim_phone_number_id).
phonequeryoptionalPhone number in any format. Matched on the last 9 digits.
recipient_idqueryoptionalBusiness-Scoped User ID (BSUID). Exact match.

Example Request

curl -X GET 'https://your-crm-domain.com/api/client/whatsapp/{phoneNumberId}/mode?recipient_id=US.13491208655302741918' \
  -H 'Authorization: Bearer YOUR_PUBLIC_API_TOKEN'

Response

{
  "mode": "ai",
  "exists": true,
  "contact_id": "uuid",
  "name": "John Doe",
  "phone": "60123456789",
  "recipient_id": "US.13491208655302741918"
}

Error Responses

422Provide either phone or recipient_id
PATCH/api/client/whatsapp/{phoneNumberId}/modePublic API Token
Set WhatsApp Mode

Switches a contact between AI and human handling — use it to hand a conversation to staff when the agent cannot answer.

  • Provide one of phone or recipient_id, plus mode.
  • Unlike GET, an unknown contact is a 404 — there is no row to write to.
  • While a contact is in ai mode the CRM composer is disabled, so staff and the agent cannot reply over each other.

Fields

FieldTypeRequiredDescription
phoneNumberIdpathrequiredThe WhatsApp Business phone number ID (kirim_phone_number_id).
phonestringoptionalPhone number in any format. Matched on the last 9 digits.
recipient_idstringoptionalBusiness-Scoped User ID (BSUID). Exact match.
modeenumrequiredai | human.

Example Request

curl -X PATCH 'https://your-crm-domain.com/api/client/whatsapp/{phoneNumberId}/mode' \
  -H 'Authorization: Bearer YOUR_PUBLIC_API_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{"phone":"60123456789","mode":"human"}'

Request Body

{
  "phone": "60123456789",
  "mode": "human"
}

Response

{
  "mode": "human",
  "contact_id": "uuid",
  "name": "John Doe",
  "phone": "60123456789",
  "recipient_id": "US.13491208655302741918"
}

Error Responses

404Contact not found
422Validation failed