API Reference v1
REST + Webhooks integration reference (version 2026-06-15)
CRM Integration Quickstart
The fastest path to connect a CRM to Heilo. The full API reference is below.
Integrate webhook-first: the call.completed event is the primary data source (it carries the recording link and the processed transcript). REST /calls is a supplement — introspection and reading selected metadata.
- Expose a webhook endpoint in your CRM or middleware (no code: use Zapier/Make — see the guide below).
- Add a subscription in Heilo (Settings → Integrations) for call.completed (optionally also call.outbound.attempted).
- Receive call.completed and verify the signature (Heilo-Signature header, HMAC — see below).
- Deduplicate by event_id (heilo-event-id header); data.call_id groups events of the same call.
- Map the fields to your CRM: find/create a contact by phone, create a lead/deal, and attach an activity/note (summary + recording link).
No code? The no-code connection guide (Zapier/Make) walks through it step by step.
Authentication
Public API uses Bearer tokens. Generate an API key from the "API keys" card on the Integrations page and send it in the header:
Authorization: Bearer hk_live_AbC1MnPq...
Heilo has three auth modes:
- Bearer (API keys hk_live_…) — for Public API. No CSRF, no cookies.
- Session cookies — for the web app (heilo.io). Do NOT use for Public API.
- HMAC-SHA256 — for Webhooks Heilo sends to YOUR endpoint (you verify signature header).
| Scope | Meaning |
|---|---|
| read.calls | Read calls: GET /api/v1/calls, GET /api/v1/calls/:id |
| write.calls, read.contacts, write.contacts, manage.webhooks, manage.api_keys | Reserved for planned API endpoints — don't select them ahead of time. |
The environment field in the /me response is always live today. Test keys are planned.
Base URL
All public endpoints live under /api/v1/. Production:
https://www.heilo.io/api/v1
Endpoints
Every /api/v1 endpoint requires Bearer. /me below is for key introspection; reading calls is in the "Calls" section. When integrating a CRM, treat webhooks as the primary data source — REST is for introspection and reading selected metadata.
/api/v1/meAPI key introspection — returns the key id, user_id, scopes, rate limit. Useful for Zapier/Make "connection test".
Request
curl https://www.heilo.io/api/v1/me \ -H "Authorization: Bearer hk_live_AbC1MnPq..."
Response
{
"success": true,
"data": {
"api_key_id": "a1b2c3d4-5e6f-7081-92a3-b4c5d6e7f809",
"user_id": "5f4e3d2c-1a2b-4c3d-8e9f-0a1b2c3d4e5f",
"organization_id": "7a8b9c0d-1e2f-4a3b-9c8d-7e6f5a4b3c2d",
"scopes": ["read.calls", "manage.webhooks"],
"rate_limit_per_hour": 1000,
"environment": "live"
},
"meta": { "timestamp": "2026-06-03T12:34:56Z" }
}Use case: Zapier connection test during custom integration setup. A 200 response proves the key + network work.
Calls
Read endpoints for calls. Require the read.calls scope.
/api/v1/callsList the organization's calls. Pagination (page/limit≤100), filters: direction, status, date range (dateFrom/dateTo). Returns has_more.
Query parameters
| Parameter | Type | Values |
|---|---|---|
| page | int | from 1 (default 1) |
| limit | int | 1–100 (default 20) |
| direction | enum | inbound | outbound |
| status | enum | new | to_call | contacted | qualified |
| dateFrom / dateTo | string | date YYYY-MM-DD or ISO 8601, e.g. 2026-06-03T12:34:56Z |
Request
curl "https://www.heilo.io/api/v1/calls?limit=20&direction=inbound" \ -H "Authorization: Bearer hk_live_AbC1MnPq..."
Response
{
"success": true,
"data": {
"items": [
{
"call_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"direction": "inbound",
"caller_phone": "+48600100200",
"customer_phone_e164": "+48600100200",
"caller_name": "Jan Kowalski",
"duration": 87,
"crm_status": "new",
"review_status": null,
"outbound_lifecycle": null,
"transcript_processed": { "caller_name": "Jan Kowalski", "summary": "...", "service_needed": "..." },
"created_at": "2026-06-03T12:34:56Z"
}
],
"has_more": false,
"page": 1,
"limit": 20
},
"meta": { "timestamp": "2026-06-03T12:34:56Z" }
}/api/v1/calls/:idFetch a single call by id. 404 if it doesn't belong to the key's organization or was deleted.
Request
curl https://www.heilo.io/api/v1/calls/<id> \ -H "Authorization: Bearer hk_live_AbC1MnPq..."
Recording files are NOT exposed via the API — they are erased per legal retention (GDPR). The API returns call metadata and transcript text.
Rate limits
Hourly limits per-key and per-user (sum of all keys). Reset on the UTC hour. Every request counts, regardless of response status.
Per key
1000 req/h
Per account (sum of keys)
5000 req/h
When exceeded we return 429 with Retry-After header (seconds until reset):
HTTP/1.1 429 Too Many Requests Retry-After: 1842 X-RateLimit-Limit: 1000 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 2026-06-03T13:00:00Z
Errors
All errors return a uniform JSON shape with error.code (stable) and error.message (human-readable, may change). Log the code, not the message.
{
"success": false,
"error": { "code": "RATE_LIMITED", "message": "Per-key rate limit 1000/h exceeded" },
"meta": { "timestamp": "2026-06-03T12:34:56Z" }
}| HTTP | code | Meaning |
|---|---|---|
| 400 | BAD_REQUEST | Malformed query or body parameters (generic validation) |
| 401 | UNAUTHORIZED | Missing / invalid Bearer token |
| 402 | SUBSCRIPTION_INACTIVE | Subscription inactive — renew billing to re-enable the key |
| 403 | FORBIDDEN | Key does not have the required scope |
| 404 | NOT_FOUND | Resource not found (or belongs to another user) |
| 422 | VALIDATION_ERROR | A business rule rejected the request (e.g. invalid phone number, quota) |
| 429 | RATE_LIMITED | Hourly limit exceeded (check Retry-After) |
| 500 | DATABASE_ERROR | Server / database error — safe to retry with backoff |
| 503 | MAINTENANCE | Public API temporarily disabled (kill switch) |
The SUBSCRIPTION_INACTIVE code appears in two situations: HTTP 402 — your Heilo subscription has lapsed (billing), and HTTP 409 — the webhook subscription is paused (e.g. on the Test action); in that case click "Reverify" first.
Outbound Webhooks
Heilo POSTs JSON to your endpoint after each event. Subscriptions are created from the "Webhook subscriptions" card — requires a handshake on activation. Every request includes an HMAC signature you must verify:
POST <your URL>
content-type: application/json
heilo-signature: t=1717423396,v1=4f3a...
heilo-event-id: 1bf3a5e2-...
heilo-event-type: call.completed
{
"api_version": "2026-06-15",
"event_id": "1bf3a5e2-...",
"event_type": "call.completed",
"resource_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"created_at": "2026-06-03T12:34:56Z",
"data": { /* see the setup guide for the full schema */ }
}Retry and pause policy:
- Transient errors (HTTP 408/429/5xx, network) retry with exponential backoff (2min, 5min, 30min, 2h) — then dead-letter after 5 attempts.
- Permanent errors (HTTP 401/403/422) pause the subscription immediately — no retries.
- 50 consecutive transient failures, or 2 consecutive HTTP 410 Gone (e.g. deleted Make scenario), also pause the subscription.
- Resume a paused subscription with "Reverify" — a fresh handshake reactivates the queue.
When a subscription is paused automatically, we send an e-mail to the account owner. You can resend dead-lettered events with the "Resend" button in the Delivery log — once the subscription passes reverification.
Verification modes
Heilo supports two activation models for webhook subscriptions. The default (permissive) fits Zapier / Make / typical CRM endpoints. Strict matches the Slack Events API model — useful for custom servers that can synchronously echo the challenge.
Mode permissive (default)
Heilo POSTs webhook.subscription.verify to your URL. Any 2xx response activates the subscription. Response body is ignored. This matches the Stripe / GitHub / Twilio model.
Mode strict (opt-in)
Heilo POSTs webhook.subscription.verify with a challenge field. Your endpoint MUST reply with 2xx and a JSON body:
{"challenge":"<echo of the challenge field from Heilo's POST>"}You pick the mode at subscription creation (verification_mode in POST /api/v1/webhook-subscriptions, default permissive). Changing mode post-creation requires delete + recreate — intentional, as it changes the contract semantics.
Limits: max 20 active subscriptions per account (env-overridable). A subscription is auto-paused after 50 consecutive transient failures or 2 consecutive HTTP 410, and immediately on HTTP 401/403/422.
HMAC verification (signing_secret)
Stripe-compatible format. signed_string = "<unix_ts>.<raw_body>" (dot-separated). Always verify the RAW request body (before JSON parsing) — any normalization changes the signature.
signed_string = "<unix_timestamp>.<raw_request_body>" signature = HMAC-SHA256(signing_secret, signed_string).hex() header = "t=<unix_timestamp>,v1=<signature>"
import { createHmac, timingSafeEqual } from 'crypto';
// rawBody MUST be the exact received bytes — do NOT re-serialize the JSON.
function verifyHeiloSignature(rawBody, header, signingSecret, toleranceSec = 300) {
if (!header) return false;
const parts = Object.fromEntries(
header.split(',').map((p) => p.split('=').map((s) => s.trim()))
);
const t = Number(parts.t);
const sig = parts.v1;
if (!Number.isFinite(t) || !sig) return false;
// Asymmetric tolerance: reject old replays; allow only small clock skew ahead.
const now = Math.floor(Date.now() / 1000);
if (now - t > toleranceSec || t - now > 60) return false;
const expected = createHmac('sha256', signingSecret)
.update(`${t}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(sig, 'utf8');
// timingSafeEqual THROWS on length mismatch — length-check first.
return a.length === b.length && timingSafeEqual(a, b);
}Default 300s (5 min) tolerance prevents replay attacks. Server clocks must be NTP-synced.
The signing secret is shown only once — when the subscription is created. If you lose it or suspect a leak: delete the subscription and create it again with the same URL (the address in Zapier/Make stays the same). In-place secret rotation is planned.
Event types
Pick event_types when creating a subscription. Each event has a unique event_id (UUID v5) and deduplicates per subscription.
The first event you receive is webhook.test — a test message with data._test = true. Use it to map your fields or filter it out.
| event_type | Description |
|---|---|
| call.completed | Call completed, transcript ready |
| call.outbound.attempted | Outbound dial attempted (before connect) |
| call.recording.ready | Recording file available for download |
| call.transcribed | Transcript ready (separate from call.completed) |
| call.failed | Call failed (busy/no-answer/error) |
| call.outbound.lifecycle_repaired | Outbound lifecycle correction — call state repaired |
| call.deletion_scheduled | Call scheduled for deletion (GDPR Art. 17, retention) |
| call.recording.deleted | Recording deleted (GDPR) |
| contact.created | New contact created |
| contact.updated | Contact updated |
Below is the data object of every event. Field names, types and enum values are part of the API contract and do not change meaning within v1; new optional fields may be added over time.
call.completed
Sent after the recording and transcript of a completed call are processed. The primary data source for a CRM — full payload (including recording_url and the processed transcript).
{
"api_version": "2026-06-15",
"event_id": "1bf3a5e2-...", // same value as heilo-event-id header
"event_type": "call.completed",
"resource_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"created_at": "2026-06-03T12:34:56Z",
"data": {
"call_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"direction": "inbound",
"caller_phone": "+48600100200",
"customer_phone_e164": "+48600100200",
"duration": 87,
"recording_url": "https://www.heilo.io/api/v1/calls/.../recording.mp3?token=...&exp=...",
"transcript_processed": { "caller_name": "Jan Kowalski", "summary": "...", "service_needed": "..." },
"transcript_original": "..."
}
}| Field | Type | Description |
|---|---|---|
| call_id | string (uuid) | Call ID in Heilo — same value as resource_id; links all events of one call. |
| direction | 'inbound' | 'outbound' | Call direction. |
| caller_phone | string | Phone number of the other party, as stored on the call. |
| customer_phone_e164 | string | The same number again — an alias kept for easier field mapping. |
| duration | number | Recording length in seconds. |
| recording_url | string | null | Stable link to the recording; null when the recording was not yet stored at processing time. |
| transcript_processed | object | Processed call analysis — see the transcript_processed field reference in this section. |
| transcript_original | string | null | Raw verbatim transcript; null when unavailable. |
Delivery is at-least-once and not order-guaranteed — persist idempotently. Dedup key: event_id. data.call_id links events of the same call (e.g. call.completed after call.recording.ready).
transcript_processed — field reference
The stable subset you can rely on when mapping to a CRM. Every field is optional — it is null when the call did not contain that information.
| Field | Type | Description |
|---|---|---|
| caller_name | string | null | The caller's name, if they gave one. |
| summary | string | null | Short paragraph summarising the call. |
| subject | string | null | One-line title of the call (up to 80 characters). |
| service_needed | string | null | What the caller asked for. |
| services_match | boolean | null | Whether the request matches the services you offer. |
| lead_score | number | null (1–10) | Lead quality estimate from 1 to 10. |
| preferred_date | string | null | Date or time the caller mentioned, if any. |
| client_city | string | null | City, if mentioned. |
| client_address | string | null | Street address, if mentioned. |
| additional_details | string | null | Extra context from the call. |
Fields that may appear
| Field | Type | Description |
|---|---|---|
| caller_location | string | null | Geographic reference detected in the conversation. |
| client_country | string | null | Country, if mentioned. |
| counterparty_name | string | null | Name of the other party — outbound and conversation-mode calls only. |
| detected_language | string | Language code of the conversation (e.g. pl); the key may be absent entirely. |
| proposal_items | object[] | null | Suggested follow-ups and decisions extracted from the call. |
The analysis may include additional fields — treat unknown fields as optional and never assume they are present.
call.outbound.attempted
Sent when an outbound dial reaches a final state — including failures. completed means the call connected and ended normally; the recording and transcript follow as separate events.
| Field | Type | Description |
|---|---|---|
| call_id | string (uuid) | Call ID in Heilo — same value as resource_id; links all events of one call. |
| agent_user_id | string (uuid) | ID of the Heilo user who placed the call. |
| customer_phone | string | The dialled customer number. |
| customer_phone_e164 | string | The same number again — an alias kept for easier field mapping. |
| outbound_lifecycle | 'completed' | 'agent_no_answer' | 'customer_no_answer' | 'failed_to_initiate' | Final state that triggered the event; completed means the call connected and ended normally. |
| duration | number | null | Call duration in seconds; null when the call failed at initiation or the duration is not known yet. |
| attempted_at | string (ISO 8601) | When the event was emitted (ISO 8601). |
| has_recording | boolean | true only when outbound_lifecycle is completed — the recording then follows as call.recording.ready. |
call.recording.ready
Sent when the recording file is available. Use it to fetch or archive the audio.
| Field | Type | Description |
|---|---|---|
| call_id | string (uuid) | Call ID in Heilo — same value as resource_id; links all events of one call. |
| recording_url | string | null | Stable link to the recording; in rare cases null when the link could not be generated. |
| duration | number | null | Duration in seconds; null when not reported yet. |
call.transcribed
Sent in the same processing run as call.completed — it carries only the transcript, without call metadata or the recording link.
| Field | Type | Description |
|---|---|---|
| call_id | string (uuid) | Call ID in Heilo — same value as resource_id; links all events of one call. |
| transcript_original | string | null | Raw verbatim transcript; null when unavailable. |
| transcript_processed | object | Processed call analysis — see the transcript_processed field reference in this section. |
call.failed
Sent when an outbound call did not complete (no answer, busy, initiation error). Emitted for outbound calls only. Usually not worth creating a lead — log a contact attempt instead.
| Field | Type | Description |
|---|---|---|
| call_id | string (uuid) | Call ID in Heilo — same value as resource_id; links all events of one call. |
| outbound_lifecycle | 'agent_no_answer' | 'customer_no_answer' | 'failed_to_initiate' | Which stage of the outbound call failed. |
| failure_reason | string | null | Machine-readable failure code (e.g. customer_busy, agent_no_confirmation); can be null. |
call.outbound.lifecycle_repaired
Sent when Heilo retroactively corrects the state of an outbound call (a late carrier callback proved the call did connect). Update the call state on your side.
| Field | Type | Description |
|---|---|---|
| call_id | string (uuid) | Call ID in Heilo — same value as resource_id; links all events of one call. |
| previous_lifecycle | 'agent_only' | State before the correction; currently always agent_only. |
| new_lifecycle | 'completed' | State after the correction; currently always completed. |
| repaired_at | string (ISO 8601) | When the correction happened (ISO 8601). |
call.deletion_scheduled
GDPR Art. 17: the call is scheduled for deletion. Your CRM should stop using the recording and prepare to delete the data.
| Field | Type | Description |
|---|---|---|
| call_id | string (uuid) | Call ID in Heilo — same value as resource_id; links all events of one call. |
| pending_deletion_at | string (ISO 8601) | When the data will be permanently deleted (ISO 8601). |
| reason | 'consent_not_asked' | 'consent_withdrawn' | 'retention_expired' | 'user_erasure' | Why the deletion was scheduled. |
call.recording.deleted
GDPR: the recording was deleted — recording_url returns 410. Remove or disable the recording link on your side.
| Field | Type | Description |
|---|---|---|
| call_id | string (uuid) | Call ID in Heilo — same value as resource_id; links all events of one call. |
| reason | string | null | Reason recorded when the deletion was scheduled; can be null. |
| recording_sid | string | null | Twilio recording ID; null when it could not be resolved. |
| deletion_kind | 'hard_deleted' | 'twilio_404' | hard_deleted = deleted by Heilo; twilio_404 = the file was already gone on Twilio's side. |
| deleted_at | string (ISO 8601) | When the recording was deleted (ISO 8601). |
contact.created
Sent when a new contact is created in Heilo. data.contact is a full snapshot of the new contact; notes and tags are not included.
| Field | Type | Description |
|---|---|---|
| contact | object | Full snapshot of the new contact (fields below). |
| contact.id | string (uuid) | Contact ID in Heilo. |
| contact.phone | string | The contact's phone number. |
| contact.first_name | string | null | null when not provided. |
| contact.last_name | string | null | null when not provided. |
| contact.email | string | null | null when not provided. |
| contact.company | string | null | null when not provided. |
contact.updated
Sent when a contact is edited. Unlike contact.created, this is not a snapshot: data.diff contains only the changed fields.
| Field | Type | Description |
|---|---|---|
| contact_id | string (uuid) | ID of the updated contact. |
| diff | object (partial) | Only the fields that changed — keys absent from diff were not modified. |
Possible keys in diff: first_name, last_name, email, phone, company, notes, tags
webhook.test
The first message on a new subscription and on every manual test. resource_id is the subscription ID (not a call ID); the remaining data fields mirror the call.completed example with sample values.
| Field | Type | Description |
|---|---|---|
| _test | true | Always true — tells the test message apart from real events. |
| _message | string | Human-readable note that this is a test. |
| _sent_at | string (ISO 8601) | When the test was sent (ISO 8601). |
Versioning & roadmap
Versioning
Heilo API uses a date-stamped version. Only breaking changes bump the major version (v1 → v2). Adding fields or endpoints is non-breaking.
Current version
v1 · 2026-06-15
The date is the API version identifier (date-stamped), not today's date.
Status
Beta
Backwards-compatible: new response fields, new event_types, new endpoints. Breaking change = new major (v2). Old version supported min. 12 months after v2 announcement.
Roadmap (v1.1+)
Endpoints planned for upcoming v1.X releases. Not a hard commitment — direction depends on feedback.
- GET /contacts — list contacts
- POST /contacts — create contact (sync from CRM into Heilo)
Need an endpoint? Email support@heilo.io with your use-case — we prioritize the roadmap based on real demand.
Manage keys and webhooks in the panel
Generate API keys, add webhook subscriptions and watch the delivery log after signing in.