Skip to main content

API Reference v1

REST + Webhooks integration reference (version 2026-06-15)

v1 · 2026-06-15Beta

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.

  1. Expose a webhook endpoint in your CRM or middleware (no code: use Zapier/Make — see the guide below).
  2. Add a subscription in Heilo (Settings → Integrations) for call.completed (optionally also call.outbound.attempted).
  3. Receive call.completed and verify the signature (Heilo-Signature header, HMAC — see below).
  4. Deduplicate by event_id (heilo-event-id header); data.call_id groups events of the same call.
  5. 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).
ScopeMeaning
read.callsRead calls: GET /api/v1/calls, GET /api/v1/calls/:id
read.recordingsMints a link to a call’s audio through `GET /calls/{id}/recording-url`. Separate from `read.calls` because audio is a different consent from metadata — an existing key does not gain it, you create a new key with explicit recording access.
write.calls, read.contacts, write.contacts, manage.webhooks, manage.api_keysReserved 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 and version

All public endpoints live under /api/v1/. Production:

https://www.heilo.io/api/v1

Current version

v1 · 2026-06-15

The date is the API version identifier (date-stamped), not today's date.

Status

Beta

Heilo API uses a date-stamped version. Only breaking changes bump the major version (v1 → v2). Adding fields or endpoints is non-breaking.

Backwards-compatible: new response fields, new event_types, new endpoints. Breaking change = new major (v2). Old version supported min. 12 months after v2 announcement.

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" }
}
HTTPcodeMeaning
400BAD_REQUESTMalformed query or body parameters (generic validation)
401UNAUTHORIZEDMissing / invalid Bearer token
402SUBSCRIPTION_INACTIVESubscription inactive — renew billing to re-enable the key
403FORBIDDENKey does not have the required scope
404NOT_FOUNDThe resource does not exist or is outside the API key’s organization.
422VALIDATION_ERRORA business rule rejected the request (e.g. invalid phone number, quota)
429RATE_LIMITEDHourly limit exceeded (check Retry-After)
500DATABASE_ERRORServer / database error — safe to retry with backoff
503MAINTENANCEPublic 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.

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.

GET

/api/v1/me

read.callsTry it

API key introspection — returns the key id, user_id, scopes, rate limit. Useful for Zapier/Make "connection test".

Use case: Zapier connection test during custom integration setup. A 200 response proves the key + network work.

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" }
}

Read endpoints for calls. Require the read.calls scope.

GET

/api/v1/calls

read.callsTry it

List the organization's calls. Pagination (page/limit≤100), filters: direction, status, date range (dateFrom/dateTo). Returns has_more.

Query parameters

ParameterTypeValues
pageintfrom 1 (default 1)
limitint1–100 (default 20)
directionenuminbound | outbound
statusenumnew | to_call | contacted | qualified
dateFrom / dateTostringdate YYYY-MM-DD or ISO 8601, e.g. 2026-06-03T12:34:56Z
phonestringthe customer number on the call; spaces, hyphens and the leading + are optional, e.g. 600 100 200
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",
        "customer_phone_national": "600 100 200",
        "company_phone_e164": "+48222630000",
        "app_url": "https://www.heilo.io/calls/3fa85f64-5717-4562-b3fc-2c963f66afa6",
        "caller_name": "Jan Kowalski",
        "duration": 87,
        "crm_status": "new",
        "review_status": null,
        "outbound_lifecycle": null,
        "transcript_processed": { "caller_name": "Jan Kowalski", "summary": "...", "service_needed": "...", "followup_email": null },
        "created_at": "2026-06-03T12:34:56Z"
      }
    ],
    "has_more": false,
    "page": 1,
    "limit": 20
  },
  "meta": { "timestamp": "2026-06-03T12:34:56Z" }
}

Fetch 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..."

