# API-Referenz v1

> REST- + Webhooks-Integrationsreferenz (Version 2026-06-15)

Maschineläsbari Fassig vo de Heilo-API-Referänz, us de gliiche Date erzügt wie d Site. Fassig für Mänsche: https://www.heilo.io/ch/docs/api

- **Aktuelle Version**: v1 · 2026-06-15
- **Status**: Beta
- **Basis-URL**: `https://www.heilo.io/api/v1`
- **OpenAPI 3.1**: https://www.heilo.io/openapi.json

---

## Schnellstart für die CRM-Integration

Der schnellste Weg, ein CRM mit Heilo zu verbinden. Die vollständige API-Referenz finden Sie weiter unten.

> Integrieren Sie webhook-first: Das Ereignis call.completed ist die primäre Datenquelle (es enthält den Aufzeichnungslink und das verarbeitete Transkript). REST /calls ist eine Ergänzung — Introspektion und Lesen ausgewählter Metadaten.

1. Stellen Sie einen Webhook-Endpunkt in Ihrem CRM oder Ihrer Middleware bereit (ohne Code: verwenden Sie Zapier/Make — siehe die Anleitung unten).
2. Fügen Sie in Heilo (Einstellungen → Integrationen) ein Abonnement für call.completed hinzu (optional auch call.outbound.attempted).
3. Empfangen Sie call.completed und verifizieren Sie die Signatur (Heilo-Signature-Header, HMAC — siehe unten).
4. Deduplizieren Sie nach event_id (heilo-event-id-Header); data.call_id gruppiert Ereignisse desselben Anrufs.
5. Ordnen Sie die Felder Ihrem CRM zu: einen Kontakt anhand der Telefonnummer finden/erstellen, einen Lead/Deal erstellen und eine Aktivität/Notiz anhängen (Zusammenfassung + Aufzeichnungslink).

## Authentifizierung

