# API Reference v1

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

Machine-readable mirror of the Heilo API reference, generated from the same data as the page. Human-readable version: https://www.heilo.io/docs/api

- **Current version**: v1 · 2026-06-15
- **Status**: Beta
- **Base URL**: `https://www.heilo.io/api/v1`
- **OpenAPI 3.1**: https://www.heilo.io/openapi.json

---

## 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).

## Authentication

Public API uses Bearer tokens. Generate an API key from the ["API keys" card](https://www.heilo.io/settings/integrations#api-keys) on the Integrations page and send it in the header:

```http
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 |
| `read.recordings` | Mints 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_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 and version

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

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

### Versioning

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

```json
{
  "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` | The resource does not exist or is outside the API key’s organization. |
| 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.

## 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.

### `GET /api/v1/me`

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

**Request**

```bash
curl https://www.heilo.io/api/v1/me \
  -H "Authorization: Bearer hk_live_AbC1MnPq..."
```

**Response**

```json
{
  "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.

### `GET /api/v1/calls`

List 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 |
| `phone` | `string` | the customer number on the call; spaces, hyphens and the leading + are optional, e.g. 600 100 200 |

**Request**

```bash
curl "https://www.heilo.io/api/v1/calls?limit=20&direction=inbound" \
  -H "Authorization: Bearer hk_live_AbC1MnPq..."
```

**Response**

```json
{
  "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"
  }
}
```

### `GET /api/v1/calls/{id}`

Fetch a single call by id. 404 if it doesn't belong to the key's organization or was deleted.

**Request**

```bash
curl https://www.heilo.io/api/v1/calls/<id> \
  -H "Authorization: Bearer hk_live_AbC1MnPq..."
```

### `GET /api/v1/calls/{id}/recording-url`

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**

```bash
curl https://www.heilo.io/api/v1/calls/<id>/recording-url \
  -H "Authorization: Bearer hk_live_AbC1MnPq..."
```

**Response**

```json
{
  "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.

## 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.

```http
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": {}
}
```

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:

```json
{
  "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:

```json
{"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>"
```

**Node.js (Express, raw body parser):**

```javascript
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.
```

**Python (Flask / FastAPI):**

```python
import hmac, hashlib, time, re, json

# raw_body must be bytes (e.g. Flask request.get_data()), before JSON parsing.
# signing_secrets: every secret you currently trust, tried in constant time each.
# During a secret rotation pass [new_secret, old_secret]; 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.
def verify_heilo_signature(raw_body: bytes, header: str, signing_secrets):
    # One string is accepted too. Empty entries are dropped: an HMAC key of b''
    # is valid, so a blanked env var (OLD_SECRET=) would let anyone sign events.
    if isinstance(signing_secrets, str):
        signing_secrets = [signing_secrets]
    secrets = [s for s in (signing_secrets or []) if isinstance(s, str) and s]
    if not secrets:
        return False
    match = re.fullmatch(r't=([0-9]+),v1=([a-f0-9]{64})', header or '')
    if not match:
        return False
    try:
        timestamp = int(match[1])
    except ValueError:
        return False
    now = int(time.time())
    # Receiver policy: at most 10 minutes old or 5 minutes ahead (clock skew).
    if now - timestamp > 600 or timestamp - now > 300:
        return False
    return any(
        hmac.compare_digest(
            hmac.new(
                signing_secret.encode('utf-8'), match[1].encode('ascii') + b'.' + raw_body,
                hashlib.sha256,
            ).hexdigest(),
            match[2],
        )
        for signing_secret in secrets
    )

def receive_heilo_webhook(raw_body: bytes, signature_header: str, signing_secrets):
    if not signature_header:
        probe = json.loads(raw_body)
        if (isinstance(probe, dict) and set(probe) == {'event_type', 'challenge'}
            and probe['event_type'] == 'webhook.subscription.verify'
            and isinstance(probe['challenge'], str)):
            return {'kind': 'verification', 'response': {'challenge': probe['challenge']}}
        raise ValueError('Missing Heilo-Signature')
    if not verify_heilo_signature(raw_body, signature_header, signing_secrets):
        raise ValueError('Invalid Heilo-Signature')
    return {'kind': 'event', 'event': json.loads(raw_body)}

# HTTP adapter: limit body size; verification -> 200 JSON; JSON error -> 400.
# Signature error -> 401 (pauses delivery; fix the secret, then Reverify).
# For events: validate envelope/type, exclude webhook.test from CRM writes,
# durably deduplicate/enqueue by event_id before 2xx; enqueue failure -> 503.
```

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_type | Description |
| --- | --- |
| `call.completed` | Call completed, transcript ready |
| `call.outbound.attempted` | Outbound attempt reached a final state (connected or failed) |
| `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) |
| `call.followup_email.drafted` | Follow-up email draft ready |
| `call.tracker.matched` | Tracker matched |
| `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).

| Field | Type | Description |
| --- | --- | --- |
| `call_id` | `string (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_phone` | `string \| null` | Stored caller number; null if unavailable in call.completed. |
| `customer_phone_e164` | `string \| null` | Customer number for matching a CRM contact; null if unavailable in call.completed. |
| `customer_phone_national` | `string \| null` | The 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_e164` | `string \| null` | The 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_at` | `string (ISO 8601) \| null` | When 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_url` | `string \| null` | Permanent 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. |
| `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. |

```json
{
  "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."
  }
}
```

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 — only on outbound calls and live calls Heilo silently listens to (listener mode). |
| `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. For business call events it equals resource_id; in webhook.test it is a synthetic call ID. |
| `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` | Customer 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. |
| `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 and the call was not in no_recording mode — the recording then follows as call.recording.ready. |

```json
{
  "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.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. For business call events it equals resource_id; in webhook.test it is a synthetic call ID. |
| `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. |

```json
{
  "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.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. For business call events it equals resource_id; in webhook.test it is a synthetic call ID. |
| `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. |

```json
{
  "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.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. 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_reason` | `string \| null` | Machine-readable failure code (e.g. customer_busy, agent_no_confirmation); can be null. |

```json
{
  "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_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. 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_at` | `string (ISO 8601)` | When the correction happened (ISO 8601). |

```json
{
  "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_scheduled`

GDPR 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.

| Field | Type | Description |
| --- | --- | --- |
| `call_id` | `string (uuid)` | Call ID in Heilo. For business call events it equals resource_id; in webhook.test it is a synthetic call ID. |
| `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. |

```json
{
  "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.deleted`

GDPR: 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.

| Field | Type | Description |
| --- | --- | --- |
| `call_id` | `string (uuid)` | Call ID in Heilo. For business call events it equals resource_id; in webhook.test it is a synthetic call ID. |
| `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). |

```json
{
  "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.drafted`

Sent 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.

| Field | Type | Description |
| --- | --- | --- |
| `call_id` | `string (uuid)` | Call ID in Heilo. For business call events it equals resource_id; in webhook.test it is a synthetic call ID. |
| `subject` | `string` | Email subject, ready to paste. |
| `body` | `string` | Email body as plain text, WITHOUT a signature — the rep adds their own when sending. |
| `language` | `string` | Draft language: the detected call language, falling back to the account language configured in Heilo (not a per-number setting). |

```json
{
  "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.matched`

Sent 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.

| Field | Type | Description |
| --- | --- | --- |
| `call_id` | `string (uuid)` | Call ID in Heilo. For business call events it equals resource_id; in webhook.test it is a synthetic call ID. |
| `matches` | `object[]` | The trackers matched in this call. |
| `matches[].tracker_id` | `string (uuid)` | Identifier of the configured tracker. |
| `matches[].name` | `string` | Tracker name. |
| `matches[].matched_terms` | `string[]` | Matched terms. |
| `matches[].excerpts` | `string[]` | Transcript excerpts; may be empty. |

```json
{
  "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.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. |

```json
{
  "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.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. |

```json
{
  "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.test`

Sent 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.

| Field | Type | Description |
| --- | --- | --- |
| `call_id` | `string (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_phone` | `string \| null` | Stored caller number; null if unavailable in call.completed. |
| `customer_phone_e164` | `string \| null` | Customer number for matching a CRM contact; null if unavailable in call.completed. |
| `customer_phone_national` | `string \| null` | The 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_e164` | `string \| null` | The 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_at` | `string (ISO 8601) \| null` | When 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_url` | `string \| null` | Permanent 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. |
| `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. |
| `_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). |

```json
{
  "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"
  }
}
```

## 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.