A freshly signed link to the call audio, valid for 15 minutes. It is a download permission with a deadline rather than a durable reference: store the `call_id` and ask again, because a link saved in a CRM field is dead within the hour. The link inside a `call.recording.ready` webhook is signed for seven days — this endpoint is how you reach the audio after that.

  • 200 — the link and the moment it stops working.
  • 409 `RECORDING_NOT_READY` — the call is yours and the audio has not arrived yet. Ask again shortly.
  • 409 `RECORDING_WITHHELD` — the call is in the trash or scheduled for deletion. Both of those can be undone, so keep asking rather than writing the recording off on your side.
  • 410 `RECORDING_DELETED` — the retention sweep removed the audio and it cannot be recovered. Stop asking and stop re-queueing.
  • 410 `RECORDING_NOT_CAPTURED` — this outbound call was never recorded and no recording will ever exist for it: the capture gate refused it when it started, it ran in no_recording mode, or it ended without a conversation. Stop asking; there is no reason to retry. An inbound call whose recording was refused keeps answering 409, because a recording can still follow.

The response is served with `Cache-Control: no-store`, because the body carries a credential. Permission is re-checked when the file is fetched, not only when the link is minted, so a call deleted in between stops being readable immediately.

Request
curl https://www.heilo.io/api/v1/calls/<id>/recording-url \
  -H "Authorization: Bearer hk_live_AbC1MnPq..."
Response
{
  "success": true,
  "data": {
    "url": "https://www.heilo.io/api/v1/calls/<id>/recording.mp3?token=...&exp=...",
    "expires_at": "2026-09-15T12:15:00.000Z"
  }
}

The calls list and detail endpoints do not return recording files. You receive the recording link in a webhook, and after it expires from `GET /calls/{id}/recording-url`. Recordings may be deleted under retention and GDPR rules.

What REST carries and what only a webhook carries

The two channels do not carry the same thing, and the difference decides which you pick. REST is durable: you can read a call from it at any time. A webhook is richer, but it arrives once and part of what it carries expires.

DataRESTWebhook
Call metadata: direction, numbers, duration, dateyesyes
CRM status and review statusyesno
Summary, caller name, what the customer needsyesyes
The rest of the analysis: city, address, lead score, preferred date, languagenoyes
The word-for-word transcriptnoyes
Recording linkyes, via /calls/{id}/recording-urlyes, valid 7 days

Hence the practical rule: take the full analysis from the webhook at the moment it arrives, because REST never carried it. The recording is the one thing you can now recover later — ask /calls/{id}/recording-url for a fresh 15-minute link instead of storing the seven-day one from the payload.

Specification and a live request

The same three operations, written for machines: OpenAPI 3.1. Import it into Postman or Insomnia, or generate a client in your own language.

Download OpenAPI 3.1

Specification and a live request

Send one request from here to check that your key works, before you write any code.

This calls the real API with your real key and returns your own data. The key stays in this browser tab — we never store it.

Outbound Webhooks

Heilo sends signed JSON events to your endpoint. Create subscriptions in the Webhook subscriptions card. Activation uses a separate, unsigned webhook.subscription.verify handshake; it must never create business records.

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 retry up to 5 total attempts: after 2 min, 5 min, 30 min and 2 h, with jitter on later retries. After the fifth failure the delivery is dead-lettered.
  • 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.

A 2xx response acknowledges HTTP receipt, not completion of an automation or a CRM write. Deliveries may repeat or arrive out of order. Heilo reconciles recent gaps within a bounded window; this is not unlimited historical recovery. Monitor the Delivery log and your CRM separately.

Verification modes

The unsigned handshake contains only event_type = webhook.subscription.verify and challenge. Reply only to confirm the endpoint; do not enqueue business work. Both modes use this same request:

{
  "event_type": "webhook.subscription.verify",
  "challenge": "example-verification-token"
}

Mode permissive (default)

Any 2xx response activates the subscription; the response body is ignored. This is the default mode for a no-code receiver.

Mode strict (opt-in)

Reply with 2xx and JSON containing exactly the challenge value received from Heilo:

{"challenge":"<echo of the challenge field from Heilo's POST>"}

Choose the mode in the creation form. Changing it requires deleting and recreating the subscription. Subscription-management routes require a logged-in session and CSRF protection; they are not endpoints available with an API key.

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)

Signed events, including the manual webhook.test, use Heilo-Signature with HMAC-SHA256 over timestamp + dot + the original request bytes. Verify the signature before parsing business JSON. Only the endpoint-verification handshake is unsigned.

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 'node:crypto';

