---
title: "Webhooks"
description: "Bodo reports changes to your endpoint. An event is thin: type, id and time. You fetch the details through the API with your own rights."
lang: en
url: https://developers.bodo-app.com/en/guides/webhooks/
apiVersion: 2026-11-01
---

# Webhooks

Bodo reports changes to your endpoint. An event is thin: type, id and time. You fetch the details through the API with your own rights.

## How an event reaches you

Five steps. Bodo only sends the message “something changed”; you read what changed yourself.

1. **Change in Bodo**: web, MCP or API
2. **Outbox**: same transaction
3. **Permission filter**: may the subscription read it?
4. **Delivery**: signed, with retries
5. **Your endpoint**: answers 2xx within 10 s

Fetch details: GET /v1/contacts/con_4Nf7… with your own rights

The outbox writes the event in the same transaction as the change, so none gets lost. Delivery is at least once, without a fixed order: spot duplicates by `webhook-id`.

## An event as it arrives

Thin event: type, record id and time. No field values, no personal data.

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

You read the same events through `GET /v1/events`, for example to catch up after an outage.

## Create an endpoint

In Bodo under Settings › Interfaces › Webhooks, or through the API. The subscription belongs to the principal that creates it; each principal has at most 16 endpoints.

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

[Manage in Bodo](https://bodo-app.com/settings/webhooks): delivery log, resend, rotate the secret. Only `https` on port 443, otherwise [422 WEBHOOK_URL_INVALID](/en/problems/WEBHOOK_URL_INVALID/).

## Event catalog

You only get an event when the principal of your subscription may read the record at delivery time.

| Event | When | Needs read access |
| --- | --- | --- |
| `contact.created` | Contact created | `contacts:read` |
| `contact.updated` | Contact updated | `contacts:read` |
| `contact.deleted` | Contact deleted | `contacts:read` |
| `company.created` | Company created | `companies:read` |
| `company.updated` | Company updated | `companies:read` |
| `company.deleted` | Company deleted | `companies:read` |
| `task.created` | Task created | `tasks:read` |
| `task.updated` | Task updated | `tasks:read` |
| `task.completed` | Task completed | `tasks:read` |
| `task.deleted` | Task deleted | `tasks:read` |
| `document.created` | Document uploaded and checked | `documents:read` |
| `document.deleted` | Document deleted | `documents:read` |
| `operation.completed` | Long run finished (result or error in the operation) | own operation |
| `webhook.test` | Test event on demand | owner of the subscription |

Events of the blocked families (secrets, identity) do not exist. New event types are additive, invoices and time entries follow; ignore types you do not know.

## Verify the signature · TypeScript

Always on the raw body, never on parsed JSON. Acknowledge first, process after.

**@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 };
```

## Verify the signature · Python

Same rule: raw body, a window of 5 minutes.

**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()
```

## Without an SDK

HMAC-SHA256 over `webhook-id.webhook-timestamp.body`; the key is the secret without `whsec_`, Base64-decoded. Compare in constant time, timestamps at most 5 minutes old. Any Standard Webhooks library fits.

## Retries

When your endpoint does not answer with a 2xx, Bodo tries again with growing gaps for almost 3 days.

| Attempt | Gap | Since the event |
| --- | --- | --- |
| 1 | at once | with the event |
| 2 | +30 s | after 30 s |
| 3 | +2 min | after 2.5 min |
| 4 | +10 min | after 12.5 min |
| 5 | +1 h | after 1.2 h |
| 6 | +3 h | after 4.2 h |
| 7 | +6 h | after 10.2 h |
| 8 | +12 h | after 22.2 h |
| 9 | +1 day | after 1.9 days |
| 10 | +1 day | after 2.9 days |

Then given up. An attempt fails without a 2xx within 10 s. Every gap varies by ±10 %.

| Situation | What Bodo does |
| --- | --- |
| **Failing** | After 24 h without success the owner gets an email and an entry in the bell. |
| **Disabled** | After 72 h without success or on 410 Gone Bodo switches the endpoint off. Re-enable with one click, catch up with “Resend” or `updatedSince`. |
| **Delivery log** | Every attempt with status, response time and code, for 30 days. Bodo never stores the body of your answer. |
| **Rotate the secret** | For 24 h `webhook-signature` carries two signatures, old and new. No outage during the change. |

Creating, listing and deleting subscriptions and reading the delivery log work through MCP and the CLI as well. Syncing without webhooks: [Pagination & sync](/en/guides/pagination/).
