# HalloFelix External API

Unified partner API for calls, campaigns, customers, and WhatsApp.
Calls are read-only; campaigns can be created and started; customers can be listed and created (idempotent by phone number); WhatsApp supports reading and sending.

> Legacy: an older Chatwoot-compatible WhatsApp surface (`/contacts`, `/conversations`, `/automation`) remains available
> for existing Make/Chatwoot integrations and shares the same `api_access_token`. New integrations should use the
> `/whatsapp/*` endpoints documented here instead.

## Quickstart for AI agents

1. Ask the user for two values from the HalloFelix app: `accountId` and `api_access_token`.
2. Use this base URL: `https://app.hallofelix.de/api/v1/accounts/{accountId}`.
3. For incremental sync, start with `GET /calls?updated_since=<ISO-8601>&limit=50`.
4. Keep following `next_cursor` until `has_more` is `false`.
5. The call sync feed already includes transcript, summary, and summary_data_points.
6. Use detail endpoints when you need the same object shape directly by ID:
   - `GET /calls/{callId}`
   - `GET /campaigns/{campaignId}`
   - `GET /customers/{customerId}`
7. If webhooks are configured, trust `x-hallofelix-event` for routing and verify `x-hallofelix-signature` with the same `api_access_token`.
8. Call webhooks also include transcript and summary data, so an AI agent can often process the event without any follow-up fetch.
9. All timestamps are ISO-8601 UTC strings.

## MCP server (Claude, ChatGPT, Cursor, …)

All endpoints below are also available as tools of a remote MCP server at `https://app.hallofelix.de/api/mcp` (Streamable HTTP).
Apps that support OAuth (Claude.ai, Claude Desktop, ChatGPT) only need the URL: the user signs in to HalloFelix and allows access.
Other clients send the tenant API token as `Authorization: Bearer <api_access_token>`, e.g. Claude Code:

```bash
claude mcp add --transport http hallofelix https://app.hallofelix.de/api/mcp --header "Authorization: Bearer <api_access_token>"
```

## Authentication

Send the tenant token in the `api_access_token` header.

```
api_access_token: <tenant token>
```

Base URL pattern:

```
https://app.hallofelix.de/api/v1/accounts/{accountId}
```

## Call Sync

Use the calls feed for polling-based synchronization.
Calls are sorted by `updated_at` and support cursor pagination.

Example:

```bash
curl "https://app.hallofelix.de/api/v1/accounts/{accountId}/calls?updated_since=2026-01-01T00:00:00.000Z&limit=50" \
  -H "api_access_token: <tenant token>"
```

Response meta includes `has_more` and `next_cursor`.
The call payload already contains transcript, transcript_source, summary, and summary_data_points.

### Recording links

> `recording_url` und `stereo_recording_url` sind signierte Links, die 60 Minuten gültig sind. Laden Sie die Aufnahme innerhalb dieser Zeit herunter oder rufen Sie den Endpunkt erneut ab, um einen neuen Link zu erhalten. Links dauerhaft zu speichern wird nicht unterstützt.
>
> `recording_url` and `stereo_recording_url` are signed links valid for 60 minutes. Download the recording within that time or call the endpoint again to get a new link. Storing the links permanently is not supported.

> Tonaufnahmen können gemäß der Aufbewahrungsfrist des Accounts (Einstellungen → Unternehmen, Standard 30 Tage) automatisch gelöscht werden. Danach sind `recording_url` und `stereo_recording_url` `null` und `recording_deleted_at` enthält den Zeitpunkt der Löschung. Transkript und Zusammenfassung bleiben erhalten.

Changelog: 12.10.2026: `recording_url` und `stereo_recording_url` liefern signierte, 60 Minuten gültige Links statt dauerhafter URLs. Neues Feld `recording_deleted_at`.

## Calls

### GET /calls

Calls synchronisieren. Cursor-basierter Feed für Call-Daten. Für zuverlässige Polling-Syncs mit updated_since und next_cursor. `recording_url` und `stereo_recording_url` sind signierte Links, die 60 Minuten gültig sind. Laden Sie die Aufnahme innerhalb dieser Zeit herunter oder rufen Sie den Endpunkt erneut ab, um einen neuen Link zu erhalten. Links dauerhaft zu speichern wird nicht unterstützt. Tonaufnahmen können gemäß der Aufbewahrungsfrist des Accounts (Einstellungen → Unternehmen, Standard 30 Tage) automatisch gelöscht werden. Danach sind `recording_url` und `stereo_recording_url` `null` und `recording_deleted_at` enthält den Zeitpunkt der Löschung. Transkript und Zusammenfassung bleiben erhalten.

Parameters:
- `updated_since` (query): ISO-8601; liefert nur seitdem geänderte Calls
- `cursor` (query): Vom letzten Response zurückgegeben
- `limit` (query): 1-100, Standard 50
- `direction` (query): inbound | outbound
- `status` (query): queued | ringing | in_progress | completed | failed | no_answer | canceled
- `campaign_id` (query): UUID einer Kampagne
- `customer_id` (query): UUID eines Kunden
- `assistant_id` (query): UUID eines Assistenten

Example response:

```json
{
  "data": {
    "meta": {
      "limit": 50,
      "has_more": true,
      "next_cursor": "eyJ1cGRhdGVkQXQiOiIyMDI2LTA3LTE1VDEwOjM1OjAwLjAwMFoiLCJpZCI6ImNhbGwtdXVpZCJ9"
    },
    "payload": [
      {
        "id": "call-uuid",
        "date_time": "2026-07-15T10:30:00.000Z",
        "updated_at": "2026-07-15T10:35:00.000Z",
        "duration_ms": 92400,
        "duration_seconds": 92,
        "direction": "outbound",
        "outbound_status": "completed",
        "effective_call_routing": "immediately",
        "from_number": "+4930123456",
        "to_number": "+491700000000",
        "success": true,
        "rating": null,
        "caller_spoke": true,
        "summary": "Customer reached and appointment booked.",
        "recording_url": "https://<account>.r2.cloudflarestorage.com/call-uuid-mono.mp3?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=3600&X-Amz-Signature=…",
        "stereo_recording_url": null,
        "recording_deleted_at": null,
        "scheduled_at": null,
        "enqueued_at": "2026-07-15T10:00:00.000Z",
        "attempt_number": 1,
        "root_call_id": null,
        "initiated_by": "user_123",
        "customer": {
          "id": "customer-uuid",
          "external_identifier": "crm-4711",
          "first_name": "Marco",
          "last_name": "Schneider",
          "name": "Marco Schneider",
          "email": "marco@example.com",
          "language": "de",
          "call_handling_override": null,
          "phone_number": "+491700000000",
          "phone_numbers": [
            {
              "id": "phone-uuid",
              "phone_number": "+491700000000",
              "phone_type": "mobile",
              "is_primary": true
            }
          ],
          "calls_count": 3,
          "last_called_at": "2026-07-15T10:30:00.000Z",
          "created_at": "2026-07-10T08:00:00.000Z",
          "updated_at": "2026-07-15T10:35:00.000Z"
        },
        "assistant": {
          "id": "assistant-uuid",
          "name": "Outbound Felix",
          "assistant_phone_number": "+4930123456"
        },
        "campaign": {
          "id": "campaign-uuid",
          "name": "Q3 Follow-up",
          "status": "running"
        },
        "transcript": [
          {
            "role": "assistant",
            "text": "Hello Mr Schneider, this is HalloFelix."
          },
          {
            "role": "user",
            "text": "Yes, speaking."
          }
        ],
        "transcript_source": "improved",
        "summary_data_points": {
          "appointment_booked": true,
          "preferred_time": "2026-07-20T09:00:00.000Z"
        }
      }
    ]
  }
}
```

### GET /calls/{callId}

Call-Detail. Voller Call-Datensatz inklusive Transcript, Summary und referenziertem Customer/Campaign/Assistant. `recording_url` und `stereo_recording_url` sind signierte Links, die 60 Minuten gültig sind. Laden Sie die Aufnahme innerhalb dieser Zeit herunter oder rufen Sie den Endpunkt erneut ab, um einen neuen Link zu erhalten. Links dauerhaft zu speichern wird nicht unterstützt. Tonaufnahmen können gemäß der Aufbewahrungsfrist des Accounts (Einstellungen → Unternehmen, Standard 30 Tage) automatisch gelöscht werden. Danach sind `recording_url` und `stereo_recording_url` `null` und `recording_deleted_at` enthält den Zeitpunkt der Löschung. Transkript und Zusammenfassung bleiben erhalten.

Parameters:
- `callId` (path, required): UUID des Calls

Example response:

```json
{
  "data": {
    "id": "call-uuid",
    "date_time": "2026-07-15T10:30:00.000Z",
    "updated_at": "2026-07-15T10:35:00.000Z",
    "duration_ms": 92400,
    "duration_seconds": 92,
    "direction": "outbound",
    "outbound_status": "completed",
    "effective_call_routing": "immediately",
    "from_number": "+4930123456",
    "to_number": "+491700000000",
    "success": true,
    "rating": null,
    "caller_spoke": true,
    "summary": "Customer reached and appointment booked.",
    "recording_url": "https://<account>.r2.cloudflarestorage.com/call-uuid-mono.mp3?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=3600&X-Amz-Signature=…",
    "stereo_recording_url": null,
    "recording_deleted_at": null,
    "scheduled_at": null,
    "enqueued_at": "2026-07-15T10:00:00.000Z",
    "attempt_number": 1,
    "root_call_id": null,
    "initiated_by": "user_123",
    "customer": {
      "id": "customer-uuid",
      "external_identifier": "crm-4711",
      "first_name": "Marco",
      "last_name": "Schneider",
      "name": "Marco Schneider",
      "email": "marco@example.com",
      "language": "de",
      "call_handling_override": null,
      "phone_number": "+491700000000",
      "phone_numbers": [
        {
          "id": "phone-uuid",
          "phone_number": "+491700000000",
          "phone_type": "mobile",
          "is_primary": true
        }
      ],
      "calls_count": 3,
      "last_called_at": "2026-07-15T10:30:00.000Z",
      "created_at": "2026-07-10T08:00:00.000Z",
      "updated_at": "2026-07-15T10:35:00.000Z"
    },
    "assistant": {
      "id": "assistant-uuid",
      "name": "Outbound Felix",
      "assistant_phone_number": "+4930123456"
    },
    "campaign": {
      "id": "campaign-uuid",
      "name": "Q3 Follow-up",
      "status": "running"
    },
    "transcript": [
      {
        "role": "assistant",
        "text": "Hello Mr Schneider, this is HalloFelix."
      },
      {
        "role": "user",
        "text": "Yes, speaking."
      }
    ],
    "transcript_source": "improved",
    "summary_data_points": {
      "appointment_booked": true,
      "preferred_time": "2026-07-20T09:00:00.000Z"
    }
  }
}
```

## Campaigns

### GET /campaigns

Kampagnen auflisten. Paginiert Kampagnen mit berechneten Erfolgs- und Antwortquoten.

Parameters:
- `status` (query): draft | pending | running | paused | completed | failed | canceled
- `archived` (query): true | false
- `page` (query): Seitenzahl, Standard 1

Example response:

```json
{
  "data": {
    "meta": {
      "page": 1,
      "per_page": 50,
      "total_count": 1
    },
    "payload": [
      {
        "id": "campaign-uuid",
        "name": "Q3 Follow-up",
        "status": "running",
        "archived": false,
        "scheduled_at": "2026-07-15T10:00:00.000Z",
        "started_at": "2026-07-15T10:05:00.000Z",
        "completed_at": null,
        "paused_at": null,
        "max_attempts": 3,
        "retry_interval_hours": 24,
        "outbound_first_sentence": "Hello, this is HalloFelix calling.",
        "outbound_goal": "Book an appointment with existing leads.",
        "restrict_to_business_hours": true,
        "outbound_first_sentence_voice_enabled": false,
        "call_assistant": {
          "id": "assistant-uuid",
          "name": "Outbound Felix",
          "assistant_phone_number": "+4930123456"
        },
        "call_records_count": 12,
        "created_by": "user_123",
        "created_at": "2026-07-14T09:00:00.000Z",
        "updated_at": "2026-07-15T10:35:00.000Z",
        "stats": {
          "total_customers": 10,
          "total_attempts": 12,
          "completed_calls": 6,
          "no_answer_calls": 2,
          "failed_calls": 1,
          "canceled_calls": 0,
          "queued_calls": 1,
          "success_rate": 60,
          "answer_rate": 75,
          "next_scheduled_at": "2026-07-16T10:00:00.000Z"
        }
      }
    ]
  }
}
```

### GET /campaigns/{campaignId}

Kampagnen-Detail. Voller Kampagnendatensatz inklusive berechneter Statistiken.

Parameters:
- `campaignId` (path, required): UUID der Kampagne

Example response:

```json
{
  "data": {
    "id": "campaign-uuid",
    "name": "Q3 Follow-up",
    "status": "running",
    "archived": false,
    "scheduled_at": "2026-07-15T10:00:00.000Z",
    "started_at": "2026-07-15T10:05:00.000Z",
    "completed_at": null,
    "paused_at": null,
    "max_attempts": 3,
    "retry_interval_hours": 24,
    "outbound_first_sentence": "Hello, this is HalloFelix calling.",
    "outbound_goal": "Book an appointment with existing leads.",
    "restrict_to_business_hours": true,
    "outbound_first_sentence_voice_enabled": false,
    "call_assistant": {
      "id": "assistant-uuid",
      "name": "Outbound Felix",
      "assistant_phone_number": "+4930123456"
    },
    "call_records_count": 12,
    "created_by": "user_123",
    "created_at": "2026-07-14T09:00:00.000Z",
    "updated_at": "2026-07-15T10:35:00.000Z",
    "stats": {
      "total_customers": 10,
      "total_attempts": 12,
      "completed_calls": 6,
      "no_answer_calls": 2,
      "failed_calls": 1,
      "canceled_calls": 0,
      "queued_calls": 1,
      "success_rate": 60,
      "answer_rate": 75,
      "next_scheduled_at": "2026-07-16T10:00:00.000Z"
    },
    "customer_data": [
      {
        "firstName": "Marco",
        "lastName": "Schneider",
        "phoneNumber": "+491700000000"
      }
    ],
    "stored_total_calls": 12,
    "stored_completed_calls": 6,
    "stored_failed_calls": 1
  }
}
```

### POST /campaigns/{campaignId}/start

Kampagne starten. Startet bzw. setzt eine Kampagne fort (Status `running`). Der Dispatcher beginnt zu wählen.

Parameters:
- `campaignId` (path, required): UUID der Kampagne

Example response:

```json
{
  "data": {
    "id": "campaign-uuid",
    "name": "Q3 Follow-up",
    "status": "running",
    "archived": false,
    "scheduled_at": "2026-07-15T10:00:00.000Z",
    "started_at": "2026-07-15T10:05:00.000Z",
    "completed_at": null,
    "paused_at": null,
    "max_attempts": 3,
    "retry_interval_hours": 24,
    "outbound_first_sentence": "Hello, this is HalloFelix calling.",
    "outbound_goal": "Book an appointment with existing leads.",
    "restrict_to_business_hours": true,
    "outbound_first_sentence_voice_enabled": false,
    "call_assistant": {
      "id": "assistant-uuid",
      "name": "Outbound Felix",
      "assistant_phone_number": "+4930123456"
    },
    "call_records_count": 12,
    "created_by": "user_123",
    "created_at": "2026-07-14T09:00:00.000Z",
    "updated_at": "2026-07-15T10:35:00.000Z",
    "stats": {
      "total_customers": 10,
      "total_attempts": 12,
      "completed_calls": 6,
      "no_answer_calls": 2,
      "failed_calls": 1,
      "canceled_calls": 0,
      "queued_calls": 1,
      "success_rate": 60,
      "answer_rate": 75,
      "next_scheduled_at": "2026-07-16T10:00:00.000Z"
    },
    "customer_data": [
      {
        "firstName": "Marco",
        "lastName": "Schneider",
        "phoneNumber": "+491700000000"
      }
    ],
    "stored_total_calls": 12,
    "stored_completed_calls": 6,
    "stored_failed_calls": 1
  }
}
```

### POST /campaigns

Kampagne erstellen. Erstellt eine Kampagne. `status: "draft"` (Standard) speichert nur; `status: "pending"` validiert die Konfiguration, legt die Kontakte an und stellt die Anrufe in die Warteschlange.

Parameters:
- `name` (body, required): Name der Kampagne
- `status` (body): draft | pending (Standard draft)
- `call_assistant_id` (body): UUID des Call-Assistenten (via GET /assistants; Pflicht für pending)
- `outbound_first_sentence` (body): Erster Satz (Pflicht für pending)
- `outbound_goal` (body): Ziel der Kampagne (Pflicht für pending)
- `max_attempts` (body): 1-4; Anzahl Versuche (Pflicht für pending)
- `retry_interval_hours` (body): 1-168; Pflicht bei max_attempts > 1
- `customer_ids` (body): UUIDs bestehender Kunden (via GET /customers)
- `scheduled_at` (body): ISO-8601; optionaler Startzeitpunkt
- `restrict_to_business_hours` (body): true | false (Standard true)

