Skip to content
Platform · API

Three route families. One base URL.

HAL's API surface splits by audience: widget flows (public), account routes (your session), and the public v1 API (API keys). Pick the right family and the rest is plain REST + JSON.

Base URL

https://api.chatwithhal.com

Route families

  • /api/widget/* — public flows used by the embeddable widget: conversations, identify, stages, CSAT, automations, articles. Project-scoped by app_id.
  • /api/* (account) — dashboard-grade routes authenticated with a bearer token from login. Full workspace management.
  • /api/v1/* — the public integration API, authenticated with a project API key. Plan-gated and rate-limited per project.

Authentication

# account routes
Authorization: Bearer <access-token>

# public v1 routes
X-API-Key: <project-api-secret>

API keys are per project (Settings → API). The key's project scopes every v1 call — there is no cross-project access.

Public v1: contacts, companies & deals

GET    /api/v1/contacts?search=&limit=&offset=
POST   /api/v1/contacts
GET    /api/v1/contacts/{id}
PATCH  /api/v1/contacts/{id}
DELETE /api/v1/contacts/{id}

GET    /api/v1/companies?search=&stage_id=&limit=&offset=
POST   /api/v1/companies          # name required
GET    /api/v1/companies/{id}
PATCH  /api/v1/companies/{id}

GET    /api/v1/stages
GET    /api/v1/deals?company_id=&contact_id=&stage_id=&status=&search=&limit=&offset=
POST   /api/v1/deals              # company_id required
GET    /api/v1/deals/{id}
PATCH  /api/v1/deals/{id}
DELETE /api/v1/deals/{id}
PUT    /api/v1/deals/{id}/contacts/{contactId}    # { "role": "decision_maker" }
DELETE /api/v1/deals/{id}/contacts/{contactId}

Company writes accept deal_value, stage_id, notes, domain, industry, and is_priority. Both resources accept a flat metadata object — plain scalars only, ≤ 50 keys, ≤ 10KB total, replace-on-write.

Deals accept name, stage_id, amount, deal_type (new_business, expansion, renewal), owner_user_id, expected_close_date, next_step, next_step_due_at, notes, source and lost_reason; status (open, won, lost) follows the stage's probability. A company's stage_id and deal_value mirror its main deal.

Public v1: conversations & events

GET   /api/v1/conversations
GET   /api/v1/conversations/{id}     # includes messages
PATCH /api/v1/conversations/{id}     # status / assignee

POST  /api/v1/events/track

events/track records a product event and — when it matches your automation or health-signal rules — updates pipelines and health signals. Attach user_id, email, or company_id so the event lands on the right customer:

curl -X POST "https://api.chatwithhal.com/api/v1/events/track" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $HAL_API_KEY" \
  -d '{
    "name": "invoice.paid",
    "event_key": "inv_789",          # unique key = idempotent
    "value": 149.0,
    "user_id": "usr_123",
    "company": { "name": "Nova Leaf", "domain": "novaleaf.com" }
  }'

An event's metadata only fills contact properties that are missing or empty, so events never overwrite data saved elsewhere. Send "overwrite_metadata": true to replace existing values.

Webhooks

Manage per-project webhooks via the API. Each delivery POSTs a JSON envelope; when the webhook has a secret, it is signed (see Verifying webhook signatures):

GET/POST /api/v1/webhooks
PUT/DELETE /api/v1/webhooks/{id}

{
  "event": "conversation.created",
  "payload": { "...": "event-specific fields" },
  "timestamp": "2026-01-15T10:30:00.000Z"
}

Twenty-two event types across conversations, messages, contacts, companies, deals (deal.created, deal.updated, deal.stage_changed, deal.deleted), stage changes, visitor and visitor event tracking, identification, and CSAT — including visitor.event_tracked and company.event_tracked so your systems can react to product signals in real time. Queue-based delivery with retries and a 10-second timeout.

Verifying webhook signatures

Deliveries from a webhook with a secret carry X-HAL-Signature: t=<unix seconds>,v1=<signature>. v1 is the hex HMAC-SHA256, keyed with your webhook secret, of the timestamp, a dot and the raw request body ("<t>.<body>"). To verify a delivery:

  1. Read t and every v1 from the header.
  2. Compute the HMAC of t + "." + raw body with your secret and compare it to a v1 in constant time. Use the raw bytes you received, before any JSON parsing.
  3. Reject the delivery if t is more than 5 minutes from your clock. This is what stops a captured delivery from being replayed later.
// Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';

function verifyHalWebhook(header, rawBody, secret, toleranceSeconds = 300) {
  const pairs = header.split(',').map((part) => part.trim().split('='));
  const t = Number(pairs.find(([key]) => key === 't')?.[1]);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = Buffer.from(createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'));
  return pairs.some(([key, value = '']) => {
    const given = Buffer.from(value);
    return key === 'v1' && given.length === expected.length && timingSafeEqual(given, expected);
  });
}
# Python
import hashlib, hmac, time

def verify_hal_webhook(header: str, raw_body: bytes, secret: str, tolerance: int = 300) -> bool:
    pairs = [p.split("=", 1) for p in header.split(",") if "=" in p]
    t = next((v for k, v in pairs if k == "t"), None)
    if t is None or not t.isdigit() or abs(time.time() - int(t)) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(v, expected) for k, v in pairs if k == "v1")

Every delivery also has X-Webhook-Delivery, which stays the same across retries of that delivery: store the ids you have handled and ignore repeats. X-Webhook-Event names the event.

Deprecated: the old X-Webhook-Signature: sha256=<HMAC of the raw body> header has no timestamp, so it can't stop replays. It is still sent alongside X-HAL-Signature until 1 May 2027; move your verification to X-HAL-Signature before then.

Account routes (selection)

GET/PATCH  /api/me                        # profile, preferences
GET        /api/me/data-export            # full GDPR export
DELETE     /api/me                        # request account deletion
CRUD       /api/apps, /api/contacts, /api/crm-stages
CRUD       /api/articles, /api/article-groups
GET        /api/ai/usage | /api/ai/quota
POST       /api/billing/checkout | /portal | /change-plan

Errors & limits

  • Validation failures return 400 with a specific message; plan-gated features return 402 with upgrade context.
  • v1 is rate-limited per project — back off on 429.
  • Every write accepts idempotency where it matters most: events/track dedupes on event_key.