// Pass the exact received Buffer (e.g. Express raw parser), never JSON.stringify(req.body).
// signingSecrets: every secret you currently trust, tried in constant time each.
// During a secret rotation pass [newSecret, oldSecret]; remove the old one once
// Heilo's grace window ends. Heilo itself always sends exactly ONE v1 signature —
// this list is what lets your receiver accept it under either secret while both
// are live, never a second signature to parse.
function verifyHeiloSignature(rawBody, header, signingSecrets) {
  const match = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(header || '');
  if (!match) return false;
  const timestamp = Number(match[1]);
  const now = Math.floor(Date.now() / 1000);
  // Receiver policy: at most 10 minutes old or 5 minutes ahead (clock skew).
  if (!Number.isSafeInteger(timestamp) || now - timestamp > 600 || timestamp - now > 300) return false;
  // One string is accepted too. Empty entries are dropped: an HMAC key of ''
  // is valid, so a blanked env var (OLD_SECRET=) would let anyone sign events.
  const secrets = [].concat(signingSecrets).filter((s) => typeof s === 'string' && s.length > 0);
  if (secrets.length === 0) return false;
  const actual = Buffer.from(match[2], 'hex');
  return secrets.some((signingSecret) => {
    const expected = createHmac('sha256', signingSecret)
      .update(match[1] + '.')
      .update(rawBody)
      .digest();
    return actual.length === expected.length && timingSafeEqual(actual, expected);
  });
}

function receiveHeiloWebhook(rawBody, signatureHeader, signingSecrets) {
  if (!signatureHeader) {
    // The ONLY unsigned exception confirms the endpoint; never enqueue it for CRM writes.
    const probe = JSON.parse(rawBody.toString('utf8'));
    if (probe && typeof probe === 'object' && !Array.isArray(probe)
      && Object.keys(probe).length === 2
      && probe.event_type === 'webhook.subscription.verify'
      && typeof probe.challenge === 'string') {
      return { kind: 'verification', response: { challenge: probe.challenge } };
    }
    throw new Error('Missing Heilo-Signature');
  }
  if (!verifyHeiloSignature(rawBody, signatureHeader, signingSecrets)) throw new Error('Invalid Heilo-Signature');
  return { kind: 'event', event: JSON.parse(rawBody.toString('utf8')) };
}

// HTTP adapter: limit body size; map verification to 200 JSON response.
// Signature errors -> 401 (pauses delivery; fix the secret, then Reverify). JSON errors -> 400.
// For kind=event: validate the envelope and chosen event_type, ignore webhook.test
// in business flows, then durably deduplicate/enqueue by event_id BEFORE returning 2xx.
// Enqueue failure: return 503. An HTTP acknowledgement is not proof of a CRM write.

These examples accept signatures up to 600 seconds (10 minutes) old and 300 seconds (5 minutes) ahead, matching the existing Heilo verifier. Synchronize server clocks. Timestamp checks limit the replay window; deduplication by event_id is still required.

The signing secret is shown only once — when the subscription is created. Lost it, or think it leaked? Open the subscription under Settings → Integrations → Webhooks and rotate it there — see "Secret rotation" below. There is no need to delete and recreate the subscription.

Secret rotation

Heilo always sends exactly one v1 signature, never two at once. So a rotation is about order: your receiver learns the new secret first, and only then does Heilo start signing with it.

  1. Prepare a new secret in the subscription's settings. It is shown once, and waits up to 24 hours for activation.
  2. Install the new secret NEXT TO the current one — your receiver must accept a signature made with either.
  3. Optionally send a test signed with the new secret. It carries the Heilo-Secret-Rotation-Id header and an ordinary webhook.test body; a 2xx response does not prove your receiver checked the signature, so confirm that in your own log.
  4. Activate the rotation.
  5. Keep the old secret installed for 1 hour after activation, then remove it.

Heilo changes nothing on your receiver. During that window it is your receiver that must accept a signature made with either of the two secrets.

Emergency: the secret leaked

An emergency rotation pauses sending immediately. Install ONLY the new secret — treat the old one as compromised — then activate; there is no grace window. Resume sending afterwards with Reverify. Deliveries already queued when sending paused are skipped; resend them from the delivery log.

