---
title: "Webhooks"
description: "Bodo meldet Änderungen aktiv an deinen Endpunkt. Ein Ereignis ist dünn: Typ, ID und Zeit. Die Details holst du per API mit deinen eigenen Rechten."
lang: de
url: https://developers.bodo-app.com/guides/webhooks/
apiVersion: 2026-11-01
---

# Webhooks

Bodo meldet Änderungen aktiv an deinen Endpunkt. Ein Ereignis ist dünn: Typ, ID und Zeit. Die Details holst du per API mit deinen eigenen Rechten.

## So kommt ein Ereignis bei dir an

Fünf Schritte. Bodo schickt nur die Nachricht „etwas hat sich geändert“; was genau, liest du selbst nach.

1. **Änderung in Bodo**: Web, MCP oder API
2. **Outbox**: gleiche Transaktion
3. **Rechte-Filter**: darf das Abo das lesen?
4. **Zustellung**: signiert, mit Wiederholung
5. **Dein Endpunkt**: antwortet 2xx in 10 s

Details holen: GET /v1/contacts/con_4Nf7… mit deinen eigenen Rechten

Die Outbox schreibt das Ereignis in derselben Transaktion wie die Änderung, es geht also keins verloren. Zugestellt wird mindestens einmal, ohne feste Reihenfolge: Erkenne Doppelte an `webhook-id`.

## Ein Ereignis, wie es ankommt

Thin Event: Typ, ID des Datensatzes und Zeit. Keine Feldwerte, keine Personendaten.

```http
POST https://hooks.musterfirma.de/bodo
webhook-id: evt_01JC4M8Q2R6T0V3X5Z7B9D1F3H
webhook-timestamp: 1793697262
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
User-Agent: Bodo-Webhooks/1.0
Content-Type: application/json

{
  "id": "evt_01JC4M8Q2R6T0V3X5Z7B9D1F3H",
  "type": "contact.updated",
  "createdAt": "2026-11-03T09:14:22.000Z",
  "data": {
    "object": "contact",
    "id": "con_4Nf7Kx9aB2cD3eF4gH5jK6mN7p"
  }
}
```

Dieselben Ereignisse liest du auch über `GET /v1/events`, etwa zum Nachholen nach einem Ausfall.

## Endpunkt anlegen

In Bodo unter Einstellungen › Schnittstellen › Webhooks oder per API. Das Abo gehört dem Prinzipal, der es anlegt; jeder Prinzipal hat höchstens 16 Endpunkte.

**TypeScript · SDK**

```typescript
// Portal D10 · Webhooks — create an endpoint for three event types and send the test event.
// Run: BODO_API_KEY=bodo_sa_test_… bun examples/webhook-endpoint.ts
import { Bodo } from "@bodo/api";

const bodo = new Bodo({ apiKey: process.env.BODO_API_KEY, preview: true });

const endpoint = await bodo.webhookEndpoints.create({
  name: "Musterfirma CRM-Abgleich",
  url: "https://hooks.musterfirma.de/bodo",
  eventTypes: ["contact.created", "contact.updated", "task.completed"],
});
// endpoint.secret (whsec_…) is shown exactly once: store it now
console.log(endpoint.id);

await bodo.webhookEndpoints.sendTest(endpoint.id); // sends webhook.test to your URL
```