Example response:

```json
{
  "data": {
    "id": "campaign-uuid",
    "name": "Q3 Follow-up",
    "status": "running",
    "archived": false,
    "scheduled_at": "2026-07-15T10:00:00.000Z",
    "started_at": "2026-07-15T10:05:00.000Z",
    "completed_at": null,
    "paused_at": null,
    "max_attempts": 3,
    "retry_interval_hours": 24,
    "outbound_first_sentence": "Hello, this is HalloFelix calling.",
    "outbound_goal": "Book an appointment with existing leads.",
    "restrict_to_business_hours": true,
    "outbound_first_sentence_voice_enabled": false,
    "call_assistant": {
      "id": "assistant-uuid",
      "name": "Outbound Felix",
      "assistant_phone_number": "+4930123456"
    },
    "call_records_count": 12,
    "created_by": "user_123",
    "created_at": "2026-07-14T09:00:00.000Z",
    "updated_at": "2026-07-15T10:35:00.000Z",
    "stats": {
      "total_customers": 10,
      "total_attempts": 12,
      "completed_calls": 6,
      "no_answer_calls": 2,
      "failed_calls": 1,
      "canceled_calls": 0,
      "queued_calls": 1,
      "success_rate": 60,
      "answer_rate": 75,
      "next_scheduled_at": "2026-07-16T10:00:00.000Z"
    },
    "customer_data": [
      {
        "firstName": "Marco",
        "lastName": "Schneider",
        "phoneNumber": "+491700000000"
      }
    ],
    "stored_total_calls": 12,
    "stored_completed_calls": 6,
    "stored_failed_calls": 1
  }
}
```

### PATCH /campaigns/{campaignId}

Kampagnen-Status ändern. Ändert den Laufstatus. Body: `{ status: "running" | "paused" | "canceled" }`.

Parameters:
- `campaignId` (path, required): UUID der Kampagne
- `status` (body, required): running | paused | canceled

Example response:

```json
{
  "data": {
    "id": "campaign-uuid",
    "name": "Q3 Follow-up",
    "status": "running",
    "archived": false,
    "scheduled_at": "2026-07-15T10:00:00.000Z",
    "started_at": "2026-07-15T10:05:00.000Z",
    "completed_at": null,
    "paused_at": null,
    "max_attempts": 3,
    "retry_interval_hours": 24,
    "outbound_first_sentence": "Hello, this is HalloFelix calling.",
    "outbound_goal": "Book an appointment with existing leads.",
    "restrict_to_business_hours": true,
    "outbound_first_sentence_voice_enabled": false,
    "call_assistant": {
      "id": "assistant-uuid",
      "name": "Outbound Felix",
      "assistant_phone_number": "+4930123456"
    },
    "call_records_count": 12,
    "created_by": "user_123",
    "created_at": "2026-07-14T09:00:00.000Z",
    "updated_at": "2026-07-15T10:35:00.000Z",
    "stats": {
      "total_customers": 10,
      "total_attempts": 12,
      "completed_calls": 6,
      "no_answer_calls": 2,
      "failed_calls": 1,
      "canceled_calls": 0,
      "queued_calls": 1,
      "success_rate": 60,
      "answer_rate": 75,
      "next_scheduled_at": "2026-07-16T10:00:00.000Z"
    },
    "customer_data": [
      {
        "firstName": "Marco",
        "lastName": "Schneider",
        "phoneNumber": "+491700000000"
      }
    ],
    "stored_total_calls": 12,
    "stored_completed_calls": 6,
    "stored_failed_calls": 1
  }
}
```

## Customers

### GET /customers

Kunden auflisten. Paginiert Kunden mit Telefonnummmern, Call-Zählung und letztem Call-Zeitpunkt.

Parameters:
- `q` (query): Suche über Name, E-Mail und Telefonnummer
- `page` (query): Seitenzahl, Standard 1

Example response:

```json
{
  "data": {
    "meta": {
      "page": 1,
      "per_page": 50,
      "total_count": 1
    },
    "payload": [
      {
        "id": "customer-uuid",
        "external_identifier": "crm-4711",
        "first_name": "Marco",
        "last_name": "Schneider",
        "name": "Marco Schneider",
        "email": "marco@example.com",
        "language": "de",
        "call_handling_override": null,
        "phone_number": "+491700000000",
        "phone_numbers": [
          {
            "id": "phone-uuid",
            "phone_number": "+491700000000",
            "phone_type": "mobile",
            "is_primary": true
          }
        ],
        "calls_count": 3,
        "last_called_at": "2026-07-15T10:30:00.000Z",
        "created_at": "2026-07-10T08:00:00.000Z",
        "updated_at": "2026-07-15T10:35:00.000Z"
      }
    ]
  }
}
```

### GET /customers/{customerId}

Kunden-Detail. Voller Kundendatensatz.

Parameters:
- `customerId` (path, required): UUID des Kunden

Example response:

```json
{
  "data": {
    "id": "customer-uuid",
    "external_identifier": "crm-4711",
    "first_name": "Marco",
    "last_name": "Schneider",
    "name": "Marco Schneider",
    "email": "marco@example.com",
    "language": "de",
    "call_handling_override": null,
    "phone_number": "+491700000000",
    "phone_numbers": [
      {
        "id": "phone-uuid",
        "phone_number": "+491700000000",
        "phone_type": "mobile",
        "is_primary": true
      }
    ],
    "calls_count": 3,
    "last_called_at": "2026-07-15T10:30:00.000Z",
    "created_at": "2026-07-10T08:00:00.000Z",
    "updated_at": "2026-07-15T10:35:00.000Z"
  }
}
```

### POST /customers

Kunden erstellen. Legt einen Kunden an. Idempotent über die Telefonnummer: Existiert im Account bereits ein Kunde mit dieser Nummer, wird dieser unverändert mit `200` zurückgegeben; sonst wird er neu angelegt (`201`). Ungültige Telefonnummern ergeben `400`.

Parameters:
- `first_name` (body, required): Vorname
- `last_name` (body): Nachname
- `phone_number` (body, required): Telefonnummer, idealerweise E.164 (z. B. +491700000000); ohne Ländervorwahl wird DE angenommen
- `email` (body): E-Mail-Adresse

Example response:

```json
{
  "data": {
    "id": "customer-uuid",
    "external_identifier": "crm-4711",
    "first_name": "Marco",
    "last_name": "Schneider",
    "name": "Marco Schneider",
    "email": "marco@example.com",
    "language": "de",
    "call_handling_override": null,
    "phone_number": "+491700000000",
    "phone_numbers": [
      {
        "id": "phone-uuid",
        "phone_number": "+491700000000",
        "phone_type": "mobile",
        "is_primary": true
      }
    ],
    "calls_count": 3,
    "last_called_at": "2026-07-15T10:30:00.000Z",
    "created_at": "2026-07-10T08:00:00.000Z",
    "updated_at": "2026-07-15T10:35:00.000Z"
  }
}
```

## Assistants

### GET /assistants

Call-Assistenten auflisten. Schlanke Auswahlliste (id, name, Telefonnummer) — für die Auswahl von `call_assistant_id` bzw. `assistant_id`.

Example response:

```json
{
  "data": {
    "meta": { "total_count": 1 },
    "payload": [
      {
        "id": "assistant-uuid",
        "name": "Outbound Felix",
        "assistant_phone_number": "+4930123456",
        "first_message": "Hallo, hier ist HalloFelix.",
        "conversation_speed": "normal",
        "call_voice": "voice14",
        "multilingual": true,
        "formal_address": true,
        "sms_enabled": true,
        "email_sending_enabled": true,
        "appointment_booking_mode": "direct",
        "calls_count": 42,
        "campaigns_count": 4
      }
    ]
  }
}
```

### GET /assistants/{assistantId}

Call-Assistent Detail. Vollständige, im Dashboard editierbare Konfiguration eines Call-Assistenten. Sammlungen (Vorlagen, Weiterleitungen, Kalender, Öffnungszeiten) sind hier nur lesbar.

Parameters:
- `assistantId` (path, required): UUID des Assistenten (via GET /assistants)

Example response:

```json
{
  "data": {
    "id": "assistant-uuid",
    "name": "Outbound Felix",
    "assistant_phone_number": "+4930123456",
    "first_message": "Hallo, hier ist HalloFelix.",
    "first_message_outside_hours": null,
    "passthrough_phone_number": "+4930999999",
    "use_caller_id": true,
    "multilingual": true,
    "multilingual_languages": ["de", "en"],
    "formal_address": true,
    "conversation_speed": "normal",
    "call_voice": "voice14",
    "pickup_delay_seconds": 0,
    "background_sound": true,
    "dsgvo_warning": true,
    "dsgvo_warning_message": null,
    "additional_instructions": null,
    "silence_warning_message": null,
    "still_there_message": null,
    "end_call_message": null,
    "call_routing_default": "immediately",
    "call_routing_during_opening_hours": "ringHumanBeforeAgent",
    "sms_enabled": true,
    "sms_sender_id": "Felix",
    "appointment_booking_mode": "direct",
    "booking_confirmation_sms_message": null,
    "email_sending_enabled": false,
    "email_routing_instructions": null,
    "auto_assign_from_email_routing": false,
    "notification_emails": ["team@example.com"],
    "voice_intro_enabled": false,
    "voice_intro_outside_hours_enabled": false,
    "opening_hours": null,
    "closed_date_ranges": null,
    "sms_templates": [],
    "email_templates": [],
    "call_transfers": [],
    "calendars": [],
    "task_type_ids": []
  }
}
```

### PATCH /assistants/{assistantId}

Call-Assistent bearbeiten. Ändert die Einstellungen eines Call-Assistenten. Nur die zu ändernden Felder senden (snake_case); alles andere — inkl. aller Sammlungen — bleibt erhalten. Änderungen werden automatisch an die Sprach-Engine übertragen, genau wie beim Speichern im Dashboard.

Parameters:
- `assistantId` (path, required): UUID des Assistenten
- `first_message` (body): Erster Satz des Assistenten
- `call_voice` (body): Stimme (voice1 … voice21)
- `conversation_speed` (body): slow | normal | fast
- `formal_address` (body): true = Sie, false = Du
- `multilingual` (body): true | false
- `multilingual_languages` (body): Sprachen im Multilingual-Modus als ISO-Codes, z.B. ["de", "en", "tr"]. Deutsch ist immer enthalten.
- `sms_enabled` (body): true | false
- `email_sending_enabled` (body): true | false
- `appointment_booking_mode` (body): direct | sms_link
- `…` (body): Alle weiteren editierbaren Felder: siehe GET /assistants/{assistantId}

Example response:

```json
{
  "data": {
    "id": "assistant-uuid",
    "name": "Outbound Felix",
    "assistant_phone_number": "+4930123456",
    "first_message": "Hallo, hier ist HalloFelix.",
    "first_message_outside_hours": null,
    "passthrough_phone_number": "+4930999999",
    "use_caller_id": true,
    "multilingual": true,
    "multilingual_languages": ["de", "en"],
    "formal_address": true,
    "conversation_speed": "normal",
    "call_voice": "voice14",
    "pickup_delay_seconds": 0,
    "background_sound": true,
    "dsgvo_warning": true,
    "dsgvo_warning_message": null,
    "additional_instructions": null,
    "silence_warning_message": null,
    "still_there_message": null,
    "end_call_message": null,
    "call_routing_default": "immediately",
    "call_routing_during_opening_hours": "ringHumanBeforeAgent",
    "sms_enabled": true,
    "sms_sender_id": "Felix",
    "appointment_booking_mode": "direct",
    "booking_confirmation_sms_message": null,
    "email_sending_enabled": false,
    "email_routing_instructions": null,
    "auto_assign_from_email_routing": false,
    "notification_emails": ["team@example.com"],
    "voice_intro_enabled": false,
    "voice_intro_outside_hours_enabled": false,
    "opening_hours": null,
    "closed_date_ranges": null,
    "sms_templates": [],
    "email_templates": [],
    "call_transfers": [],
    "calendars": [],
    "task_type_ids": []
  }
}
```

## WhatsApp

### GET /whatsapp/assistants

WhatsApp-Assistenten auflisten. Auswahlliste der WhatsApp-Assistenten des Kontos (id, name, Zeitplan-Modus, Nutzung).

Example response:

```json
{
  "data": {
    "meta": { "total_count": 1 },
    "payload": [
      {
        "id": "whatsapp-assistant-uuid",
        "name": "WhatsApp Assistent",
        "first_message": "Hallo! Wie kann ich helfen?",
        "multilingual": false,
        "formal_address": true,
        "ai_schedule_mode": "always",
        "appointment_booking_mode": "direct",
        "inherits_calendar_from_assistant_id": null,
        "accounts_count": 1,
        "task_types_count": 3
      }
    ]
  }
}
```

### GET /whatsapp/assistants/{assistantId}

WhatsApp-Assistent Detail. Vollständige, im Dashboard editierbare Konfiguration eines WhatsApp-Assistenten. Sammlungen (Kalender, Vorlagen, Handoff-Regeln, Öffnungszeiten) sind hier nur lesbar.

Parameters:
- `assistantId` (path, required): UUID des WhatsApp-Assistenten (via GET /whatsapp/assistants)

Example response:

```json
{
  "data": {
    "id": "whatsapp-assistant-uuid",
    "name": "WhatsApp Assistent",
    "first_message": "Hallo! Wie kann ich helfen?",
    "multilingual": false,
    "formal_address": true,
    "style_instructions": null,
    "general_instructions": null,
    "closing_instructions": null,
    "handoff_user_id": null,
    "handoff_prefer_last_assigned": true,
    "ai_schedule_mode": "always",
    "appointment_booking_mode": "direct",
    "link_messages_enabled": false,
    "booking_confirmation_link_message": null,
    "inherits_calendar_from_assistant_id": null,
    "choice_messages_enabled": false,
    "opening_hours": null,
    "closed_date_ranges": null,
    "calendars": [],
    "task_type_ids": [],
    "handoff_rules": [],
    "link_templates": [],
    "pdf_templates": []
  }
}
```

### PATCH /whatsapp/assistants/{assistantId}

WhatsApp-Assistent bearbeiten. Ändert die Einstellungen eines WhatsApp-Assistenten. Nur die zu ändernden Felder senden (snake_case); alles andere — inkl. aller Sammlungen — bleibt erhalten.

Parameters:
- `assistantId` (path, required): UUID des WhatsApp-Assistenten
- `first_message` (body): Erste Nachricht
- `ai_schedule_mode` (body): always | inside_hours | outside_hours
- `formal_address` (body): true = Sie, false = Du
- `multilingual` (body): true | false
- `choice_messages_enabled` (body): true = die KI darf mit Antwort-Buttons oder einer Auswahlliste fragen
- `style_instructions` (body): Stil-Anweisungen
- `general_instructions` (body): Allgemeine Anweisungen
- `closing_instructions` (body): Abschluss-Verhalten (überschreibt Standard; leer = Standard, Fähigkeit hat Vorrang)
- `…` (body): Alle weiteren editierbaren Felder: siehe GET /whatsapp/assistants/{assistantId}

Example response:

```json
{
  "data": {
    "id": "whatsapp-assistant-uuid",
    "name": "WhatsApp Assistent",
    "first_message": "Hallo! Wie kann ich helfen?",
    "multilingual": false,
    "formal_address": true,
    "style_instructions": null,
    "general_instructions": null,
    "closing_instructions": null,
    "handoff_user_id": null,
    "handoff_prefer_last_assigned": true,
    "ai_schedule_mode": "always",
    "appointment_booking_mode": "direct",
    "link_messages_enabled": false,
    "booking_confirmation_link_message": null,
    "inherits_calendar_from_assistant_id": null,
    "choice_messages_enabled": false,
    "opening_hours": null,
    "closed_date_ranges": null,
    "calendars": [],
    "task_type_ids": [],
    "handoff_rules": [],
    "link_templates": [],
    "pdf_templates": []
  }
}
```

### GET /whatsapp/accounts