Lost the secret but it did not leak? A standard rotation is enough — no need to delete the subscription.

Event types

Pick event_types when creating a subscription. Each event has a unique event_id (UUID v5) and deduplicates per subscription.

webhook.test is sent by the manual Test action and has data._test = true. Use it to map fields, then exclude it from business processing. The separate activation handshake is webhook.subscription.verify.

event_typeDescription
call.completedCall completed, transcript ready
call.outbound.attemptedOutbound attempt reached a final state (connected or failed)
call.recording.readyRecording file available for download
call.transcribedTranscript ready (separate from call.completed)
call.failedCall failed (busy/no-answer/error)
call.outbound.lifecycle_repairedOutbound lifecycle correction — call state repaired
call.deletion_scheduledCall scheduled for deletion (GDPR Art. 17, retention)
call.recording.deletedRecording deleted (GDPR)
call.followup_email.draftedFollow-up email draft ready
call.tracker.matchedTracker matched
contact.createdNew contact created
contact.updatedContact 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-4f82-4335-988e-4e044fa648d4",
  "event_type": "call.completed",
  "resource_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "created_at": "2026-09-08T10:00:00Z",
  "data": {
    "call_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "direction": "inbound",
    "caller_phone": "+447700900123",
    "customer_phone_e164": "+447700900123",
    "customer_phone_national": "07700 900123",
    "company_phone_e164": "+48222630000",
    "call_created_at": "2026-09-08T10:00:00Z",
    "app_url": "https://www.heilo.io/calls/3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "duration": 87,
    "recording_url": "https://www.heilo.io/api/v1/calls/3fa85f64-5717-4562-b3fc-2c963f66afa6/recording.mp3?token=EXAMPLE&exp=1789466400",
    "transcript_processed": {
      "caller_name": "Alicja Testowa",
      "counterparty_name": null,
      "caller_location": "Warszawa, Mokotów",
      "client_address": "ul. Przykładowa 10",
      "client_city": "Warszawa",
      "client_country": "Poland",
      "service_needed": "Tynki gipsowe w mieszkaniu 65m²",
      "services_match": true,
      "preferred_date": "w przyszłym tygodniu",
      "additional_details": "Klient wspomniał o terminie do końca czerwca. Druga rozmowa po wycenie.",
      "lead_score": 7,
      "summary": "Prośba o wycenę tynków gipsowych w mieszkaniu 65 m² na Mokotowie.",
      "subject": "Wycena tynków — Warszawa, 65 m²",
      "detected_language": "pl"
    },
    "transcript_original": "Proszę o ofertę w przyszłym tygodniu."
  }
}
FieldTypeDescription
call_idstring (uuid)Call ID in Heilo. For business call events it equals resource_id; in webhook.test it is a synthetic call ID.
direction'inbound' | 'outbound'Call direction.
caller_phonestring | nullStored caller number; null if unavailable in call.completed.
customer_phone_e164string | nullCustomer number for matching a CRM contact; null if unavailable in call.completed.
customer_phone_nationalstring | nullThe same customer number in national format, ready to paste into a CRM phone field; null when the stored value is not a usable number, as with a withheld caller ID.
company_phone_e164string | nullThe number this call came in on, or went out from. Usually your Heilo number; on a forwarded call it is your own number that forwarded it. Use it to route notes when you run more than one number; null when not recorded.
call_created_atstring (ISO 8601) | nullWhen the call was recorded in Heilo (ISO 8601). Not the same as the envelope created_at, which is the moment the event was sent and can be days later for a repaired event.
app_urlstring | nullPermanent link to the call in Heilo. Unlike recording_url it does not expire, so it is safe to store in a CRM record. Opening it requires a Heilo login.
durationnumberRecording length in seconds.
recording_urlstring | nullStable link to the recording; null when the recording was not yet stored at processing time.
transcript_processedobjectProcessed call analysis — see the transcript_processed field reference in this section.
transcript_originalstring | nullRaw 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).

