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.comRoute families
/api/widget/*— public flows used by the embeddable widget: conversations, identify, stages, CSAT, automations, articles. Project-scoped byapp_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/trackevents/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:
- Read
tand everyv1from the header. - Compute the HMAC of
t + "." + raw bodywith your secret and compare it to av1in constant time. Use the raw bytes you received, before any JSON parsing. - Reject the delivery if
tis 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-planErrors & 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/trackdedupes onevent_key.