WhatsApp-Nummern auflisten. Die verbundenen WhatsApp-Nummern des Kontos (die „Inboxes").

Example response:

```json
{
  "data": {
    "meta": { "total_count": 1 },
    "payload": [
      {
        "id": "whatsapp-account-uuid",
        "display_phone_number": "+4930123456",
        "verified_name": "Beispiel GmbH",
        "active": true,
        "ai_auto_reply_enabled": true,
        "welcome_message_enabled": true,
        "out_of_office_enabled": false,
        "template_messages_enabled": true,
        "assistant_id": "whatsapp-assistant-uuid",
        "conversations_count": 128,
        "created_at": "2026-06-01T00:00:00.000Z",
        "updated_at": "2026-07-15T10:30:00.000Z"
      }
    ]
  }
}
```

### GET /whatsapp/conversations

Konversationen synchronisieren. Cursor-basierter Feed, stabil nach updated_at sortiert. Ideal für Polling-Syncs.

Parameters:
- `updated_since` (query): ISO-8601; nur seitdem geänderte Konversationen
- `cursor` (query): Vom letzten Response zurückgegeben
- `limit` (query): 1-100, Standard 50
- `status` (query): active | closed | paused
- `account_id` (query): UUID einer WhatsApp-Nummer
- `assigned` (query): ai | human
- `q` (query): Suche über die Telefonnummer

Example response:

```json
{
  "data": {
    "meta": {
      "limit": 50,
      "has_more": true,
      "next_cursor": "eyJ1cGRhdGVkQXQiOiIyMDI2LTA3LTE1VDEwOjMwOjA1LjAwMFoiLCJpZCI6ImNvbnZlcnNhdGlvbi11dWlkIn0"
    },
    "payload": [
      {
        "id": "conversation-uuid",
        "account_id": "whatsapp-account-uuid",
        "phone_number": "+491700000000",
        "status": "active",
        "assigned_user_id": null,
        "ai_active": true,
        "ai_muted_until": null,
        "partner_control": false,
        "partner_control_until": null,
        "snoozed_until": null,
        "unread_count": 1,
        "messages_count": 8,
        "last_message_at": "2026-07-15T10:30:00.000Z",
        "last_closed_at": null,
        "labels": [
          { "id": "label-uuid", "name": "VIP", "color": "#ef4444", "sort_order": 0, "created_at": "2026-07-01T00:00:00.000Z", "updated_at": "2026-07-01T00:00:00.000Z" }
        ],
        "customer": {
          "id": "customer-uuid",
          "name": "Marco Schneider",
          "phone_number": "+491700000000"
        },
        "last_message": {
          "id": "message-uuid",
          "external_message_id": "wamid.HBgL...",
          "conversation_id": "conversation-uuid",
          "account_id": "whatsapp-account-uuid",
          "direction": "inbound",
          "status": "delivered",
          "type": "text",
          "from": "+491700000000",
          "to": "+4930123456",
          "text": "Hallo, ich hätte eine Frage zu meinem Termin.",
          "caption": null,
          "media": null,
          "interactive": null,
          "interactive_reply": null,
          "sent_by": null,
          "sent_by_user_id": null,
          "sent_via_api": false,
          "reply_to_message_id": null,
          "reactions": null,
          "error_code": null,
          "error_message": null,
          "timestamp": "2026-07-15T10:30:00.000Z",
          "created_at": "2026-07-15T10:30:00.000Z",
          "updated_at": "2026-07-15T10:30:05.000Z"
        },
        "created_at": "2026-07-14T09:00:00.000Z",
        "updated_at": "2026-07-15T10:30:05.000Z"
      }
    ]
  }
}
```

### GET /whatsapp/conversations/{conversationId}/messages

Nachrichten auflisten. Nachrichten einer Konversation, älteste zuerst, cursor-basiert.

Parameters:
- `conversationId` (path, required): UUID der Konversation
- `since` (query): ISO-8601 auf dem Nachrichten-Zeitstempel
- `cursor` (query): Vom letzten Response zurückgegeben
- `direction` (query): inbound | outbound

Example response:

```json
{
  "data": {
    "meta": {
      "limit": 50,
      "has_more": false,
      "next_cursor": null
    },
    "payload": [
      {
        "id": "message-uuid",
        "external_message_id": "wamid.HBgL...",
        "conversation_id": "conversation-uuid",
        "account_id": "whatsapp-account-uuid",
        "direction": "inbound",
        "status": "delivered",
        "type": "text",
        "from": "+491700000000",
        "to": "+4930123456",
        "text": "Hallo, ich hätte eine Frage zu meinem Termin.",
        "caption": null,
        "media": null,
        "interactive": null,
        "interactive_reply": null,
        "sent_by": null,
        "sent_by_user_id": null,
        "sent_via_api": false,
        "reply_to_message_id": null,
        "reactions": null,
        "error_code": null,
        "error_message": null,
        "timestamp": "2026-07-15T10:30:00.000Z",
        "created_at": "2026-07-15T10:30:00.000Z",
        "updated_at": "2026-07-15T10:30:05.000Z"
      }
    ]
  }
}
```

### POST /whatsapp/conversations/{conversationId}/messages

Nachricht senden. Sendet Text, Antwort-Buttons, eine Auswahlliste oder eine Vorlage in eine bestehende Konversation. Alles außer Vorlagen nur im 24-Stunden-Fenster. Header `Idempotency-Key` unterstützt.

Parameters:
- `conversationId` (path, required): UUID der Konversation
- `type` (body): `text`, `buttons`, `list` oder `template`
- `text` (body): Nachrichtentext (bei text, buttons, list). Max. 1024 Zeichen bei buttons, 4096 bei list.
- `buttons` (body): Bei type=buttons: 1–3 Einträge `{ title, id? }`, Titel max. 20 Zeichen und eindeutig
- `rows` (body): Bei type=list: 1–10 Einträge `{ title, description?, id? }`, Titel max. 24, Beschreibung max. 72 Zeichen
- `button` (body): Bei type=list: Beschriftung des Buttons, der die Liste öffnet (max. 20 Zeichen)
- `header` (body): Optionale Kopfzeile bei buttons/list (max. 60 Zeichen)
- `footer` (body): Optionale Fußzeile bei buttons/list (max. 60 Zeichen)
- `template` (body): { name oder id, language?, variables? } (bei type=template)
- `partner_control` (body): `true` übernimmt die Konversation für Ihre Integration: keine KI, keine automatischen Antworten, keine Makros

Example response:

```json
{
  "data": {
    "id": "message-uuid",
    "external_message_id": "wamid.HBgL...",
    "conversation_id": "conversation-uuid",
    "account_id": "whatsapp-account-uuid",
    "direction": "inbound",
    "status": "delivered",
    "type": "text",
    "from": "+491700000000",
    "to": "+4930123456",
    "text": "Hallo, ich hätte eine Frage zu meinem Termin.",
    "caption": null,
    "media": null,
    "interactive": null,
    "interactive_reply": null,
    "sent_by": null,
    "sent_by_user_id": null,
    "sent_via_api": false,
    "reply_to_message_id": null,
    "reactions": null,
    "error_code": null,
    "error_message": null,
    "timestamp": "2026-07-15T10:30:00.000Z",
    "created_at": "2026-07-15T10:30:00.000Z",
    "updated_at": "2026-07-15T10:30:05.000Z"
  }
}
```

### POST /whatsapp/conversations/{conversationId}/notes

Interne Notiz hinzufügen. Legt eine interne Notiz zur Konversation an. Die Notiz wird nicht an den WhatsApp-Kontakt gesendet. Erfolg: HTTP 201.

Parameters:
- `conversationId` (path, required): UUID der Konversation
- `content` (body, required): Nicht leerer Notiztext; JSON-Body: `{ "content": "Vorgang abgeschlossen" }`

Example response:

```json
{
  "data": {
    "id": "note-uuid",
    "content": "Vorgang abgeschlossen. Ergebnis: …",
    "author_user_id": "api",
    "mentioned_user_ids": [],
    "created_at": "2026-07-01T00:00:00.000Z"
  }
}
```

### POST /whatsapp/messages

Konversation starten & senden. Sendet an eine Telefonnummer und legt Kontakt + Konversation bei Bedarf an. Neue Konversationen benötigen eine Vorlage.

Parameters:
- `account_id` (body, required): UUID der WhatsApp-Nummer
- `to` (body, required): Ziel-Telefonnummer (E.164)
- `contact_name` (body): Anzeigename für einen neuen Kontakt
- `template` (body): { name oder id, language?, variables? }
- `partner_control` (body): `true` übernimmt die Konversation für Ihre Integration: keine KI, keine automatischen Antworten, keine Makros

Example response:

```json
{
  "data": {
    "id": "message-uuid",
    "external_message_id": "wamid.HBgL...",
    "conversation_id": "conversation-uuid",
    "account_id": "whatsapp-account-uuid",
    "direction": "inbound",
    "status": "delivered",
    "type": "text",
    "from": "+491700000000",
    "to": "+4930123456",
    "text": "Hallo, ich hätte eine Frage zu meinem Termin.",
    "caption": null,
    "media": null,
    "interactive": null,
    "interactive_reply": null,
    "sent_by": null,
    "sent_by_user_id": null,
    "sent_via_api": false,
    "reply_to_message_id": null,
    "reactions": null,
    "error_code": null,
    "error_message": null,
    "timestamp": "2026-07-15T10:30:00.000Z",
    "created_at": "2026-07-15T10:30:00.000Z",
    "updated_at": "2026-07-15T10:30:05.000Z"
  }
}
```

### PATCH /whatsapp/conversations/{conversationId}

Konversation aktualisieren. Status schließen/öffnen, snoozen, Zuweisung ändern (`assigned_user_id: null` gibt an die KI zurück) oder die Partner-Steuerung setzen. Der Body wird vollständig geprüft, bevor etwas geändert wird.

Parameters:
- `conversationId` (path, required): UUID der Konversation
- `status` (body): active | closed
- `snoozed_until` (body): ISO-8601 (überschreibt status)
- `assigned_user_id` (body): ID einer Person aus GET /users (Mitglied oder `ext_…`) oder null (KI)
- `partner_control` (body): `true` übernimmt, `false` gibt frei. Schließen oder Zuweisen beendet die Steuerung von selbst.

Example response:

```json
{
  "data": {
    "id": "conversation-uuid",
    "account_id": "whatsapp-account-uuid",
    "phone_number": "+491700000000",
    "status": "active",
    "assigned_user_id": null,
    "ai_active": true,
    "ai_muted_until": null,
    "partner_control": false,
    "partner_control_until": null,
    "snoozed_until": null,
    "unread_count": 1,
    "messages_count": 8,
    "last_message_at": "2026-07-15T10:30:00.000Z",
    "last_closed_at": null,
    "labels": [
      { "id": "label-uuid", "name": "VIP", "color": "#ef4444", "sort_order": 0, "created_at": "2026-07-01T00:00:00.000Z", "updated_at": "2026-07-01T00:00:00.000Z" }
    ],
    "customer": {
      "id": "customer-uuid",
      "name": "Marco Schneider",
      "phone_number": "+491700000000"
    },
    "last_message": {
      "id": "message-uuid",
      "external_message_id": "wamid.HBgL...",
      "conversation_id": "conversation-uuid",
      "account_id": "whatsapp-account-uuid",
      "direction": "inbound",
      "status": "delivered",
      "type": "text",
      "from": "+491700000000",
      "to": "+4930123456",
      "text": "Hallo, ich hätte eine Frage zu meinem Termin.",
      "caption": null,
      "media": null,
      "interactive": null,
      "interactive_reply": null,
      "sent_by": null,
      "sent_by_user_id": null,
      "sent_via_api": false,
      "reply_to_message_id": null,
      "reactions": null,
      "error_code": null,
      "error_message": null,
      "timestamp": "2026-07-15T10:30:00.000Z",
      "created_at": "2026-07-15T10:30:00.000Z",
      "updated_at": "2026-07-15T10:30:05.000Z"
    },
    "created_at": "2026-07-14T09:00:00.000Z",
    "updated_at": "2026-07-15T10:30:05.000Z"
  }
}
```

### PUT /whatsapp/conversations/{conversationId}/labels

Labels setzen. Ersetzt die Labels einer Konversation. Body: `{ label_ids: [...] }`.

Parameters:
- `conversationId` (path, required): UUID der Konversation

Example response:

```json
{
  "data": {
    "payload": [
      { "id": "label-uuid", "name": "VIP", "color": "#ef4444", "sort_order": 0, "created_at": "2026-07-01T00:00:00.000Z", "updated_at": "2026-07-01T00:00:00.000Z" }
    ]
  }
}
```

### POST /whatsapp/conversations/{conversationId}/labels

Labels hinzufügen. Fügt Labels hinzu und lässt die vorhandenen stehen. Body: `{ label_ids: [...] }`.

Parameters:
- `conversationId` (path, required): UUID der Konversation

Example response:

```json
{
  "data": {
    "payload": [
      { "id": "label-uuid", "name": "VIP", "color": "#ef4444", "sort_order": 0, "created_at": "2026-07-01T00:00:00.000Z", "updated_at": "2026-07-01T00:00:00.000Z" }
    ]
  }
}
```

### GET /whatsapp/labels

Labels auflisten. Alle Konversations-Labels des Kontos.

Example response:

```json
{
  "data": {
    "payload": [
      { "id": "label-uuid", "name": "VIP", "color": "#ef4444", "sort_order": 0, "created_at": "2026-07-01T00:00:00.000Z", "updated_at": "2026-07-01T00:00:00.000Z" }
    ]
  }
}
```

### GET /whatsapp/templates

Vorlagen auflisten. Nachrichtenvorlagen der WhatsApp-Nummer mit dem aktuellen Freigabestatus direkt von Meta. Standard sind die freigegebenen (sendbaren) — nötig, um außerhalb des 24-Stunden-Fensters zu senden. Die Liste ist vollständig, ohne Paginierung.

Parameters:
- `account_id` (query): UUID der WhatsApp-Nummer (optional bei nur einer Nummer)
- `status` (query): `approved` (Standard), `all` oder ein einzelner Status wie `pending`, `rejected`, `paused`

Example response:

```json
{
  "data": {
    "meta": { "account_id": "whatsapp-account-uuid", "total_count": 1 },
    "payload": [
      {
        "id": "1234567890",
        "name": "reminder",
        "language": "de",
        "category": "UTILITY",
        "status": "approved",
        "components": [
          { "type": "BODY", "text": "Hallo {{1}}, Ihr Termin ist am {{2}}." }
        ]
      }
    ]
  }
}
```

### GET /whatsapp/templates/{templateId}

Vorlage abrufen. Eine Vorlage mit ihrem aktuellen Meta-Freigabestatus — so lässt sich verfolgen, ob sie noch geprüft, freigegeben oder abgelehnt wurde. `pending` bedeutet, dass Meta noch keine Entscheidung gemeldet hat; die Prüfung kann mehrere Stunden dauern.

Parameters:
- `templateId` (path, required): ID der Vorlage (aus der Liste oder aus der Antwort beim Anlegen)
- `account_id` (query): UUID der WhatsApp-Nummer (optional bei nur einer Nummer)

Example response:

```json
{
  "data": {
    "account_id": "whatsapp-account-uuid",
    "id": "1234567890",
    "name": "hausrat_erstkontakt_du",
    "language": "de",
    "category": "UTILITY",
    "status": "pending",
    "components": [
      { "type": "BODY", "text": "Hallo {{1}}, dein Berater {{2}} meldet sich.", "example": { "body_text": [["Max", "Frau Sommer"]] } },
      { "type": "BUTTONS", "buttons": [{ "type": "QUICK_REPLY", "text": "Ja, gerne" }] }
    ]
  }
}
```

### POST /whatsapp/templates

Vorlage anlegen. Legt eine Vorlage an und reicht sie bei Meta zur Prüfung ein. Für die Freigabe gibt es keine Minutengarantie; sie ist erst bei Status `approved` sendbar. Lehnt Meta die Anfrage ab (z. B. Name schon vergeben), kommt 422 mit Metas Begründung.

Parameters:
- `account_id` (body): UUID der WhatsApp-Nummer (optional bei nur einer Nummer)
- `name` (body, required): Nur Kleinbuchstaben, Ziffern und Unterstriche
- `language` (body, required): Sprachcode, z. B. `de` oder `en_US`
- `category` (body, required): `utility` oder `marketing`
- `body` (body, required): Text mit Platzhaltern `{{1}}`, `{{2}}`, … (lückenlos ab 1, nicht am Anfang oder Ende). Max. 1024 Zeichen.
- `examples` (body): Ein Beispielwert je Platzhalter, in Reihenfolge. Pflicht, sobald der Text Platzhalter enthält.
- `header` (body): Optionale Kopfzeile, nur Text ohne Platzhalter (max. 60 Zeichen)
- `footer` (body): Optionale Fußzeile (max. 60 Zeichen)
- `buttons` (body): Bis zu 10 Quick-Reply-Buttons: `[{ "type": "quick_reply", "text": "Ja, gerne" }]` (Text max. 25 Zeichen)

Example response:

```json
{
  "data": {
    "account_id": "whatsapp-account-uuid",
    "id": "1234567890",
    "name": "hausrat_erstkontakt_du",
    "language": "de",
    "category": "UTILITY",
    "status": "pending",
    "components": [
      { "type": "BODY", "text": "Hallo {{1}}, dein Berater {{2}} meldet sich.", "example": { "body_text": [["Max", "Frau Sommer"]] } },
      { "type": "BUTTONS", "buttons": [{ "type": "QUICK_REPLY", "text": "Ja, gerne" }] }
    ]
  }
}
```

## Users

### GET /users

Team-Mitglieder auflisten. Das Team des Kontos (id, name, email), dem ein Chat zugewiesen werden kann — für die Auswahl von `assigned_user_id`: Mitglieder und Kollegen aus WhatsApp Web ohne HalloFelix-Login (id `ext_…`, in den letzten 30 Tagen aktiv), ohne interne HalloFelix-Administratoren.

Example response:

```json
{
  "data": {
    "meta": { "total_count": 1 },
    "payload": [
      {
        "id": "user_123",
        "name": "Marco Schneider",
        "email": "marco@example.com"
      }
    ]
  }
}
```

## Webhooks

### GET /webhook

Webhook-Ziel prüfen. Liefert, ob ein Ziel der aktuellen API konfiguriert ist, und dessen SHA-256-Fingerabdruck. Die URL selbst und die Chatwoot-kompatible URL werden nicht zurückgegeben.

Example response:

```json
{
  "data": {
    "configured": true,
    "url_sha256": "be935acb4b320c0bb1bf78d746dad045290c2bc1b54b5cc26f5bd8a29b3269e1"
  }
}
```

### PUT /webhook

Webhook-Ziel setzen. Setzt die URL der aktuellen API nur, wenn sie noch dem zuletzt ausgelesenen Wert entspricht. Ein vorhandenes Ziel erfordert zusätzlich eine ausdrückliche Ersetzungsbestätigung.

Parameters:
- `webhook_url` (body, required): Öffentliche HTTPS-URL auf Port 443 (kein IP-Literal, keine Zugangsdaten oder Fragment)
- `expected_url_sha256` (body, required): Fingerabdruck aus GET /webhook, auch null; bei Abweichung 409
- `confirm_replace` (body): true, wenn ein bestehendes anderes Ziel nach Bestätigung ersetzt werden soll

Example response:

```json
{
  "data": {
    "configured": true,
    "url_sha256": "be935acb4b320c0bb1bf78d746dad045290c2bc1b54b5cc26f5bd8a29b3269e1"
  }
}
```

## WhatsApp Messaging

Send messages with `POST /whatsapp/conversations/{conversationId}/messages` (reply into an existing chat)
or `POST /whatsapp/messages` (start a new chat by phone number, creating the contact + conversation).

Rules:
- Body is `{ "type": "text", "text": "…" }` or `{ "type": "template", "template": { "name", "language?", "variables?" } }`.
- Reply buttons: `{ "type": "buttons", "text": "Passt das so?", "buttons": [{ "title": "Ja" }, { "title": "Nein" }] }` — 1 to 3 buttons, titles up to 20 characters and unique.
- List: `{ "type": "list", "text": "Welches Fahrzeug?", "button": "Auswählen", "rows": [{ "title": "VW Golf", "description": "HH-AB 123" }] }` — 1 to 10 rows, title up to 24 and description up to 72 characters. Both accept an optional `header` and `footer` (60 characters each) and an optional `id` per entry (default `option_1`, `option_2`, …).
- Free-form `text`, `buttons` and `list` are only allowed inside the 24-hour WhatsApp customer-service window (i.e. within 24h of the last inbound message). Outside it, you MUST send an approved `template`. List templates with `GET /whatsapp/templates`.
- Pass an `Idempotency-Key` header to make retries safe — the same key returns the first result instead of sending twice.
- An API `text` reply is treated like a human agent reply: it mutes the AI auto-reply on that conversation.
- Change conversation state with `PATCH /whatsapp/conversations/{id}` (`status`, `snoozed_until`, `assigned_user_id` — `null` hands the chat back to the AI). Discover user ids with `GET /users`.

### Templates

- `GET /whatsapp/templates` lists the approved templates; `?status=all` lists every template with its review status (`approved`, `pending`, `rejected`, `paused`, …).
- The status comes directly from Meta, not a local approval queue. `pending` means Meta has not reported a decision; review can take hours, so do not promise approval within minutes or recreate a pending template just because it is still in review.
- Create one with `POST /whatsapp/templates`: `{ "name": "hausrat_erstkontakt_du", "language": "de", "category": "utility", "body": "Hallo {{1}}, dein Berater {{2}} meldet sich.", "examples": ["Max", "Frau Sommer"], "buttons": [{ "type": "quick_reply", "text": "Ja, gerne" }] }`. Placeholders are positional and bound to nothing: you pass their values when you send. Meta reviews the template; follow it with `GET /whatsapp/templates/{id}` until `status` is `approved`.
- Send a template by `name` (optionally with `language`) or by `id`: `{ "type": "template", "template": { "id": "1234567890", "variables": { "1": "Max", "2": "Frau Sommer" } } }`.
- A 422 on create means Meta refused the template and retrying unchanged will not help; `error` carries the reason Meta gives (for example that the name already has content in this language). A 502 means Meta could not be reached. Both carry `error_code` and `retryable`.

### Reading replies

- When the customer taps a button or picks a list row, the inbound message carries `interactive_reply: { type, id, title, description }`. Match on `title` (or `id`): `text` is the display form and, for a list row, also contains the description.
- An outbound buttons/list message carries its choices in `interactive: { type, header, footer, button, options }`; `text` is its body.
- Every message has `account_id`, the WhatsApp number it belongs to.
- Message lists are returned oldest first. Pass `since` (ISO-8601) to read only what is new.

### Running your own dialog (partner control)

If your integration runs the whole conversation itself (a chatbot), send `"partner_control": true` with your messages, starting with the first template.
While it is active, HalloFelix writes nothing into the chat on its own: no AI reply, no welcome or out-of-office message, no macro.

- Every send with `partner_control: true` extends the control by 7 days; without further sends it ends by itself.
- Closing the conversation (`status: "closed"`) or assigning it to a user (`assigned_user_id`) ends it. That is the handover: add a note with `POST …/notes`, a label with `POST …/labels`, then assign.
- `PATCH /whatsapp/conversations/{id}` with `partner_control: true | false` sets it without sending. The conversation reports `partner_control` and `partner_control_until`.
- A send with `partner_control: true` is allowed even if a conversation is already assigned to a user; it does not remove that assignment or prevent manual replies. Check `assigned_user_id` before taking over if your integration wants to avoid employee-owned chats. This check is optional, not enforced by the API.
- `whatsapp.message.sent` fires for every outbound message, including replies your team types in the inbox or on the phone. Your own sends carry `sent_via_api: true`; anything else means someone other than you wrote into the chat.

### When a send fails

A failed send answers `{ "error": "…", "error_code": "131026", "retryable": false }`. `error_code` is the code WhatsApp gave (null when the refusal is ours), and `retryable` says whether sending the same request again can succeed:

- `400` — the request body is wrong. Not retryable.
- `409` — the conversation or the WhatsApp account does not allow the send right now: the 24-hour window is closed, or the account has to be reconnected in HalloFelix. Not retryable.
- `422` — WhatsApp refused this message: the number is not on WhatsApp, the template is paused or its variables do not fit. Not retryable.
- `429` — WhatsApp is rate-limiting the account. Retryable; wait the seconds given in the `Retry-After` header.
- `502` — WhatsApp failed or could not be reached. Retryable.

Every refusal of the two send endpoints has this body, including our own (a malformed request, an unknown conversation, an inactive account). The one refusal of ours that is retryable is a 409 for an `Idempotency-Key` whose first request is still running.

With an `Idempotency-Key` header a retry never sends twice.

## Pre-Call Enrichment

Optionally inject your own data into an outbound call before it starts. Configure a pre-call URL in the
HalloFelix app (Integrations → API → Zugang → Pre-Call-URL). Then, immediately before each outbound call,
we send it a signed POST and wait briefly (5s timeout) for a response.

Request we send you:

```json
{
  "event": "call.pending",
  "account": { "id": "<accountId>" },
  "data": {
    "call_id": "call-uuid",
    "to_number": "+491700000000",
    "assistant_id": "assistant-uuid",
    "campaign_id": "campaign-uuid",
    "customer": { "id": "customer-uuid", "first_name": "Marco", "last_name": "Schneider", "name": "Marco Schneider" }
  }
}
```

Headers: `x-hallofelix-event: call.pending`, `x-hallofelix-timestamp`, `x-hallofelix-signature` (HMAC-SHA256 over `timestamp + "." + rawBody`, secret = the API token).

Respond with the variables to inject:

```json
{ "variable_values": { "kundenstatus": "Premium", "offener_betrag": "149,00 EUR" } }
```

These become available in the assistant prompt as `{{kundenstatus}}` etc. Values must be strings. Internal
variables (account, assistant, customer) always win and cannot be overridden. Any timeout, error, or non-2xx
response is ignored and the call proceeds normally (fail-open).

## Webhooks

Configure the current API webhook URL under Integrations → API or check its fingerprint with `GET /webhook` and set it with `PUT /webhook`. It is separate from the Chatwoot-compatible webhook URL for Make or other existing integrations, so changing one does not replace the other.

`GET /webhook` returns `{ "data": { "configured": true | false, "url_sha256": "<64 hex digits>" | null } }`. Compare the fingerprint with SHA-256 of the exact expected URL (UTF-8); the URL itself is never exposed, because it may contain another integration's secret. To reconnect, send `PUT /webhook` with `{ "webhook_url": "https://your-app.example/webhook", "expected_url_sha256": <the fingerprint from GET> }`. If the destination changed meanwhile, the server returns 409 without writing; fetch it again. Replacing a different, non-null URL also requires `"confirm_replace": true`. Obtain account-owner confirmation before replacing it; the API token authorizes the request but cannot prove that a human clicked a button. Never silently reset a URL that someone changed intentionally.

Only public HTTPS hostnames on port 443 are accepted. Redirects are not followed on delivery, and private or reserved DNS destinations are blocked at connection time. Token rotation does not clear a configured webhook URL; to stop delivery, remove the URL in the app or revoke the API token. A given account has one current-API webhook URL; multiple consumers need their own fan-out. We deliver JSON POST requests for these events:

- `call.created`
- `call.completed`
- `customer.created`
- `customer.updated`
- `whatsapp.message.received`
- `whatsapp.message.sent`
- `whatsapp.message.status`
- `whatsapp.conversation.created`
- `whatsapp.conversation.updated`

Headers:

- `x-hallofelix-event`
- `x-hallofelix-timestamp`
- `x-hallofelix-signature`

Verify the signature with HMAC-SHA256 over:

```
timestamp + "." + rawBody
```

Use the same tenant API token as the HMAC secret.

Delivery: answer with any 2xx within a few seconds. On a 5xx, a 408, a 429 or no answer we try again with growing pauses for about 20 minutes (9 attempts). A 4xx other than 408/429 is taken as a permanent refusal and not retried. Events can arrive out of order and, after a retry, more than once: use the `id` and `timestamp` in the payload. If your endpoint was down for longer, read what you missed with `GET /whatsapp/conversations/{id}/messages?since=…`.

Call webhooks (`call.created`, `call.completed`): `recording_url` und `stereo_recording_url` sind signierte Links, die 60 Minuten gültig sind. Laden Sie die Aufnahme innerhalb dieser Zeit herunter oder rufen Sie den Endpunkt erneut ab, um einen neuen Link zu erhalten. Links dauerhaft zu speichern wird nicht unterstützt. Each delivery attempt carries a freshly signed link.

### Webhook examples

#### call.completed
```json
{
  "event": "call.completed",
  "account": {
    "id": "tenant_123",
    "name": "Beispiel GmbH"
  },
  "data": {
    "id": "call-uuid",
    "date_time": "2026-07-15T10:30:00.000Z",
    "updated_at": "2026-07-15T10:35:00.000Z",
    "duration_ms": 92400,
    "duration_seconds": 92,
    "direction": "outbound",
    "outbound_status": "completed",
    "effective_call_routing": "immediately",
    "from_number": "+4930123456",
    "to_number": "+491700000000",
    "success": true,
    "rating": null,
    "caller_spoke": true,
    "summary": "Customer reached and appointment booked.",
    "recording_url": "https://<account>.r2.cloudflarestorage.com/call-uuid-mono.mp3?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=3600&X-Amz-Signature=…",
    "stereo_recording_url": null,
    "recording_deleted_at": null,
    "scheduled_at": null,
    "enqueued_at": "2026-07-15T10:00:00.000Z",
    "attempt_number": 1,
    "root_call_id": null,
    "initiated_by": "user_123",
    "customer": {
      "id": "customer-uuid",
      "external_identifier": "crm-4711",
      "first_name": "Marco",
      "last_name": "Schneider",
      "name": "Marco Schneider",
      "email": "marco@example.com",
      "language": "de",
      "call_handling_override": null,
      "phone_number": "+491700000000",
      "phone_numbers": [
        {
          "id": "phone-uuid",
          "phone_number": "+491700000000",
          "phone_type": "mobile",
          "is_primary": true
        }
      ],
      "calls_count": 3,
      "last_called_at": "2026-07-15T10:30:00.000Z",
      "created_at": "2026-07-10T08:00:00.000Z",
      "updated_at": "2026-07-15T10:35:00.000Z"
    },
    "assistant": {
      "id": "assistant-uuid",
      "name": "Outbound Felix",
      "assistant_phone_number": "+4930123456"
    },
    "campaign": {
      "id": "campaign-uuid",
      "name": "Q3 Follow-up",
      "status": "running"
    },
    "transcript": [
      {
        "role": "assistant",
        "text": "Hello Mr Schneider, this is HalloFelix."
      },
      {
        "role": "user",
        "text": "Yes, speaking."
      }
    ],
    "transcript_source": "improved",
    "summary_data_points": {
      "appointment_booked": true,
      "preferred_time": "2026-07-20T09:00:00.000Z"
    }
  }
}
```

#### customer.updated
```json
{
  "event": "customer.updated",
  "account": {
    "id": "tenant_123",
    "name": "Beispiel GmbH"
  },
  "data": {
    "id": "customer-uuid",
    "external_identifier": "crm-4711",
    "first_name": "Marco",
    "last_name": "Schneider",
    "name": "Marco Schneider",
    "email": "marco@example.com",
    "language": "de",
    "call_handling_override": null,
    "phone_number": "+491700000000",
    "phone_numbers": [
      {
        "id": "phone-uuid",
        "phone_number": "+491700000000",
        "phone_type": "mobile",
        "is_primary": true
      }
    ],
    "calls_count": 3,
    "last_called_at": "2026-07-15T10:30:00.000Z",
    "created_at": "2026-07-10T08:00:00.000Z",
    "updated_at": "2026-07-15T10:35:00.000Z"
  }
}
```

#### whatsapp.message.received
```json
{
  "event": "whatsapp.message.received",
  "account": {
    "id": "tenant_123",
    "name": "Beispiel GmbH"
  },
  "data": {
    "id": "message-uuid",
    "external_message_id": "wamid.HBgL...",
    "conversation_id": "conversation-uuid",
    "account_id": "whatsapp-account-uuid",
    "direction": "inbound",
    "status": "delivered",
    "type": "text",
    "from": "+491700000000",
    "to": "+4930123456",
    "text": "Hallo, ich hätte eine Frage zu meinem Termin.",
    "caption": null,
    "media": null,
    "interactive": null,
    "interactive_reply": null,
    "sent_by": null,
    "sent_by_user_id": null,
    "sent_via_api": false,
    "reply_to_message_id": null,
    "reactions": null,
    "error_code": null,
    "error_message": null,
    "timestamp": "2026-07-15T10:30:00.000Z",
    "created_at": "2026-07-15T10:30:00.000Z",
    "updated_at": "2026-07-15T10:30:05.000Z"
  }
}
```

#### whatsapp.conversation.updated
```json
{
  "event": "whatsapp.conversation.updated",
  "account": {
    "id": "tenant_123",
    "name": "Beispiel GmbH"
  },
  "data": {
    "id": "conversation-uuid",
    "account_id": "whatsapp-account-uuid",
    "phone_number": "+491700000000",
    "status": "active",
    "assigned_user_id": null,
    "ai_active": true,
    "ai_muted_until": null,
    "partner_control": false,
    "partner_control_until": null,
    "snoozed_until": null,
    "unread_count": 1,
    "messages_count": 8,
    "last_message_at": "2026-07-15T10:30:00.000Z",
    "last_closed_at": null,
    "labels": [
      { "id": "label-uuid", "name": "VIP", "color": "#ef4444", "sort_order": 0, "created_at": "2026-07-01T00:00:00.000Z", "updated_at": "2026-07-01T00:00:00.000Z" }
    ],
    "customer": {
      "id": "customer-uuid",
      "name": "Marco Schneider",
      "phone_number": "+491700000000"
    },
    "last_message": {
      "id": "message-uuid",
      "external_message_id": "wamid.HBgL...",
      "conversation_id": "conversation-uuid",
      "account_id": "whatsapp-account-uuid",
      "direction": "inbound",
      "status": "delivered",
      "type": "text",
      "from": "+491700000000",
      "to": "+4930123456",
      "text": "Hallo, ich hätte eine Frage zu meinem Termin.",
      "caption": null,
      "media": null,
      "interactive": null,
      "interactive_reply": null,
      "sent_by": null,
      "sent_by_user_id": null,
      "sent_via_api": false,
      "reply_to_message_id": null,
      "reactions": null,
      "error_code": null,
      "error_message": null,
      "timestamp": "2026-07-15T10:30:00.000Z",
      "created_at": "2026-07-15T10:30:00.000Z",
      "updated_at": "2026-07-15T10:30:05.000Z"
    },
    "created_at": "2026-07-14T09:00:00.000Z",
    "updated_at": "2026-07-15T10:30:05.000Z"
  }
}
```
