# Kennu API

The Kennu API lets your systems create meetings, add attendees and content, send briefings and see who has read them. Webhooks tell your systems when something happens; the MCP server lets AI assistants do the same on your behalf.

- Base URL: `https://dev.kennu.io/api/v1`
- JSON in, JSON out, UTF-8
- Every request is authenticated with an API key of your organization

## Quickstart

1. In Kennu, open **Organization → Developers → API keys** (owners and admins) and create a key with *Full access*. Copy it — it's shown once.
2. Create a meeting:

```bash
curl https://dev.kennu.io/api/v1/meetings \
  -H "Authorization: Bearer kn_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: crm-event-4711" \
  -d '{
    "title": "Partnership kickoff",
    "starts_at": "2026-11-03T15:00:00Z",
    "duration_minutes": 45,
    "purpose": "Agree on the pilot scope.",
    "attendees": [{ "email": "priya@brightwater.example", "name": "Priya Nair" }],
    "template_id": "4f1c..."
  }'
```

3. Send the briefing: `POST /api/v1/meetings/{id}/send`. Put the `briefing_url` from the response into your calendar or video-call invite.

## Authentication

Send your key in the `Authorization` header: `Authorization: Bearer kn_live_...`. Keys start with `kn_live_` in production and `kn_test_` elsewhere.

| Permission | Allows |
| --- | --- |
| Full access | Everything below |
| Read only | `GET` requests only |

A key belongs to your organization, not to a person. Meetings created through the API are organized by `organizer_email` (a member who may organize meetings) or, if you leave it out, by the member who created the key. Revoke keys you no longer need under Developers → API keys.

## Errors

Errors use HTTP status codes and a consistent body:

```json
{ "error": { "type": "validation_error", "message": "starts_at: Invalid ISO datetime" } }
```

| Status | Type | Meaning |
| --- | --- | --- |
| 401 | missing_api_key, invalid_api_key | No key, or a revoked or unknown key |
| 403 | insufficient_permission, not_an_organizer | Read-only key, or the organizer may not organize |
| 404 | not_found | Not in your organization |
| 409 | meeting_cancelled | The meeting was cancelled |
| 422 | validation_error, invalid_request, invalid_organizer | The request body isn't valid |
| 429 | rate_limit_exceeded | Slow down (see below) |
| 500 | internal_error | Our fault — try again |

## Rate limits

About 10 requests per second per key. Above that you get `429` with a `Retry-After` header.

## Idempotency

Send an `Idempotency-Key` header with `POST`, `PATCH` and `DELETE` requests. Repeating a request with the same key within 24 hours returns the first response (with `Idempotent-Replayed: true`) instead of doing it twice — useful for retries.

## Pagination

Lists return `{ "object": "list", "data": [...], "has_more": true }`. Use `?limit=1..100` (default 20) and `?after=<id of the last item>` for the next page.

## Meetings

### The meeting object

```json
{
  "object": "meeting",
  "id": "8f0e...",
  "title": "Partnership kickoff",
  "starts_at": "2026-11-03T15:00:00+00:00",
  "duration_minutes": 45,
  "purpose": "Agree on the pilot scope.",
  "organizer": { "id": "5777...", "name": "Mara Lindqvist" },
  "briefing_url": "https://dev.kennu.io/m/kIR3P-...",
  "briefing_sent_at": null,
  "cancelled_at": null,
  "created_at": "2026-10-20T19:20:00+00:00",
  "attendees": [
    { "email": "priya@brightwater.example", "name": "Priya Nair", "role": null, "organization": null, "status": "invited", "briefing_viewed_at": null }
  ],
  "modules": [{ "id": "38e3...", "title": "About Halden Logistics", "type": "about", "organization_id": "8719..." }]
}
```

`status` is `invited` until the person opens the briefing (`joined`); a guest's profile is shared only from then on.

### List meetings

`GET /api/v1/meetings` — newest first, paginated.

### Create a meeting

`POST /api/v1/meetings`