Die öffentliche API verwendet Bearer-Token. Generieren Sie einen API-Schlüssel über die [Karte „API-Schlüssel“](https://www.heilo.io/settings/integrations#api-keys) auf der Integrationsseite und senden Sie ihn im Header:

```http
Authorization: Bearer hk_live_AbC1MnPq...
```

Heilo hat drei Authentifizierungsmodi:

- Bearer (API-Schlüssel hk_live_…) — für die öffentliche API. Kein CSRF, keine Cookies.
- Session-Cookies — für die Web-App (heilo.io). NICHT für die öffentliche API verwenden.
- HMAC-SHA256 — für Webhooks, die Heilo an IHREN Endpunkt sendet (Sie verifizieren den Signatur-Header).

| Berechtigung (Scope) | Bedeutung |
| --- | --- |
| `read.calls` | Anrufe lesen: GET /api/v1/calls, GET /api/v1/calls/:id |
| `read.recordings` | Erstellt über `GET /calls/{id}/recording-url` en Link zum Audio vom ene Aaruef. Getrennt vo `read.calls`, wil Audio en anderi Iwilligung isch als Metadate — en bestehende Schlüssel überchunnt en nöd, du machsch en neue mit usdrücklichem Ufnahmszuegriff. |
| `write.calls, read.contacts, write.contacts, manage.webhooks, manage.api_keys` | Reserviert für geplante API-Adressen — wählen Sie sie nicht auf Vorrat aus. |

Das Feld environment in der /me-Antwort hat heute immer den Wert live. Test-Schlüssel sind geplant.

## Basis-URL und Version

Alle öffentlichen Endpunkte liegen unter /api/v1/. Produktion:

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

### Versionierung

Die Heilo-API verwendet eine datumsbasierte Version. Nur Breaking Changes erhöhen die Hauptversion (v1 → v2). Das Hinzufügen von Feldern oder Endpunkten ist nicht breaking.

Abwärtskompatibel: neue Antwortfelder, neue event_types, neue Endpunkte. Breaking Change = neue Hauptversion (v2). Die alte Version wird mind. 12 Monate nach Ankündigung von v2 unterstützt.

## Rate Limits

Stündliche Limits pro Schlüssel und pro Benutzer (Summe aller Schlüssel). Zurücksetzung zur vollen UTC-Stunde. Jede Anfrage zählt, unabhängig vom Antwortstatus.

- Pro Schlüssel: 1000 req/h
- Pro Konto (Summe der Schlüssel): 5000 req/h

Bei Überschreitung geben wir 429 mit dem Retry-After-Header zurück (Sekunden bis zum Zurücksetzen):

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

## Fehler

Alle Fehler geben eine einheitliche JSON-Struktur mit error.code (stabil) und error.message (menschenlesbar, kann sich ändern) zurück. Protokollieren Sie den Code, nicht die Nachricht.

```json
{
  "success": false,
  "error": {
    "code": "RATE_LIMITED",
    "message": "Per-key rate limit 1000/h exceeded"
  },
  "meta": {
    "timestamp": "2026-06-03T12:34:56Z"
  }
}
```

| HTTP | code | Bedeutung |
| --- | --- | --- |
| 400 | `BAD_REQUEST` | Fehlerhafte Query- oder Body-Parameter (generische Validierung) |
| 401 | `UNAUTHORIZED` | Fehlender / ungültiger Bearer-Token |
| 402 | `SUBSCRIPTION_INACTIVE` | Abonnement inaktiv — erneuern Sie die Abrechnung, um den Schlüssel wieder zu aktivieren |
| 403 | `FORBIDDEN` | Der Schlüssel verfügt nicht über den erforderlichen Scope |
| 404 | `NOT_FOUND` | Die Ressource existiert nicht oder liegt ausserhalb der Organisation des API-Schlüssels. |
| 422 | `VALIDATION_ERROR` | Eine Geschäftsregel hat die Anfrage abgelehnt (z. B. ungültige Telefonnummer, Kontingent) |
| 429 | `RATE_LIMITED` | Stundenlimit überschritten (siehe Retry-After) |
| 500 | `DATABASE_ERROR` | Server-/Datenbankfehler — kann mit Backoff gefahrlos wiederholt werden |
| 503 | `MAINTENANCE` | Öffentliche API vorübergehend deaktiviert (Kill-Switch) |

Der Code SUBSCRIPTION_INACTIVE tritt in zwei Situationen auf: HTTP 402 — das Heilo-Abonnement ist abgelaufen (Zahlung), und HTTP 409 — das Webhook-Abonnement ist pausiert (z. B. bei der Test-Aktion); klicken Sie dann zuerst auf „Erneut verifizieren“.

## Endpunkte

Jeder /api/v1-Endpunkt erfordert Bearer. /me unten dient zur Schlüssel-Introspektion; das Lesen von Anrufen finden Sie im Abschnitt „Anrufe“. Behandeln Sie bei der Integration eines CRM Webhooks als primäre Datenquelle — REST dient zur Introspektion und zum Lesen ausgewählter Metadaten.

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

API-Schlüssel-Introspektion — gibt die Key-ID, user_id, scopes und das Rate Limit zurück. Nützlich für den „Verbindungstest“ von Zapier/Make.

**Anfrage**

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

**Antwort**

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

Anwendungsfall: Zapier-Verbindungstest während der Einrichtung einer benutzerdefinierten Integration. Eine 200-Antwort beweist, dass Schlüssel + Netzwerk funktionieren.

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

Listet die Anrufe der Organisation auf. Paginierung (page/limit≤100), Filter: direction, status, Datumsbereich (dateFrom/dateTo). Gibt has_more zurück.

**Abfrageparameter**

| Parameter | Typ | Werte |
| --- | --- | --- |
| `page` | `int` | ab 1 (Standard 1) |
| `limit` | `int` | 1–100 (Standard 20) |
| `direction` | `enum` | `inbound` \| `outbound` |
| `status` | `enum` | `new` \| `to_call` \| `contacted` \| `qualified` |
| `dateFrom` / `dateTo` | `string` | Datum YYYY-MM-DD oder ISO 8601, z. B. 2026-06-03T12:34:56Z |
| `phone` | `string` | d Chundenummere vom Gspröch; Läärschläg, Bindestrich und s füehrende + sind optional, z. B. 600 100 200 |

**Anfrage**

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

**Antwort**

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

Ruft einen einzelnen Anruf anhand der id ab. 404, wenn er nicht zur Organisation des Schlüssels gehört oder gelöscht wurde.

**Anfrage**

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

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

Es frisch signierts Link zum Audio vom Aaruef, 15 Minute gültig. Es isch e Download-Bereichtigung mit eme Ablauf, kei dauerhafti Referenz: speicher d `call_id` und frag nomal, wil en Link, wo im CRM-Fäld liit, i weniger als ere Stund tot isch. De Link im Webhook `call.recording.ready` isch sibe Täg signiert — über dää Endpunkt chunnsch du nachher as Audio.

- 200 — de Link und de Moment, wo er ufhört z funktioniere.
- 409 `RECORDING_NOT_READY` — de Aaruef ghört dir und s Audio isch no nöd da. Frag glii nomal.
- 409 `RECORDING_WITHHELD` — de Aaruef isch im Abfall oder zum Lösche vorgmerkt. Beides cha rückgängig gmacht werde, also frag wiiter, statt d Ufnahm bi dir abzschriibe.
- 410 `RECORDING_DELETED` — de Ufbewahrigslauf hät s Audio entfernt, es isch nöd meh z rette. Hör uf z frage und uf z queue.
- 410 `RECORDING_NOT_CAPTURED` — dä usgehend Aaruef isch nie ufgno worde und es wird nie e Ufnahm dafür gäh: s Capture-Gate hät en bim Start abglehnt, er isch im Modus no_recording gloffe oder hät ohni Gspröch ufghört. Hör uf z frage — es gits kei Grund zum nomal probiere. En iigehende Aaruef, wo d Ufnahm abglehnt worde isch, git wiiterhin 409, will no e Ufnahm cho cha.

D Antwort chunnt mit `Cache-Control: no-store`, wil de Body e Bereichtigung treit. D Bereichtigung wird bim Abhole vo de Datei nomal prüeft, nöd nu bim Erstelle vom Link — en zwüschedure glöschte Aaruef isch sofort nüme läsbar.

**Anfrage**

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

**Antwort**

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

D Endpunkt für d Aaruef-Liste und s Aaruef-Detail gänd kei Ufnahmsdatei zrugg. De Link zur Ufnahm überchunnsch im Webhook und nach sim Ablauf über `GET /calls/{id}/recording-url`. Ufnahme chönd nach Ufbewahrigs- und DSGVO-Regle glöscht werde.

## Ausgehende Webhooks

Heilo sendet signierte JSON-Ereignisse an Ihren Endpunkt. Erstellen Sie Abonnements in der Karte Webhook-Abonnements. Die Aktivierung verwendet einen separaten, unsignierten Handshake webhook.subscription.verify, der keine Geschäftsdaten anlegen darf.

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

Wiederholungs- und Pausierungsrichtlinie:

- Bei vorübergehenden Fehlern gibt es insgesamt höchstens 5 Versuche: nach 2 Min., 5 Min., 30 Min. und 2 Std., mit zufälliger Zeitverschiebung (Jitter) bei späteren Versuchen. Nach dem fünften Fehler endet die Zustellung als Dead Letter.
- Permanente Fehler (HTTP 401/403/422) pausieren das Abonnement sofort — keine Wiederholungen.
- 50 aufeinanderfolgende vorübergehende Fehler oder 2 aufeinanderfolgende HTTP 410 Gone (z. B. gelöschtes Make-Szenario) pausieren das Abonnement ebenfalls.
- Setzen Sie ein pausiertes Abonnement mit „Erneut verifizieren“ fort — ein neuer Handshake reaktiviert die Warteschlange.

Wird ein Abonnement automatisch pausiert, senden wir eine E-Mail an die Adresse des Kontoinhabers. Ereignisse aus der Dead-Letter-Warteschlange können Sie mit der Schaltfläche „Erneut senden“ im Zustellungsprotokoll erneut senden — nachdem das Abonnement erneut verifiziert wurde.

2xx bestätigt den HTTP-Empfang, nicht den Abschluss einer Automatisierung oder das Speichern im CRM. Zustellungen können sich wiederholen oder in anderer Reihenfolge eintreffen. Heilo gleicht aktuelle Lücken in einem begrenzten Zeitfenster aus, nicht die gesamte Historie. Prüfen Sie Zustellprotokoll und CRM getrennt.

### Verifizierungsmodi

Der unsignierte Handshake enthält nur event_type = webhook.subscription.verify und challenge. Bestätigen Sie nur den Endpunkt; starten Sie keine Geschäftsverarbeitung. Beide Modi verwenden dieselbe Anfrage:

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

**Modus** — `permissive` (Standard)

Jede 2xx-Antwort aktiviert das Abonnement; der Antwortinhalt wird ignoriert. Dies ist der Standardmodus für No-Code-Empfänger.

**Modus** — `strict` (Opt-in)

Antworten Sie mit 2xx und JSON mit exakt dem von Heilo empfangenen challenge-Wert:

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

Wählen Sie den Modus beim Erstellen. Eine Änderung erfordert Löschen und Neuerstellen des Abonnements. Verwaltungsrouten erfordern eine angemeldete Sitzung und CSRF-Schutz; sie sind nicht mit einem API-Schlüssel zugänglich.

Limits: max. 20 aktive Abonnements pro Konto (per Umgebungsvariable überschreibbar). Ein Abonnement wird nach 50 aufeinanderfolgenden vorübergehenden Fehlern oder 2 aufeinanderfolgenden HTTP 410 automatisch pausiert und sofort bei HTTP 401/403/422.

### HMAC-Verifizierung (signing_secret)

Signierte Ereignisse einschliesslich des manuellen webhook.test verwenden Heilo-Signature mit HMAC-SHA256 über Zeitstempel + Punkt + ursprüngliche Anfragebytes. Prüfen Sie die Signatur vor dem Parsen der Geschäftsdaten. Nur der Handshake zur Endpunktbestätigung ist unsigniert.

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

Die Beispiele akzeptieren Signaturen bis zu 600 Sekunden (10 Minuten) alt und 300 Sekunden (5 Minuten) in der Zukunft, entsprechend dem bestehenden Heilo-Prüfer. Synchronisieren Sie Serveruhren. Zeitprüfungen begrenzen das Replay-Fenster; Deduplizierung nach event_id bleibt erforderlich.

Das Signatur-Secret zeigen wir nur einmal an — beim Erstellen des Abonnements. Haben Sie es verloren oder vermuten Sie ein Leck? Öffnen Sie das Abonnement unter Einstellungen → Integrationen → Webhooks und rotieren Sie es dort — siehe „Rotation des Secrets“ unten. Das Abonnement muss dafür nicht gelöscht und neu erstellt werden.

### Rotation des Secrets

Heilo sendet immer genau eine v1-Signatur, nie zwei gleichzeitig. Die Rotation funktioniert über die Installationsreihenfolge, nicht dadurch, dass Ihr Empfänger mehrere Signaturen parsen lernt.

1. Bereiten Sie ein neues Secret in den Abonnement-Einstellungen vor. Es wird einmal angezeigt und wartet bis zu 24 Stunden auf die Aktivierung.
2. Installieren Sie das neue Secret NEBEN dem aktuellen — Ihr Empfänger muss eine Signatur akzeptieren, die mit einem von beiden erstellt wurde.
3. Senden Sie optional einen mit dem neuen Secret signierten Test. Er trägt den Header Heilo-Secret-Rotation-Id und einen gewöhnlichen webhook.test-Body; eine 2xx-Antwort beweist nicht, dass Ihr Empfänger die Signatur geprüft hat — bestätigen Sie das in Ihrem eigenen Log.
4. Aktivieren Sie die Rotation.
5. Behalten Sie das alte Secret noch 1 Stunde nach der Aktivierung bei, dann entfernen Sie es.

Heilo ändert nichts an Ihrem Empfänger. In diesem Zeitfenster muss Ihr Empfänger eine Signatur akzeptieren, die mit einem der beiden Secrets erstellt wurde.

**Notfall: das Secret ist durchgesickert** — Eine Notfall-Rotation pausiert den Versand sofort. Installieren Sie NUR das neue Secret — behandeln Sie das alte als kompromittiert — und aktivieren Sie dann; es gibt keine Karenzzeit. Setzen Sie den Versand danach mit „Erneut verifizieren“ fort. Bereits in der Warteschlange stehende Zustellungen werden beim Pausieren übersprungen; senden Sie sie aus dem Zustellprotokoll erneut.

Secret verloren, aber kein Leck? Eine normale Rotation funktioniert trotzdem — das Abonnement muss nicht gelöscht werden.

## Ereignistypen

Wählen Sie event_types beim Erstellen eines Abonnements. Jedes Ereignis hat eine eindeutige event_id (UUID v5) und wird pro Abonnement dedupliziert.

webhook.test wird manuell über Test gesendet und enthält data._test = true. Nutzen Sie es zur Feldzuordnung und schliessen Sie es danach von der Geschäftsverarbeitung aus. Der separate Aktivierungs-Handshake ist webhook.subscription.verify.

| event_type | Beschreibung |
| --- | --- |
| `call.completed` | Anruf abgeschlossen, Transkript bereit |
| `call.outbound.attempted` | Ausgehender Anrufversuch hat einen Endzustand erreicht (verbunden oder fehlgeschlagen) |
| `call.recording.ready` | Aufzeichnungsdatei zum Download verfügbar |
| `call.transcribed` | Transkript bereit (getrennt von call.completed) |
| `call.failed` | Anruf fehlgeschlagen (besetzt/keine Antwort/Fehler) |
| `call.outbound.lifecycle_repaired` | Korrektur des ausgehenden Lebenszyklus — Anrufstatus repariert |
| `call.deletion_scheduled` | Anruf zur Löschung vorgemerkt (DSGVO Art. 17, Aufbewahrung) |
| `call.recording.deleted` | Aufzeichnung gelöscht (DSGVO) |
| `call.followup_email.drafted` | E-Mail-Entwurf fürs Follow-up parat |
| `call.tracker.matched` | Tracker troffe |
| `contact.created` | Neuer Kontakt erstellt |
| `contact.updated` | Kontakt aktualisiert |

Unten das data-Objekt jedes Ereignisses. Feldnamen, Typen und Aufzählungswerte sind Teil des API-Vertrags und ändern ihre Bedeutung innerhalb von v1 nicht; mit der Zeit können neue, optionale Felder hinzukommen.

### `call.completed`

Wird gesendet, nachdem die Aufzeichnung und das Transkript eines abgeschlossenen Anrufs verarbeitet wurden. Die primäre Datenquelle für ein CRM — vollständige Nutzdaten (einschliesslich recording_url und des verarbeiteten Transkripts).

| Feld | Typ | Beschreibung |
| --- | --- | --- |
| `call_id` | `string (uuid)` | Anruf-ID in Heilo. Bei geschäftlichen Anrufereignissen identisch mit resource_id; bei webhook.test eine synthetische Anruf-ID. |
| `direction` | `'inbound' \| 'outbound'` | Anrufrichtung. |
| `caller_phone` | `string \| null` | Gespeicherte Anrufernummer; null, falls in call.completed nicht verfügbar. |
| `customer_phone_e164` | `string \| null` | Kundennummer zum CRM-Kontaktabgleich; null, falls in call.completed nicht verfügbar. |
| `customer_phone_national` | `string \| null` | Dieselbe Kundennummer im nationalen Format, direkt für ein CRM-Telefonfeld; null, wenn der gespeicherte Wert keine nutzbare Nummer ist, etwa bei unterdrückter Rufnummer. |
| `company_phone_e164` | `string \| null` | Die Nummer, über die dieses Gespräch einging oder ausging. In der Regel Ihre Heilo-Nummer; bei einer Weiterleitung Ihre eigene Nummer, die das Gespräch weitergeleitet hat. Damit lassen sich Notizen zuordnen, wenn Sie mehrere Nummern nutzen; null, wenn nicht erfasst. |
| `call_created_at` | `string (ISO 8601) \| null` | Wann das Gespräch in Heilo erfasst wurde (ISO 8601). Nicht identisch mit created_at im Umschlag, das den Versandzeitpunkt angibt und bei einem reparierten Ereignis Tage später liegen kann. |
| `app_url` | `string \| null` | Dauerhafter Link zum Gespräch in Heilo. Anders als recording_url läuft er nicht ab und kann im CRM-Datensatz bleiben. Zum Öffnen ist eine Heilo-Anmeldung nötig. |
| `duration` | `number` | Länge der Aufzeichnung in Sekunden. |
| `recording_url` | `string \| null` | Stabiler Link zur Aufzeichnung; null, wenn die Aufzeichnung zum Verarbeitungszeitpunkt noch nicht gespeichert war. |
| `transcript_processed` | `object` | Verarbeitete Anrufanalyse — siehe die transcript_processed-Feldübersicht in diesem Abschnitt. |
| `transcript_original` | `string \| null` | Rohes, wörtliches Transkript; null, wenn nicht verfügbar. |

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

Die Zustellung erfolgt mindestens einmal und ohne Reihenfolgegarantie — speichern Sie idempotent. Dedup-Schlüssel: event_id. data.call_id verknüpft Ereignisse desselben Anrufs (z. B. call.completed nach call.recording.ready).

### transcript_processed — Feldübersicht

Der stabile Teil der Felder, auf den Sie sich beim Mapping in ein CRM verlassen können. Jedes Feld ist optional — es ist null, wenn das Gespräch diese Information nicht enthielt.

| Feld | Typ | Beschreibung |
| --- | --- | --- |
| `caller_name` | `string \| null` | Name des Anrufers, sofern genannt. |
| `summary` | `string \| null` | Kurze Zusammenfassung des Anrufs. |
| `subject` | `string \| null` | Einzeiliger Titel des Anrufs (bis 80 Zeichen). |
| `service_needed` | `string \| null` | Worum der Anrufer gebeten hat. |
| `services_match` | `boolean \| null` | Ob die Anfrage zu den von Ihnen angebotenen Leistungen passt. |
| `lead_score` | `number \| null (1–10)` | Einschätzung der Lead-Qualität von 1 bis 10. |
| `preferred_date` | `string \| null` | Vom Anrufer genannter Termin, sofern vorhanden. |
| `client_city` | `string \| null` | Stadt, sofern erwähnt. |
| `client_address` | `string \| null` | Adresse, sofern erwähnt. |
| `additional_details` | `string \| null` | Zusätzlicher Kontext aus dem Anruf. |

**Felder, die auftreten können**

| Feld | Typ | Beschreibung |
| --- | --- | --- |
| `caller_location` | `string \| null` | Im Gespräch erkannter geografischer Bezug. |
| `client_country` | `string \| null` | Land, sofern erwähnt. |
| `counterparty_name` | `string \| null` | Name der Gegenseite — nur bei ausgehenden Anrufen und Live-Anrufen, bei denen Heilo still mithört (Zuhörer-Modus). |
| `detected_language` | `string` | Sprachcode des Gesprächs (z. B. pl); der Schlüssel kann ganz fehlen. |
| `proposal_items` | `object[] \| null` | Aus dem Anruf abgeleitete Vorschläge für Folgeaufgaben und Entscheidungen. |

Die Analyse kann zusätzliche Felder enthalten — behandeln Sie unbekannte Felder als optional und setzen Sie ihr Vorhandensein nie voraus.

### `call.outbound.attempted`

Wird gesendet, wenn ein ausgehender Wählversuch einen Endzustand erreicht — auch bei Fehlschlägen. completed bedeutet, dass der Anruf zustande kam und normal endete; Aufzeichnung und Transkript folgen als separate Ereignisse.

| Feld | Typ | Beschreibung |
| --- | --- | --- |
| `call_id` | `string (uuid)` | Anruf-ID in Heilo. Bei geschäftlichen Anrufereignissen identisch mit resource_id; bei webhook.test eine synthetische Anruf-ID. |
| `agent_user_id` | `string (uuid)` | ID des Heilo-Nutzers, der den Anruf gestartet hat. |
| `customer_phone` | `string` | Die gewählte Kundennummer. |
| `customer_phone_e164` | `string` | Kundennummer zum CRM-Kontaktabgleich; null, falls in call.completed nicht verfügbar. |
| `outbound_lifecycle` | `'completed' \| 'agent_no_answer' \| 'customer_no_answer' \| 'failed_to_initiate'` | Endzustand, der das Ereignis ausgelöst hat; completed bedeutet, dass der Anruf zustande kam und normal endete. |
| `duration` | `number \| null` | Gesprächsdauer in Sekunden; null, wenn der Anruf beim Aufbau scheiterte oder die Dauer noch nicht bekannt ist. |
| `attempted_at` | `string (ISO 8601)` | Zeitpunkt der Ereignis-Erzeugung (ISO 8601). |
| `has_recording` | `boolean` | true nur, wenn outbound_lifecycle completed ist und der Anruf nicht im Modus no_recording lief — die Aufzeichnung folgt dann als 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`

Wird gesendet, wenn die Aufzeichnungsdatei verfügbar ist. Verwenden Sie es, um die Audiodatei abzurufen oder zu archivieren.

| Feld | Typ | Beschreibung |
| --- | --- | --- |
| `call_id` | `string (uuid)` | Anruf-ID in Heilo. Bei geschäftlichen Anrufereignissen identisch mit resource_id; bei webhook.test eine synthetische Anruf-ID. |
| `recording_url` | `string \| null` | Stabiler Link zur Aufzeichnung; in seltenen Fällen null, wenn der Link nicht erzeugt werden konnte. |
| `duration` | `number \| null` | Dauer in Sekunden; null, wenn noch nicht bekannt. |

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

Wird im selben Verarbeitungslauf wie call.completed gesendet — enthält nur das Transkript, ohne Anrufdaten und ohne Aufzeichnungslink.

| Feld | Typ | Beschreibung |
| --- | --- | --- |
| `call_id` | `string (uuid)` | Anruf-ID in Heilo. Bei geschäftlichen Anrufereignissen identisch mit resource_id; bei webhook.test eine synthetische Anruf-ID. |
| `transcript_original` | `string \| null` | Rohes, wörtliches Transkript; null, wenn nicht verfügbar. |
| `transcript_processed` | `object` | Verarbeitete Anrufanalyse — siehe die transcript_processed-Feldübersicht in diesem Abschnitt. |

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

Wird gesendet, wenn ein ausgehender Anruf nicht zustande kam (keine Antwort, besetzt, Fehler beim Aufbau). Betrifft nur ausgehende Anrufe. Meist lohnt sich kein neuer Lead — protokollieren Sie stattdessen einen Kontaktversuch.

| Feld | Typ | Beschreibung |
| --- | --- | --- |
| `call_id` | `string (uuid)` | Anruf-ID in Heilo. Bei geschäftlichen Anrufereignissen identisch mit resource_id; bei webhook.test eine synthetische Anruf-ID. |
| `outbound_lifecycle` | `'agent_no_answer' \| 'customer_no_answer' \| 'failed_to_initiate'` | Welche Phase des ausgehenden Anrufs fehlgeschlagen ist. |
| `failure_reason` | `string \| null` | Maschinenlesbarer Fehlercode (z. B. customer_busy, agent_no_confirmation); kann null sein. |

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

Wird gesendet, wenn Heilo den Zustand eines ausgehenden Anrufs nachträglich korrigiert (eine späte Rückmeldung des Anbieters hat bestätigt, dass der Anruf doch zustande kam). Aktualisieren Sie den Anrufzustand auf Ihrer Seite.

| Feld | Typ | Beschreibung |
| --- | --- | --- |
| `call_id` | `string (uuid)` | Anruf-ID in Heilo. Bei geschäftlichen Anrufereignissen identisch mit resource_id; bei webhook.test eine synthetische Anruf-ID. |
| `previous_lifecycle` | `'agent_only'` | Zustand vor der Korrektur; derzeit immer agent_only. |
| `new_lifecycle` | `'completed'` | Zustand nach der Korrektur; derzeit immer completed. |
| `repaired_at` | `string (ISO 8601)` | Zeitpunkt der Korrektur (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`

DSGVO Art. 17: Der Anruf ist zur Löschung vorgemerkt. Ihr CRM sollte die Aufzeichnung nicht mehr verwenden und sich auf die Löschung der Daten vorbereiten. Sie werden über einen Anruf nur informiert, wenn Heilo einen Nachweis hat, dass dessen Inhalt Ihren Endpunkt erreicht haben könnte. Ein Abonnement allein auf die Löschereignisse macht Sie nicht zum Empfänger — es gibt nichts zu löschen, wenn Sie nie etwas erhalten haben.

| Feld | Typ | Beschreibung |
| --- | --- | --- |
| `call_id` | `string (uuid)` | Anruf-ID in Heilo. Bei geschäftlichen Anrufereignissen identisch mit resource_id; bei webhook.test eine synthetische Anruf-ID. |
| `pending_deletion_at` | `string (ISO 8601)` | Wann die Daten endgültig gelöscht werden (ISO 8601). |
| `reason` | `'consent_not_asked' \| 'consent_withdrawn' \| 'retention_expired' \| 'user_erasure'` | Grund für die geplante Löschung. |

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

DSGVO: Die Aufzeichnung wurde gelöscht — recording_url gibt 410 zurück. Entfernen oder deaktivieren Sie den Aufzeichnungslink auf Ihrer Seite. Sie werden über einen Anruf nur informiert, wenn Heilo einen Nachweis hat, dass dessen Inhalt Ihren Endpunkt erreicht haben könnte; dies ist also keine vollständige Übersicht aller Löschungen in Ihrer Organisation. Ein 2xx von Ihnen bedeutet, dass die Anweisung angekommen ist, niemals dass die Kopie gelöscht wurde.

| Feld | Typ | Beschreibung |
| --- | --- | --- |
| `call_id` | `string (uuid)` | Anruf-ID in Heilo. Bei geschäftlichen Anrufereignissen identisch mit resource_id; bei webhook.test eine synthetische Anruf-ID. |
| `reason` | `string \| null` | Beim Planen der Löschung erfasster Grund; kann null sein. |
| `recording_sid` | `string \| null` | Twilio-Aufzeichnungs-ID; null, wenn sie nicht ermittelt werden konnte. |
| `deletion_kind` | `'hard_deleted' \| 'twilio_404'` | hard_deleted = von Heilo gelöscht; twilio_404 = die Datei war auf Twilio-Seite bereits verschwunden. |
| `deleted_at` | `string (ISO 8601)` | Zeitpunkt der Löschung der Aufzeichnung (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`

Wird gesendet, nachdem ein beidseitiges Gespräch verarbeitet wurde und Heilo aus den Absprachen einen Follow-up-E-Mail-Entwurf erstellt hat. Nicht bei Voicemail und nicht, wenn kein Entwurf erstellt werden konnte. Heilo sendet diese E-Mail NIEMALS an den Anrufer — der Entwurf ist für Ihren Vertriebsmitarbeiter zum Prüfen und Versenden.

| Feld | Typ | Beschreibung |
| --- | --- | --- |
| `call_id` | `string (uuid)` | Anruf-ID in Heilo. Bei geschäftlichen Anrufereignissen identisch mit resource_id; bei webhook.test eine synthetische Anruf-ID. |
| `subject` | `string` | E-Mail-Betreff, fertig zum Einfügen. |
| `body` | `string` | E-Mail-Text als reiner Text, OHNE Signatur — die Signatur fügt der Mitarbeiter beim Senden hinzu. |
| `language` | `string` | Sprache des Entwurfs: die erkannte Gesprächssprache, ersatzweise die in Heilo eingestellte Kontosprache (keine Einstellung pro Nummer). |

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

Wird gesendet, wenn mindestens ein konfigurierter Keyword-Tracker zum verarbeiteten Transkript passt. Treffer enthalten Begriffe und Auszüge; fehlende Auszüge ergeben ein leeres Array.

| Feld | Typ | Beschreibung |
| --- | --- | --- |
| `call_id` | `string (uuid)` | Anruf-ID in Heilo. Bei geschäftlichen Anrufereignissen identisch mit resource_id; bei webhook.test eine synthetische Anruf-ID. |
| `matches` | `object[]` | In diesem Anruf gefundene Tracker. |
| `matches[].tracker_id` | `string (uuid)` | Kennung des konfigurierten Trackers. |
| `matches[].name` | `string` | Trackername. |
| `matches[].matched_terms` | `string[]` | Gefundene Begriffe. |
| `matches[].excerpts` | `string[]` | Transkriptauszüge; kann leer sein. |

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

Wird gesendet, wenn in Heilo ein neuer Kontakt angelegt wird. data.contact ist eine vollständige Momentaufnahme des neuen Kontakts; Notizen und Tags sind nicht enthalten.

| Feld | Typ | Beschreibung |
| --- | --- | --- |
| `contact` | `object` | Vollständige Momentaufnahme des neuen Kontakts (Felder unten). |
| `contact.id` | `string (uuid)` | Kontakt-ID in Heilo. |
| `contact.phone` | `string` | Telefonnummer des Kontakts. |
| `contact.first_name` | `string \| null` | null, wenn nicht angegeben. |
| `contact.last_name` | `string \| null` | null, wenn nicht angegeben. |
| `contact.email` | `string \| null` | null, wenn nicht angegeben. |
| `contact.company` | `string \| null` | null, wenn nicht angegeben. |

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

Wird gesendet, wenn ein Kontakt bearbeitet wird. Anders als bei contact.created ist dies keine Momentaufnahme: data.diff enthält nur die geänderten Felder.

| Feld | Typ | Beschreibung |
| --- | --- | --- |
| `contact_id` | `string (uuid)` | ID des aktualisierten Kontakts. |
| `diff` | `object (partial)` | Nur die geänderten Felder — Schlüssel, die in diff fehlen, wurden nicht geändert. |

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

Mögliche Schlüssel in diff: `first_name, last_name, email, phone, company, notes, tags`

### `webhook.test`

Wird nur beim Klick auf Test am Abonnement gesendet. resource_id ist die Abonnement-ID, nicht die Anruf-ID. data enthält synthetische call.completed-Daten und die drei unten beschriebenen Testmarker. Dies bestätigt keine Verfügbarkeit echter Audiodaten.

| Feld | Typ | Beschreibung |
| --- | --- | --- |
| `call_id` | `string (uuid)` | Anruf-ID in Heilo. Bei geschäftlichen Anrufereignissen identisch mit resource_id; bei webhook.test eine synthetische Anruf-ID. |
| `direction` | `'inbound' \| 'outbound'` | Anrufrichtung. |
| `caller_phone` | `string \| null` | Gespeicherte Anrufernummer; null, falls in call.completed nicht verfügbar. |
| `customer_phone_e164` | `string \| null` | Kundennummer zum CRM-Kontaktabgleich; null, falls in call.completed nicht verfügbar. |
| `customer_phone_national` | `string \| null` | Dieselbe Kundennummer im nationalen Format, direkt für ein CRM-Telefonfeld; null, wenn der gespeicherte Wert keine nutzbare Nummer ist, etwa bei unterdrückter Rufnummer. |
| `company_phone_e164` | `string \| null` | Die Nummer, über die dieses Gespräch einging oder ausging. In der Regel Ihre Heilo-Nummer; bei einer Weiterleitung Ihre eigene Nummer, die das Gespräch weitergeleitet hat. Damit lassen sich Notizen zuordnen, wenn Sie mehrere Nummern nutzen; null, wenn nicht erfasst. |
| `call_created_at` | `string (ISO 8601) \| null` | Wann das Gespräch in Heilo erfasst wurde (ISO 8601). Nicht identisch mit created_at im Umschlag, das den Versandzeitpunkt angibt und bei einem reparierten Ereignis Tage später liegen kann. |
| `app_url` | `string \| null` | Dauerhafter Link zum Gespräch in Heilo. Anders als recording_url läuft er nicht ab und kann im CRM-Datensatz bleiben. Zum Öffnen ist eine Heilo-Anmeldung nötig. |
| `duration` | `number` | Länge der Aufzeichnung in Sekunden. |
| `recording_url` | `string \| null` | Stabiler Link zur Aufzeichnung; null, wenn die Aufzeichnung zum Verarbeitungszeitpunkt noch nicht gespeichert war. |
| `transcript_processed` | `object` | Verarbeitete Anrufanalyse — siehe die transcript_processed-Feldübersicht in diesem Abschnitt. |
| `transcript_original` | `string \| null` | Rohes, wörtliches Transkript; null, wenn nicht verfügbar. |
| `_test` | `true` | Immer true — unterscheidet die Testnachricht von echten Ereignissen. |
| `_message` | `string` | Lesbarer Hinweis, dass es sich um einen Test handelt. |
| `_sent_at` | `string (ISO 8601)` | Zeitpunkt des Testversands (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+)

Endpunkte, die für kommende v1.X-Releases geplant sind. Keine feste Zusage — die Richtung hängt vom Feedback ab.

- `GET /contacts` — Kontakte auflisten
- `POST /contacts` — Kontakt erstellen (Synchronisierung vom CRM nach Heilo)
- `POST /calls` — Einen ausgehenden Anruf über Ihre Heilo-Nummer starten; er wird aufgezeichnet, transkribiert und wie jeder andere Anruf an Ihr CRM übermittelt

Brauchen Sie einen Endpunkt? Schreiben Sie mit Ihrem Anwendungsfall an support@heilo.io — wir priorisieren die Roadmap nach tatsächlichem Bedarf.