[In Bodo verwalten](https://bodo-app.com/settings/webhooks): Zustell-Log, erneut senden, Secret wechseln. Nur `https` auf Port 443, sonst [422 WEBHOOK_URL_INVALID](/problems/WEBHOOK_URL_INVALID/).

## Event-Katalog

Du bekommst ein Ereignis nur, wenn der Prinzipal deines Abos den Datensatz zum Zeitpunkt der Zustellung lesen darf.

| Ereignis | Wann | Braucht Leserecht |
| --- | --- | --- |
| `contact.created` | Kontakt angelegt | `contacts:read` |
| `contact.updated` | Kontakt geändert | `contacts:read` |
| `contact.deleted` | Kontakt gelöscht | `contacts:read` |
| `company.created` | Firma angelegt | `companies:read` |
| `company.updated` | Firma geändert | `companies:read` |
| `company.deleted` | Firma gelöscht | `companies:read` |
| `task.created` | Aufgabe angelegt | `tasks:read` |
| `task.updated` | Aufgabe geändert | `tasks:read` |
| `task.completed` | Aufgabe erledigt | `tasks:read` |
| `task.deleted` | Aufgabe gelöscht | `tasks:read` |
| `document.created` | Dokument fertig hochgeladen | `documents:read` |
| `document.deleted` | Dokument gelöscht | `documents:read` |
| `operation.completed` | Langer Lauf beendet (Ergebnis oder Fehler im Vorgang) | eigener Vorgang |
| `webhook.test` | Test-Ereignis auf Knopfdruck | Besitzer des Abos |

Ereignisse aus den gesperrten Familien (Geheimnisse, Identität) gibt es nicht. Neue Ereignistypen kommen additiv, Rechnungen und Zeiten folgen; ignoriere Typen, die du nicht kennst.

## Signatur prüfen · TypeScript

Immer mit dem rohen Body, nie mit geparstem JSON. Erst quittieren, dann verarbeiten.

**@bodo/api/webhooks**

```typescript
// Portal D10 · Webhooks — verify every delivery on the raw body, acknowledge first, process after.
// Run: BODO_WEBHOOK_SECRET=whsec_… bun examples/webhook-verify.ts
import { Webhook, WebhookVerificationError } from "@bodo/api/webhooks";

const wh = new Webhook(process.env.BODO_WEBHOOK_SECRET);
const queue: string[] = [];

export async function handleBodoWebhook(request: Request): Promise<Response> {
  const body = await request.text(); // the RAW body, never re-serialized JSON
  try {
    // throws on a wrong signature or a timestamp more than 5 minutes off
    const { event } = await wh.verify(body, request.headers);
    queue.push(event.id); // acknowledge first, process from the queue
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(null, { status: 400 });
    throw error;
  }
}

export default { port: 8080, fetch: handleBodoWebhook };
```

## Signatur prüfen · Python

Gleiche Regel: roher Body, Zeitfenster 5 Minuten.

**bodo_api.webhooks**

```python
# Verify every delivery on the raw body, acknowledge first, process after.
# Run: BODO_WEBHOOK_SECRET=whsec_… python examples/webhook_verify.py
import os
from collections.abc import Mapping
from http.server import BaseHTTPRequestHandler, HTTPServer
from queue import Queue
from typing import Any

from bodo_api.webhooks import Webhook, WebhookVerificationError

wh = Webhook(os.environ.get("BODO_WEBHOOK_SECRET"))
events: Queue[dict[str, Any]] = Queue()


def handle(body: bytes, headers: Mapping[str, str]) -> int:
    """Status for Bodo: 204 when the delivery verifies, 400 otherwise."""
    try:
        event = wh.verify(body, headers)  # raises on a wrong signature or a timestamp > 5 min off
    except WebhookVerificationError:
        return 400
    events.put(event)  # acknowledge first, process from the queue
    return 204


class Hook(BaseHTTPRequestHandler):
    def do_POST(self) -> None:
        body = self.rfile.read(int(self.headers.get("Content-Length", "0")))  # the RAW body
        self.send_response(handle(body, dict(self.headers.items())))
        self.end_headers()


if __name__ == "__main__":
    HTTPServer(("", 8080), Hook).serve_forever()
```

## Ohne SDK

HMAC-SHA256 über `webhook-id.webhook-timestamp.body`, Schlüssel ist das Secret ohne `whsec_`, Base64-dekodiert. Vergleich in konstanter Zeit, Zeitstempel höchstens 5 Minuten alt. Jede Bibliothek nach Standard Webhooks passt.

## Wiederholungen

Antwortet dein Endpunkt nicht mit 2xx, versucht Bodo es erneut, mit wachsendem Abstand bis knapp 3 Tage.

| Versuch | Abstand | Seit dem Ereignis |
| --- | --- | --- |
| 1 | sofort | mit dem Ereignis |
| 2 | +30 s | nach 30 s |
| 3 | +2 min | nach 2,5 min |
| 4 | +10 min | nach 12,5 min |
| 5 | +1 h | nach 1,2 h |
| 6 | +3 h | nach 4,2 h |
| 7 | +6 h | nach 10,2 h |
| 8 | +12 h | nach 22,2 h |
| 9 | +1 Tag | nach 1,9 Tage |
| 10 | +1 Tag | nach 2,9 Tage |

Danach aufgegeben. Ein Versuch gilt als gescheitert ohne 2xx in 10 s. Jeder Abstand streut um ±10 %.

| Lage | Was Bodo tut |
| --- | --- |
| **Gestört** | Nach 24 h ohne Erfolg bekommt der Besitzer eine Mail und einen Eintrag in der Glocke. |
| **Deaktiviert** | Nach 72 h ohne Erfolg oder bei 410 Gone schaltet Bodo den Endpunkt ab. Reaktivieren per Klick, Verpasstes per „Erneut senden“ oder `updatedSince`. |
| **Zustell-Log** | Jeder Versuch mit Status, Antwortzeit und Code, 30 Tage lang. Den Body deiner Antwort speichert Bodo nie. |
| **Secret wechseln** | 24 h lang trägt `webhook-signature` zwei Signaturen, alt und neu. Kein Ausfall beim Wechsel. |

Abos anlegen, auflisten, löschen und das Zustell-Log lesen geht ebenso über MCP und die CLI. Zum Abgleich ohne Webhooks: [Pagination & Sync](/guides/pagination/).