| Field | Type | |
| --- | --- | --- |
| title | string, required | Up to 200 characters |
| starts_at | ISO 8601 date-time with offset, required | |
| duration_minutes | integer 5–600 | Default 30, or the template's |
| purpose | string | Up to 5,000 characters, or the template's |
| organizer_email | string | A member who may organize; default: the key's creator |
| attendees | array of { email, name? } | Members of your organization join as colleagues, everyone else as guests |
| module_ids | array of ids | Your organization's content; default: the template's |
| template_id | id | Preselects content, duration and purpose |

Returns the meeting (`201`). Nobody is emailed until you send the briefing.

### Retrieve a meeting

`GET /api/v1/meetings/{id}` — includes attendees and who opened the briefing.

### Update a meeting

`PATCH /api/v1/meetings/{id}` with any of `title`, `starts_at`, `duration_minutes`, `purpose`.

### Cancel a meeting

`DELETE /api/v1/meetings/{id}` — the meeting stays, marked as cancelled; attendees see that on the briefing.

### Send the briefing

`POST /api/v1/meetings/{id}/send` — emails everyone not yet invited: guests get a personal, one-time link, colleagues a notice. The response lists `recipients`, `emailed`, and `links` for any guest who couldn't be emailed.

## Content, templates, members

| Endpoint | Returns |
| --- | --- |
| `GET /api/v1/modules` | Your organization's content: title, type, department, Markdown, freshness |
| `GET /api/v1/templates` | Briefing templates with their modules and defaults |
| `GET /api/v1/members` | Members with email, role, department and whether they may organize |

## Webhooks

Add an endpoint under **Organization → Developers → Webhooks** and choose events (none ticked means all).

| Event | When |
| --- | --- |
| meeting.created | A meeting was created (app or API) |
| meeting.updated | Title, time, duration or purpose changed |
| meeting.cancelled | The meeting was cancelled |
| briefing.sent | The briefing was sent |
| briefing.viewed | An attendee opened the briefing for the first time |
| attendee.joined | An invited attendee opened their invitation |
| access.requested | Someone asked for access via a forwarded link |
| question.asked | An attendee added a question for the meeting |
| content.review_requested | An organizer asked for a content review |

Each delivery is a `POST` with a JSON body `{ "type", "created_at", "data" }` and the [Standard Webhooks](https://www.standardwebhooks.com) headers `webhook-id`, `webhook-timestamp` and `webhook-signature`. Verify the signature with your endpoint's signing secret (`whsec_...`):

```ts
import { Webhook } from "standardwebhooks";

const wh = new Webhook(process.env.KENNU_WEBHOOK_SECRET);
const event = wh.verify(rawBody, {
  "webhook-id": req.headers["webhook-id"],
  "webhook-timestamp": req.headers["webhook-timestamp"],
  "webhook-signature": req.headers["webhook-signature"],
});
```

The signature is `v1,` + base64(HMAC-SHA256(secret, "`{webhook-id}.{webhook-timestamp}.{body}`")), with the secret base64-decoded after `whsec_`. Answer with any `2xx` within 10 seconds. Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours and 24 hours; every attempt is listed on the Webhooks page.

## MCP server

AI assistants that speak the Model Context Protocol (Claude, ChatGPT, Cursor and others) can use Kennu on your behalf through the MCP server at `https://dev.kennu.io/api/mcp` (Streamable HTTP). Authenticate with an API key:

```json
{
  "mcpServers": {
    "kennu": {
      "url": "https://dev.kennu.io/api/mcp",
      "headers": { "Authorization": "Bearer kn_live_..." }
    }
  }
}
```

| Tool | Does |
| --- | --- |
| list_meetings | Upcoming (or past) meetings with attendees and read status |
| get_briefing | A meeting's briefing: purpose, people, shared content |
| create_meeting_draft | Creates a meeting (nobody is emailed) |
| send_briefing | Sends a meeting's briefing |
| list_content | Your organization's content |

The assistant can do what the key can do — use a read-only key to let it read but not change anything.