call.outbound.attemptedSent 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.
FieldTypeDescription
call_idstring (uuid)Call ID in Heilo. For business call events it equals resource_id; in webhook.test it is a synthetic call ID.
agent_user_idstring (uuid)ID of the Heilo user who placed the call.
customer_phonestringThe dialled customer number.
customer_phone_e164stringCustomer number for matching a CRM contact; null if unavailable in call.completed.
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.
durationnumber | nullCall duration in seconds; null when the call failed at initiation or the duration is not known yet.
attempted_atstring (ISO 8601)When the event was emitted (ISO 8601).
has_recordingbooleantrue only when outbound_lifecycle is completed and the call was not in no_recording mode — the recording then follows as call.recording.ready.
call.outbound.attempted
{
  "api_version": "2026-06-15",
  "event_id": "1bf3a5e2-4f82-4335-988e-4e044fa648d4",
  "event_type": "call.outbound.attempted",
  "resource_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "created_at": "2026-09-08T10:00:00Z",
  "data": {
    "call_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "agent_user_id": "2ce127a0-c621-483d-b576-e68f69d95e84",
    "customer_phone": "+447700900123",
    "customer_phone_e164": "+447700900123",
    "outbound_lifecycle": "completed",
    "duration": 95,
    "attempted_at": "2026-09-08T10:00:00Z",
    "has_recording": true
  }
}
call.recording.readySent when the recording file is available. Use it to fetch or archive the audio.
FieldTypeDescription
call_idstring (uuid)Call ID in Heilo. For business call events it equals resource_id; in webhook.test it is a synthetic call ID.
recording_urlstring | nullStable link to the recording; in rare cases null when the link could not be generated.
durationnumber | nullDuration in seconds; null when not reported yet.
call.recording.ready
{
  "api_version": "2026-06-15",
  "event_id": "1bf3a5e2-4f82-4335-988e-4e044fa648d4",
  "event_type": "call.recording.ready",
  "resource_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "created_at": "2026-09-08T10:00:00Z",
  "data": {
    "call_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "recording_url": "https://www.heilo.io/api/v1/calls/3fa85f64-5717-4562-b3fc-2c963f66afa6/recording.mp3?token=EXAMPLE&exp=1789466400",
    "duration": 87
  }
}
call.transcribedSent in the same processing run as call.completed — it carries only the transcript, without call metadata or the recording link.
FieldTypeDescription
call_idstring (uuid)Call ID in Heilo. For business call events it equals resource_id; in webhook.test it is a synthetic call ID.
transcript_originalstring | nullRaw verbatim transcript; null when unavailable.
transcript_processedobjectProcessed call analysis — see the transcript_processed field reference in this section.
call.transcribed
{
  "api_version": "2026-06-15",
  "event_id": "1bf3a5e2-4f82-4335-988e-4e044fa648d4",
  "event_type": "call.transcribed",
  "resource_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "created_at": "2026-09-08T10:00:00Z",
  "data": {
    "call_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "transcript_original": "Proszę o ofertę w przyszłym tygodniu.",
    "transcript_processed": {
      "caller_name": "Alicja Testowa",
      "counterparty_name": null,
      "caller_location": "Warszawa, Mokotów",
      "client_address": "ul. Przykładowa 10",
      "client_city": "Warszawa",
      "client_country": "Poland",
      "service_needed": "Tynki gipsowe w mieszkaniu 65m²",
      "services_match": true,
      "preferred_date": "w przyszłym tygodniu",
      "additional_details": "Klient wspomniał o terminie do końca czerwca. Druga rozmowa po wycenie.",
      "lead_score": 7,
      "summary": "Prośba o wycenę tynków gipsowych w mieszkaniu 65 m² na Mokotowie.",
      "subject": "Wycena tynków — Warszawa, 65 m²",
      "detected_language": "pl"
    }
  }
}
call.failedSent 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.
FieldTypeDescription
call_idstring (uuid)Call ID in Heilo. For business call events it equals resource_id; in webhook.test it is a synthetic call ID.
outbound_lifecycle'agent_no_answer' | 'customer_no_answer' | 'failed_to_initiate'Which stage of the outbound call failed.
failure_reasonstring | nullMachine-readable failure code (e.g. customer_busy, agent_no_confirmation); can be null.
call.failed
{
  "api_version": "2026-06-15",
  "event_id": "1bf3a5e2-4f82-4335-988e-4e044fa648d4",
  "event_type": "call.failed",
  "resource_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "created_at": "2026-09-08T10:00:00Z",
  "data": {
    "call_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "outbound_lifecycle": "customer_no_answer",
    "failure_reason": "customer_busy"
  }
}
call.outbound.lifecycle_repairedSent 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.
FieldTypeDescription
call_idstring (uuid)Call ID in Heilo. For business call events it equals resource_id; in webhook.test it is a synthetic call ID.
previous_lifecycle'agent_only'State before the correction; currently always agent_only.
new_lifecycle'completed'State after the correction; currently always completed.
repaired_atstring (ISO 8601)When the correction happened (ISO 8601).
call.outbound.lifecycle_repaired
{
  "api_version": "2026-06-15",
  "event_id": "1bf3a5e2-4f82-4335-988e-4e044fa648d4",
  "event_type": "call.outbound.lifecycle_repaired",
  "resource_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "created_at": "2026-09-08T10:00:00Z",
  "data": {
    "call_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "previous_lifecycle": "agent_only",
    "new_lifecycle": "completed",
    "repaired_at": "2026-09-08T10:00:00Z"
  }
}
call.deletion_scheduledGDPR Art. 17: the call is scheduled for deletion. Your CRM should stop using the recording and prepare to delete the data. You are told about a call only if Heilo has a record that its content could have reached your endpoint. Subscribing to the deletion events alone does not make you a recipient — there is nothing to delete if you never received anything.
FieldTypeDescription
call_idstring (uuid)Call ID in Heilo. For business call events it equals resource_id; in webhook.test it is a synthetic call ID.
pending_deletion_atstring (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.deletion_scheduled
{
  "api_version": "2026-06-15",
  "event_id": "1bf3a5e2-4f82-4335-988e-4e044fa648d4",
  "event_type": "call.deletion_scheduled",
  "resource_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "created_at": "2026-09-08T10:00:00Z",
  "data": {
    "call_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "pending_deletion_at": "2026-09-09T10:00:00Z",
    "reason": "user_erasure"
  }
}
call.recording.deletedGDPR: the recording was deleted — recording_url returns 410. Remove or disable the recording link on your side. You are told about a call only if Heilo has a record that its content could have reached your endpoint, so this is not a complete feed of every deletion in your organization. A 2xx from you means the instruction arrived, never that the copy is gone.
FieldTypeDescription
call_idstring (uuid)Call ID in Heilo. For business call events it equals resource_id; in webhook.test it is a synthetic call ID.
reasonstring | nullReason recorded when the deletion was scheduled; can be null.
recording_sidstring | nullTwilio 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_atstring (ISO 8601)When the recording was deleted (ISO 8601).
call.recording.deleted
{
  "api_version": "2026-06-15",
  "event_id": "1bf3a5e2-4f82-4335-988e-4e044fa648d4",
  "event_type": "call.recording.deleted",
  "resource_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "created_at": "2026-09-08T10:00:00Z",
  "data": {
    "call_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "reason": "user_erasure",
    "recording_sid": null,
    "deletion_kind": "twilio_404",
    "deleted_at": "2026-09-08T10:00:00Z"
  }
}
call.followup_email.draftedSent after a two-sided call is processed and Heilo has composed a follow-up email draft from what was agreed. Not sent for voicemail, nor when no draft could be composed. Heilo NEVER sends this email to the caller — the draft is for your rep to review and send.
FieldTypeDescription
call_idstring (uuid)Call ID in Heilo. For business call events it equals resource_id; in webhook.test it is a synthetic call ID.
subjectstringEmail subject, ready to paste.
bodystringEmail body as plain text, WITHOUT a signature — the rep adds their own when sending.
languagestringDraft language: the detected call language, falling back to the account language configured in Heilo (not a per-number setting).
call.followup_email.drafted
{
  "api_version": "2026-06-15",
  "event_id": "1bf3a5e2-4f82-4335-988e-4e044fa648d4",
  "event_type": "call.followup_email.drafted",
  "resource_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "created_at": "2026-09-08T10:00:00Z",
  "data": {
    "call_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "subject": "Oferta po rozmowie",
    "body": "Dzień dobry, przesyłam ustalenia naszej rozmowy.",
    "language": "pl"
  }
}
call.tracker.matchedSent when at least one configured keyword tracker matches the processed transcript. Each match includes terms and excerpts; an absent excerpt is represented by an empty array.
FieldTypeDescription
call_idstring (uuid)Call ID in Heilo. For business call events it equals resource_id; in webhook.test it is a synthetic call ID.
matchesobject[]The trackers matched in this call.
matches[].tracker_idstring (uuid)Identifier of the configured tracker.
matches[].namestringTracker name.
matches[].matched_termsstring[]Matched terms.
matches[].excerptsstring[]Transcript excerpts; may be empty.
call.tracker.matched
{
  "api_version": "2026-06-15",
  "event_id": "1bf3a5e2-4f82-4335-988e-4e044fa648d4",
  "event_type": "call.tracker.matched",
  "resource_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "created_at": "2026-09-08T10:00:00Z",
  "data": {
    "call_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "matches": [
      {
        "tracker_id": "cd794362-6117-44d1-b487-c5e7e1ea9082",
        "name": "Oferta",
        "matched_terms": [
          "ofertę"
        ],
        "excerpts": [
          "Proszę o ofertę w przyszłym tygodniu."
        ]
      }
    ]
  }
}
contact.createdSent when a new contact is created in Heilo. data.contact is a full snapshot of the new contact; notes and tags are not included.
FieldTypeDescription
contactobjectFull snapshot of the new contact (fields below).
contact.idstring (uuid)Contact ID in Heilo.
contact.phonestringThe contact's phone number.
contact.first_namestring | nullnull when not provided.
contact.last_namestring | nullnull when not provided.
contact.emailstring | nullnull when not provided.
contact.companystring | nullnull when not provided.
contact.created
{
  "api_version": "2026-06-15",
  "event_id": "1bf3a5e2-4f82-4335-988e-4e044fa648d4",
  "event_type": "contact.created",
  "resource_id": "86c68698-d54c-43e7-bbbc-328499b98d12",
  "created_at": "2026-09-08T10:00:00Z",
  "data": {
    "contact": {
      "id": "86c68698-d54c-43e7-bbbc-328499b98d12",
      "phone": "+447700900123",
      "first_name": "Alicja",
      "last_name": "Testowa",
      "email": "alicja@example.com",
      "company": null
    }
  }
}
contact.updatedSent when a contact is edited. Unlike contact.created, this is not a snapshot: data.diff contains only the changed fields.
FieldTypeDescription
contact_idstring (uuid)ID of the updated contact.
diffobject (partial)Only the fields that changed — keys absent from diff were not modified.
contact.updated
{
  "api_version": "2026-06-15",
  "event_id": "1bf3a5e2-4f82-4335-988e-4e044fa648d4",
  "event_type": "contact.updated",
  "resource_id": "86c68698-d54c-43e7-bbbc-328499b98d12",
  "created_at": "2026-09-08T10:00:00Z",
  "data": {
    "contact_id": "86c68698-d54c-43e7-bbbc-328499b98d12",
    "diff": {
      "company": "Example",
      "notes": "Oddzwonić",
      "tags": [
        "oferta"
      ]
    }
  }
}

Possible keys in diff: first_name, last_name, email, phone, company, notes, tags

webhook.testSent only when you press Test on a subscription. resource_id is the subscription ID, not a call ID. The data contains a synthetic call.completed payload and the three test markers below. It does not prove that real audio is available.
FieldTypeDescription
call_idstring (uuid)Call ID in Heilo. For business call events it equals resource_id; in webhook.test it is a synthetic call ID.
direction'inbound' | 'outbound'Call direction.
caller_phonestring | nullStored caller number; null if unavailable in call.completed.
customer_phone_e164string | nullCustomer number for matching a CRM contact; null if unavailable in call.completed.
customer_phone_nationalstring | nullThe same customer number in national format, ready to paste into a CRM phone field; null when the stored value is not a usable number, as with a withheld caller ID.
company_phone_e164string | nullThe number this call came in on, or went out from. Usually your Heilo number; on a forwarded call it is your own number that forwarded it. Use it to route notes when you run more than one number; null when not recorded.
call_created_atstring (ISO 8601) | nullWhen the call was recorded in Heilo (ISO 8601). Not the same as the envelope created_at, which is the moment the event was sent and can be days later for a repaired event.
app_urlstring | nullPermanent link to the call in Heilo. Unlike recording_url it does not expire, so it is safe to store in a CRM record. Opening it requires a Heilo login.
durationnumberRecording length in seconds.
recording_urlstring | nullStable link to the recording; null when the recording was not yet stored at processing time.
transcript_processedobjectProcessed call analysis — see the transcript_processed field reference in this section.
transcript_originalstring | nullRaw verbatim transcript; null when unavailable.
_testtrueAlways true — tells the test message apart from real events.
_messagestringHuman-readable note that this is a test.
_sent_atstring (ISO 8601)When the test was sent (ISO 8601).
webhook.test
{
  "api_version": "2026-06-15",
  "event_id": "1bf3a5e2-4f82-4335-988e-4e044fa648d4",
  "event_type": "webhook.test",
  "resource_id": "c2d38cc8-bc1d-4336-91ea-51b8a549a882",
  "created_at": "2026-09-08T10:00:00Z",
  "data": {
    "call_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "direction": "inbound",
    "caller_phone": "+447700900123",
    "customer_phone_e164": "+447700900123",
    "customer_phone_national": "07700 900123",
    "company_phone_e164": "+48222630000",
    "call_created_at": "2026-09-08T10:00:00Z",
    "app_url": "https://www.heilo.io/calls/3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "duration": 87,
    "recording_url": "https://www.heilo.io/api/v1/calls/3fa85f64-5717-4562-b3fc-2c963f66afa6/recording.mp3?token=EXAMPLE&exp=1789466400",
    "transcript_processed": {
      "caller_name": "Alicja Testowa",
      "counterparty_name": null,
      "caller_location": "Warszawa, Mokotów",
      "client_address": "ul. Przykładowa 10",
      "client_city": "Warszawa",
      "client_country": "Poland",
      "service_needed": "Tynki gipsowe w mieszkaniu 65m²",
      "services_match": true,
      "preferred_date": "w przyszłym tygodniu",
      "additional_details": "Klient wspomniał o terminie do końca czerwca. Druga rozmowa po wycenie.",
      "lead_score": 7,
      "summary": "Prośba o wycenę tynków gipsowych w mieszkaniu 65 m² na Mokotowie.",
      "subject": "Wycena tynków — Warszawa, 65 m²",
      "detected_language": "pl"
    },
    "transcript_original": "Proszę o ofertę w przyszłym tygodniu.",
    "_test": true,
    "_message": "Synthetic webhook test; do not create CRM records.",
    "_sent_at": "2026-09-08T10:00:00Z"
  }
}

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.

FieldTypeDescription
caller_namestring | nullThe caller's name, if they gave one.
summarystring | nullShort paragraph summarising the call.
subjectstring | nullOne-line title of the call (up to 80 characters).
service_neededstring | nullWhat the caller asked for.
services_matchboolean | nullWhether the request matches the services you offer.
lead_scorenumber | null (1–10)Lead quality estimate from 1 to 10.
preferred_datestring | nullDate or time the caller mentioned, if any.
client_citystring | nullCity, if mentioned.
client_addressstring | nullStreet address, if mentioned.
additional_detailsstring | nullExtra context from the call.

Fields that may appear

FieldTypeDescription
caller_locationstring | nullGeographic reference detected in the conversation.
client_countrystring | nullCountry, if mentioned.
counterparty_namestring | nullName of the other party — only on outbound calls and live calls Heilo silently listens to (listener mode).
detected_languagestringLanguage code of the conversation (e.g. pl); the key may be absent entirely.
proposal_itemsobject[] | nullSuggested 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.

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)
  • POST /calls — initiate an outbound call through your Heilo number; it is recorded, transcribed and delivered to your CRM like any other call

Need an endpoint? Email support@heilo.io with your use-case — we prioritize the roadmap based on real demand.

Roadmap (v1.1+)

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.