# Add chat to your app with one API.

*Web page: https://kweepa.com/developers/bonga · Updated 2026-11-01*

> Direct and group chat, files and live delivery for any web or mobile app, through one API.

Bonga is Kweepa's chat server. Your application keeps its own users and its own sign-in, and Bonga gives them direct and group conversations, files, receipts and presence, delivered live over a WebSocket and told to your server by webhook.

**Who it is for:** Marketplaces, delivery and booking apps, support desks and communities: any application whose users should talk to each other, or to its staff, without leaving it.

## What it does

- **Your users, your ids**: Bonga knows each user by the id your application already has, and creates them the first time your server names them: nothing to register, and no second list to keep.
- **Conversations**: Direct chats and groups with owners, admins and members, and conversations under your own ids, such as an order, that your server opens and closes.
- **Messages and files**: Replies, edits, deletions, reactions, mentions and search, and files of any kind, with previews of photos and links that expire.
- **Live delivery**: New messages, edits, reactions, typing, read and delivery receipts and who is online, over one WebSocket, whether Bonga runs on one server or several.
- **Webhooks**: New messages and changes to conversations and members posted to your server, signed and retried, with who was offline, muted or mentioned.
- **The sign-in you already have**: Your server asks Bonga for a token for each user, or Bonga accepts the access tokens of an OpenID Connect provider you trust, Kweepa Identity among them.

## What you get

- Chat in your app without building a chat server
- Your users and their messages kept separate and secure
- Push notifications sent only to those who were offline

## Connects to

- REST API
- WebSocket
- Signed webhooks
- OpenID Connect providers
- Kweepa Identity
- OCI Object Storage

## How Bonga fits into your application

Bonga is a chat server that any number of applications plug into. Your application keeps its own users, its own ids and its own way of signing people in. Bonga gives it conversations, messages and files over a REST API and a WebSocket, and tells your server what happens through webhooks.

### At a glance

- **Base URL**: `https://bonga.kweepa.com/api/v1`
- **WebSocket**: `wss://bonga.kweepa.com/api/v1/ws`
- **Authentication**: `Authorization: Bearer bga_…` for your server, a user token for your front end.
- **Format**: JSON in and out, camelCase, nulls left out, times in ISO-8601 UTC.
- **Webhooks**: Signed with HMAC-SHA256, tried again for up to 8 attempts.
- **Errors**: [RFC 9457](https://datatracker.ietf.org/doc/html/rfc9457) problem details.

1. **Your front end sends**: A message with a user token, over REST or the WebSocket.
2. **Bonga numbers it**: Every message gets a `seq`, 1, 2, 3 and so on, without gaps.
3. **Connected people receive it**: `message.created` goes to every connection of every member, whichever server they are on.
4. **Your server is told**: A signed webhook says who the message was for and whether each of them was connected.
5. **You notify who was offline**: Whoever had no connection is the one to send a push notification.

### The words the guide uses

- **App**: One application that uses Bonga. Everything belongs to exactly one app, and is invisible to every other.
- **User**: Known to Bonga by **the id your application already has for them**. You never store an id of Bonga's. Users are not registered: the first time your server mentions one, by asking for a token or adding them to a conversation, they exist.
- **API key**: `bga_…`, for your server. It may do everything within the app. With the header `X-Bonga-User: <user id>` it acts as that user.
- **User token**: For your front end, fetched by your server for one user. It may do what that user may: only the conversations they are in, by the rules of their role.
- **Webhook**: How Bonga tells your server what happens, which is how you send push notifications to people who are not connected.

## Get an app and an API key

### Step 1: Ask Kweepa for an app

Tell us what your application is called and a short key for it, such as `duka`. We make the app and send you its first API key. The key is shown once, and only its hash is kept, so keep it where it was put.

> **To rotate a key** Ask for a second key, deploy it, then have the first revoked. A key that is revoked, or reaches its `expiresAt`, stops working at once on every node, and the WebSocket connections opened with it are closed with `4401` within seconds.

### Step 2: Keep the key on your server

```bash
BONGA_API_URL=https://bonga.kweepa.com/api/v1
BONGA_API_KEY=bga_...
```

Every request carries `Authorization: Bearer <credential>`. Whatever goes wrong at the door, the caller learns no more than that it was not let in (`401`).

### Step 3: Settle your app's rules

An app's rules are set when it is made, and Kweepa changes them with you. With both of the first two switched off, only your server starts conversations: right for an application where who may talk to whom is its own business, such as a customer and their rider, or a buyer and a seller.

**Rules of an app**

| Rule | Default | Meaning |
|---|---|---|
| `allowClientDirect` | `true` | Users may start a direct conversation with any user of the app. |
| `allowClientGroups` | `true` | Users may create groups. |
| `allowUserDirectory` | `false` | Users may search and look up all users of the app, not only those they share a conversation with. |
| `maxGroupMembers` | `500` | The most members of a group. |
| `maxMessageLength` | `10000` | Characters in a message. |
| `maxFileBytes` | `26214400` | 25 MB. `0` switches uploads off. |
| `editWindowSeconds` | none | How long a sender may edit a message. |
| `status` | `ACTIVE` | `SUSPENDED` turns the whole app away at once: keys, tokens and open connections. |

## A first conversation, from a terminal

Two users, a conversation and a message, with nothing but your API key. Alice and Bob exist the moment your server names them.

```bash
BONGA_API_URL=https://bonga.kweepa.com/api/v1

# Your server: a token for alice (who is created by being named), and one for bob
ALICE_TOKEN=$(curl -s -X POST $BONGA_API_URL/tokens -H "Authorization: Bearer $BONGA_API_KEY" \
        -H 'Content-Type: application/json' \
        -d '{"userId":"alice","name":"Alice"}' | jq -r .token)
curl -s -X POST $BONGA_API_URL/tokens -H "Authorization: Bearer $BONGA_API_KEY" \
     -H 'Content-Type: application/json' \
     -d '{"userId":"bob","name":"Bob"}' > /dev/null

# Alice's front end: the conversation with bob (asked for again, it is the same one), and a message
CONVERSATION_ID=$(curl -s -X POST $BONGA_API_URL/conversations -H "Authorization: Bearer $ALICE_TOKEN" \
      -H 'Content-Type: application/json' \
      -d '{"type":"DIRECT","memberIds":["bob"]}' | jq -r .id)
curl -s -X POST $BONGA_API_URL/conversations/$CONVERSATION_ID/messages -H "Authorization: Bearer $ALICE_TOKEN" \
     -H 'Content-Type: application/json' -d '{"text":"Habari Bob!"}'

# Your server, acting as bob: his list, with the unread count and a preview
curl -s $BONGA_API_URL/conversations -H "Authorization: Bearer $BONGA_API_KEY" -H 'X-Bonga-User: bob'
```

## Integrate in three steps

### Step 1: Your server hands your front end a token

When a signed-in user opens a page that chats, your server asks for a token and passes it on.

```http
POST /api/v1/tokens
Authorization: Bearer bga_…

{ "userId": "42", "name": "Alice Wanjiku", "avatarUrl": "https://…/alice.png",
  "ttlSeconds": 3600 }
```

```json
{ "token": "eyJ…", "expiresAt": "2026-10-09T18:45:12Z",
  "user": { "id": "42", "name": "Alice Wanjiku", … } }
```

- A user id is whatever yours are: letters, digits and `. _ : @ | + = -`, up to 128 characters. A number, a UUID, an email address or `auth0|5f7c…` all do.
- The same call keeps the user's name and picture up to date: what is given replaces what is on record, and what is left out stays.
- A token lasts 24 hours unless `ttlSeconds` asks otherwise, and 7 days at the most.

### Step 2: Your front end talks to Bonga

The front end calls the REST API with the token, and opens a WebSocket for what happens while the page is open.

```js
const callBonga = (method, path, body) => fetch('https://bonga.kweepa.com/api/v1' + path, {
  method, headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' },
  body: body && JSON.stringify(body),
}).then((response) => response.json());

const conversations = await callBonga('GET', '/conversations');   // each with unread count and preview
const conversation = await callBonga('POST', '/conversations', { type: 'DIRECT', memberIds: ['43'] });
const messages = await callBonga('GET', `/conversations/${conversation.id}/messages`);   // the latest 50, earliest first

const webSocket = new WebSocket('wss://bonga.kweepa.com/api/v1/ws');
webSocket.onopen = () => webSocket.send(JSON.stringify({ type: 'auth', token }));
webSocket.onmessage = (event) => {
  const frame = JSON.parse(event.data);
  if (frame.type === 'message.created') show(frame.data.message);
};
webSocket.send(JSON.stringify({ type: 'message.send', ref: '1', conversationId: conversation.id,
                                text: 'Habari!', clientId: crypto.randomUUID() }));
```

### Step 3: Your server hears what happens

Register an endpoint and Bonga posts every event of the app to it. The one most applications want is `message.created`, which says who the message was for and whether each of them was connected: whoever was not is whom to send a push notification. See [webhooks](#webhooks).

### Conversations your application owns

Often a conversation belongs to something of yours: an order, a ride, a ticket. Give it that thing's id and never keep one of Bonga's.

```bash
# Makes sure of the conversation of order 10247, and of who is in it. Call it as often as you like.
curl -s -X PUT $BONGA_API_URL/conversations/external/order-10247 -H "Authorization: Bearer $BONGA_API_KEY" \
     -H 'Content-Type: application/json' -d '{
       "title": "Order 10247",
       "metadata": { "orderId": 10247 },
       "members": [ { "id": "cust-9", "name": "Achieng" }, { "id": "rider-7", "name": "Juma" } ] }'

# A note about the conversation, from the application itself
curl -s -X POST $BONGA_API_URL/conversations/external:order-10247/messages -H "Authorization: Bearer $BONGA_API_KEY" \
     -H 'Content-Type: application/json' \
     -d '{"text":"Your rider has arrived","systemEvent":"RIDER_ARRIVED","metadata":{"riderId":"rider-7"}}'

# When the order is done: nobody but your server writes in it any more; it can still be read
curl -s -X PATCH $BONGA_API_URL/conversations/external:order-10247 -H "Authorization: Bearer $BONGA_API_KEY" \
     -H 'Content-Type: application/json' -d '{"closed":true}'
```

Wherever the id of a conversation goes, in a path or in a WebSocket frame, `external:<your id>` goes as well. What comes back always carries the conversation's own `id` and its `externalId`. A message may also carry a `customType` (say `order_card`) and `metadata` for your front end to render in its own way. Such a message needs no text.

## Credentials and sign-in

Every request carries `Authorization: Bearer <credential>`. There are five kinds, and three of them are your application's.

**Credentials**

| Credential | Who holds it | What it is |
|---|---|---|
| **API key** (`bga_…`) | Your server | The app's own server: everything within the app. With `X-Bonga-User: <user id>` it acts as that user. Limited for the whole app, far above a person. |
| **User token** | Your front end | A JWT issued by `POST /api/v1/tokens`. The user it was issued for, until it expires (24 hours unless asked otherwise, 7 days at most) or the user is signed out everywhere (`DELETE /api/v1/users/{userId}/tokens`). |
| **Identity provider's token** | Your front end | An access token of an OpenID Connect provider your app trusts, with the header `X-Bonga-App: <app key>`. See below. |
| Platform key | Kweepa | Opens the platform API: making apps, their keys and their rules. Nothing inside an app. |
| Dashboard sign-in | A person | An access token of Kweepa Identity, which opens the dashboard's API and nothing else. |

### Users who already sign in somewhere

If your users have access tokens of an OpenID Connect provider, such as your own sign-in or [Kweepa Identity](https://kweepa.com/developers/kweepa-identity), Kweepa registers it for your app and no token of Bonga's is needed. This is what is registered:

```json
{ "issuerUri": "https://id.example.com", "audience": "duka-chat" }
```

The front end then calls with `Authorization: Bearer <access token>` and `X-Bonga-App: duka`.

- The token is checked for its signature (against the provider's published keys, asymmetric algorithms only), its issuer, its audience and its times.
- The `audience` is required: a provider issues tokens to other applications too, which are not to sign anybody in here.
- The user is created on first sight from the claims `sub`, `name` and `picture`. `userIdClaim`, `nameClaim` and `avatarClaim` name others, and `jwkSetUri` says where the keys are when the provider has no discovery document.
- The provider's keys are fetched again when a token names one that is not known, at most every 30 seconds, whatever tokens come in.

> **Whose users are whose** Without a `userIdPrefix`, the id in the token is the user's id here: right for a provider whose ids are the ones your server knows its users by, as when the provider is your own sign-in. A provider whose users are other people, or a second provider, needs a prefix of its own, such as `google:`. Its user `123` is then `google:123` here, and never the user `123` your server makes tokens for. A prefix is letters, digits and `._-`, with a colon at the end, and no two providers of an app share one.

## The REST API

Everything is under `/api/v1`. Fields a client does not know are to be ignored: more may be added. "A user" below means a user token, or an API key with `X-Bonga-User`.

### Users

**Users**

| Call | What it does | Who |
|---|---|---|
| `POST /tokens` | Issue a user token. Creates or updates the user. | Your server |
| `PUT /users/{userId}` | Create or update a user (`name`, `avatarUrl`, `metadata`, `active`). An `avatarUrl` that is no http or https address is left out. | Your server |
| `DELETE /users/{userId}` | Deactivate: they can neither connect nor send. What they wrote stays. | Your server |
| `DELETE /users/{userId}/tokens` | Sign out everywhere: every token issued for them until now is refused, and their connections are closed. A token issued afterwards works. | Your server |
| `GET /users/{userId}` | A user, with `online` and `lastSeenAt`. | Your server, or a user for those they may know of |
| `GET /users?query=&cursor=&limit=` | Search by name or id. | Your server, or users where the app keeps a directory |
| `GET /presence?userIds=a,b` | Who of some users is online. | Anybody, for those they may know of |
| `GET /me`, `GET /me/unread` | The calling user, and their unread messages and conversations in all. | A user |
| `GET /me/blocks`, `PUT` and `DELETE /me/blocks/{userId}` | Blocking: neither of the two writes in their direct conversation or sees whether the other is online, and nobody is added to a group by somebody they blocked. | A user |

### Conversations

**Conversations**

| Call | What it does |
|---|---|
| `POST /conversations` | `{"type":"DIRECT","memberIds":["bob"]}` is the one conversation of two users: `201` when made, `200` when it was there. `{"type":"GROUP","title":…,"memberIds":[…]}` makes a new group, whose creator owns it. |
| `PUT /conversations/external/{externalId}` | Your server: make sure of a group under an id of your own, and of its members. |
| `GET /conversations?cursor=&limit=&archived=` | A user's list: pinned first, then by latest activity, each with `me` (role, `unreadCount`, `lastReadSeq`, muted, pinned), `peer` (the other person of a direct conversation) and `lastMessage`. A server gets all conversations of the app, newest first. |
| `GET /conversations/{conversationId}`, `GET /conversations/external/{externalId}` | One conversation. |
| `PATCH /conversations/{conversationId}` | Title, description, `avatarUrl`, metadata, `joinPolicy` (`INVITE`, `OPEN`), `postPolicy` (`MEMBERS`, `ADMINS`), `closed`. |
| `DELETE /conversations/{conversationId}` | With everything in it, for good. |
| `GET /conversations/{conversationId}/members` | Each with `role`, `lastReadSeq`, `lastDeliveredSeq`, `online`. |
| `POST /conversations/{conversationId}/members` | Add (`userIds`). `shareHistory: false` hides what was said before. |
| `PATCH /conversations/{conversationId}/members/{userId}` | Change a role. `OWNER` hands the group over. |
| `DELETE /conversations/{conversationId}/members/{userId}` | Remove a member. |
| `POST /conversations/{conversationId}/join`, `/leave` | Join a group that is open to join. Leave a group. |
| `PUT /conversations/{conversationId}/settings` | For oneself: `muted`, `mutedUntil`, `pinned`, `archived`. |
| `POST /conversations/{conversationId}/read`, `/delivered` | Mark read, or received, up to `seq` (the latest when left out). |
| `POST /conversations/{conversationId}/clear` | Empty the conversation for oneself alone. |
| `POST /conversations/{conversationId}/typing` | Say that one is typing, for a client without a WebSocket. |

### Who may do what in a group

**Roles in a group**

| Action | Owner | Admin | Member |
|---|---|---|---|
| Write, react, read | Yes | Yes | Yes, but not where `postPolicy` is `ADMINS` |
| Rename, describe, close, set policies, add members | Yes | Yes | No |
| Remove a member | Yes | Plain members | No |
| Delete anybody's message | Yes | Yes | No |
| Change roles, hand over, delete the group | Yes | No | No |

Your server may do all of it in every conversation of its app. A user who is not in a conversation is told `404` about it, whether it exists or not. When an owner leaves, the group goes to the admin, or else the member, of longest standing.

### Messages

**Messages**

| Call | What it does |
|---|---|
| `POST /conversations/{conversationId}/messages` | Send: `text`, `attachments` (ids of uploaded files), `replyTo`, `mentions`, `metadata`, `customType`, `clientId`. A server may add `systemEvent`. |
| `GET /conversations/{conversationId}/messages?before=&after=&around=&limit=` | A stretch, earliest first, with `hasMoreBefore` and `hasMoreAfter`. Nothing given: the latest. |
| `GET /conversations/{conversationId}/messages/search?query=` | Messages whose text contains the phrase, latest first. |
| `GET`, `PATCH`, `DELETE /conversations/{conversationId}/messages/{messageId}` | Read, edit (`text`, `metadata`), delete for everybody. |
| `PUT`, `DELETE /conversations/{conversationId}/messages/{messageId}/reactions/{emoji}` | React, take back. Answers the message's reactions as they now stand. |

### Four things worth knowing

- `seq`: Numbers the messages of a conversation 1, 2, 3 and so on, without gaps. Sort by it. A client that has seen 41 and receives 43 has missed one and fetches `?after=41`. A member has read every message with a `seq` no higher than their `lastReadSeq`, so "read by" and "unread" are comparisons.
- `clientId`: Makes sending safe to repeat. Give every message an id of the client's own, such as a UUID. Sent again with the same one by the same sender, the message is stored once and the one already there is answered.
- `customType`: One that begins with `app.` is your server's: only an API key sends a message of such a type, or edits one, even one sent in a user's name. A client can trust a card of that type to be the app's own. Other types are anybody's.
- **A deleted message**: Keeps its place and its `seq`, with `deleted: true` and nothing else: no text, no files, no reactions.

The notes the server writes itself are messages of `type: "SYSTEM"` with a `systemEvent` (`MEMBER_ADDED`, `MEMBER_JOINED`, `MEMBER_REMOVED`, `MEMBER_LEFT`, `CONVERSATION_RENAMED`, `CONVERSATION_CLOSED`, `CONVERSATION_REOPENED`) and, in `metadata`, the `actor` and the `users` it is about. They have no text: each client says it in its own words and language.

Lists answer `{ "items": […], "nextCursor": "…", "hasMore": true }`. Pass `nextCursor` back as `cursor` for the next page. A cursor is opaque. Messages are paged by `seq` instead.

## Live delivery over the WebSocket

`wss://bonga.kweepa.com/api/v1/ws`. Every frame is a JSON object with a `type`.

### Signing in

- A first frame `{"type":"auth","token":"…"}`, with `"app":"<app key>"` added for an identity provider's token. This is the way for a browser, since an address ends up in logs.
- The `Authorization` header of the handshake, with `X-Bonga-App` or `X-Bonga-User`, for a client that can set one.
- `?token=…` in the address, with `&app=`, for a user's token only. An API key there is refused and the connection closed (`4401`), since every proxy on the way writes the address into its log. Revoke a key that went there.

The server answers `{"type":"connected","data":{"user":{…},"sessionId":"…","serverTime":"…","heartbeatSeconds":25,"expiresAt":"…"}}`. A connection that has not signed in within 10 seconds is closed. Shortly before a token runs out the server sends `{"type":"auth.expiring","data":{"expiresAt":"…"}}`: send an `auth` frame with a new token for the same user and the connection stays.

### What a client sends

A frame may carry a `ref` of the client's choosing. The answer to it carries the same one.

**Frames a client sends**

| Frame | Answer |
|---|---|
| `{"type":"message.send","ref":"1","conversationId":"…","text":"…","clientId":"…"}`, and every other field of `POST …/messages` | `{"type":"ack","ref":"1","data":{"message":{…}}}` |
| `{"type":"read","conversationId":"…","seq":42}` | `ack` with `lastReadSeq`, `lastDeliveredSeq`, `unreadCount`. |
| `{"type":"delivered","conversationId":"…","seq":42}` | The same. |
| `{"type":"typing.start","conversationId":"…"}`, `typing.stop` | None. Passed on at most every two seconds. |
| `{"type":"presence.subscribe","userIds":["bob"]}` | `presence.state`, then `presence.changed` as they come and go. Up to 500 users. Those the caller may not know of are left out, and so are those with whom a block stands either way. |
| `{"type":"presence.unsubscribe","userIds":["bob"]}` | `ack`. |
| `{"type":"ping"}` | `{"type":"pong"}`. |

What cannot be done is answered `{"type":"error","ref":"1","error":{"status":403,"type":"forbidden","detail":"…"}}`, with the same statuses and types as the REST API, and the connection stays open.

### What a client is told

`{"type":"message.created","id":"…","createdAt":"…","data":{…}}`, to every connection of every user it concerns, whichever node they are connected to.

**Events**

| Event | Data | To |
|---|---|---|
| `message.created`, `message.updated`, `message.deleted` | `message` | Members, the sender's other devices included. `updated` and `deleted` only those who can see the message. |
| `reaction.added`, `reaction.removed` | `conversationId`, `messageId`, `seq`, `emoji`, `userId`, `reactions` (as they now stand) | Members who can see the message. |
| `conversation.created`, `conversation.updated` | `conversation` (a new one with its `members`, when they are few) | Members. |
| `conversation.deleted` | `conversationId`, `externalId` | Members. |
| `member.added` | `conversationId`, `actorId`, `members` | Members, the new ones included: finding yourself there means a conversation you do not know yet. |
| `member.removed` | `conversationId`, `userId`, `actorId`, `reason` (`left`, `removed`) | Members, and whoever was removed. |
| `member.updated` | `conversationId`, `member` | Members. |
| `conversation.read` | `conversationId`, `userId`, `lastReadSeq`, `readAt` | Members. In a group of over 100, the reader's own devices only. |
| `conversation.delivered` | `conversationId`, `userId`, `lastDeliveredSeq` | Members, not in a group of over 100. |
| `conversation.settings`, `conversation.cleared` | The user's own settings, and `visibleFromSeq` | The user's own devices. |
| `typing.started`, `typing.stopped` | `conversationId`, `userId` | The other members. |
| `presence.changed` | `userId`, `online`, `lastSeenAt` | Connections that subscribed. |

Events are sent once and kept nowhere. A conversation in an event has no `me`. A message that answers one the reader cannot see, said before they came in with `shareHistory: false` or before they cleared the conversation, comes to them without `replyTo`.

### Staying in step

A WebSocket is for what happens while it is open. After connecting, and again after every reconnect:

### Step 1: Fetch the list

`GET /conversations`, for the list with its unread counts.

### Step 2: Catch up on the conversation on screen

`GET …/messages?after=<the last seq you have>`, until `hasMoreAfter` is false.

Reconnect with a growing, randomised pause. The server sends protocol pings every 25 seconds. A client that hears nothing for twice that long should reconnect.

### Why a connection is closed

**Close codes**

| Code | Meaning | What to do |
|---|---|---|
| `4401` | Not signed in, or the credentials ran out: a token expired, the user was signed out everywhere, an API key was revoked or reached its `expiresAt`, or was put in the address. | Get a new token, reconnect. |
| `4403` | The user was deactivated or the app suspended. | Stop. |
| `4408` | No credentials within the time allowed. | Send `auth` sooner. |
| `4409` | The user opened more than 10 connections. The oldest goes. | Nothing. |
| `4413` | The client fell 512 frames behind. | Reconnect and catch up. |
| `1001`, `1011` | The server is going away, or failed. | Reconnect. |

## Webhooks

Register an endpoint and Bonga posts the events of your app to it.

```bash
curl -s -X POST $BONGA_API_URL/webhooks -H "Authorization: Bearer $BONGA_API_KEY" -H 'Content-Type: application/json' \
     -d '{"url":"https://api.example.com/hooks/bonga","eventTypes":["message.created"]}'
```

- The answer holds the endpoint's `secret` (`whsec_…`), shown then and when you ask for a new one (`PATCH /webhooks/{webhookId}` with `{"rotateSecret":true}`).
- Leave `eventTypes` out for every event. An app has up to 10 endpoints.
- `GET /webhooks/{webhookId}/deliveries` shows how the latest deliveries went. `POST /webhooks/{webhookId}/test` sends a `webhook.test` event, five at once and ten a minute.
- Endpoints are `https` and public. Redirects are not followed.

### What a delivery looks like

```http
POST /hooks/bonga
Content-Type: application/json
X-Bonga-Event: message.created
X-Bonga-Event-Id: 261009174512A1B2C3D4E5F6
X-Bonga-Delivery: 261009174512F6E5D4C3B2A1
X-Bonga-Signature: t=1791564312,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

{ "id": "261009174512A1B2C3D4E5F6", "type": "message.created", "app": "duka",
  "createdAt": "2026-10-09T17:45:12.345Z", "data": { … } }
```

`data` is what a client gets for the same event, in the table above. The events for clients alone, which are receipts, typing, presence and a user's own settings, are not sent. `message.created` carries more:

```json
{ "message": { "id": "…", "seq": 7, "text": "@carol tukutane kesho", "sender": { "id": "alice", … }, … },
  "conversation": { "id": "…", "type": "GROUP", "title": "Chama", "externalId": null, … },
  "recipients": [
    { "userId": "bob",   "online": true,  "muted": false, "mentioned": false },
    { "userId": "carol", "online": false, "muted": true,  "mentioned": true } ] }
```

`recipients` is everybody in the conversation but the sender. Somebody with `online: false` had no connection when the message was sent: that is whom a push notification is for.

### Check the signature

`v1` is the HMAC-SHA256, in hex, of `t`, a dot, and the body exactly as it arrived, under the endpoint's secret. Work it out over the raw bytes, before any JSON parsing, and turn away what is more than a few minutes old.

**Node**

```js
// Express: app.post('/hooks/bonga', express.raw({ type: 'application/json' }), handler)
const crypto = require('crypto');

function verify(rawBody, header, secret) {
  const { t: timestamp, v1: signature } = Object.fromEntries(header.split(',').map((part) => part.split('=')));
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = crypto.createHmac('sha256', secret).update(timestamp + '.').update(rawBody).digest('hex');
  return signature.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
```

**Java**

```java
static boolean verify(byte[] rawBody, String header, String secret)
        throws GeneralSecurityException {
    String timestamp = header.substring(2, header.indexOf(','));
    String signature = header.substring(header.indexOf("v1=") + 3);
    if (Math.abs(Instant.now().getEpochSecond() - Long.parseLong(timestamp)) > 300)
        return false;
    Mac hmacSha256 = Mac.getInstance("HmacSHA256");
    hmacSha256.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
    hmacSha256.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
    String expected = HexFormat.of().formatHex(hmacSha256.doFinal(rawBody));
    return MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8),
            signature.getBytes(StandardCharsets.UTF_8));
}
```

### What to count on

- Answer `2xx` within 10 seconds. Anything else is tried again with a pause that doubles, from 30 seconds up to an hour, eight attempts in all. Then it is given up and kept for a month to look at (`GET /webhooks/{webhookId}/deliveries`, whose `lastError` says what kind of failure it was). A delivery not made within a day is given up however few times it was tried.
- **An endpoint that keeps failing is paused.** After five failed attempts in a row nothing is posted to it for a minute, twice as long after each failure that follows, half an hour at most, and the first attempt that arrives ends it. `GET /webhooks` shows `pausedUntil` meanwhile.
- A delivery may arrive **more than once**, since your answer can be lost. Every attempt of an event carries the same `id`: drop what you have seen.
- Deliveries are **not ordered**. What they report carries its own order: a message its `seq`.
- An event is queued in the transaction of the change it reports: your server never hears of something that was rolled back and never misses something that was not.

## Files

Sending a file takes two steps: upload it, then send a message with its id.

```bash
FILE_ID=$(curl -s -X POST $BONGA_API_URL/files -H "Authorization: Bearer $ALICE_TOKEN" -F file=@photo.jpg | jq -r .id)
curl -s -X POST $BONGA_API_URL/conversations/$CONVERSATION_ID/messages -H "Authorization: Bearer $ALICE_TOKEN" \
     -H 'Content-Type: application/json' -d "{\"text\":\"Look\",\"attachments\":[\"$FILE_ID\"]}"
```

```json
{ "id": "…", "name": "photo.jpg", "contentType": "image/jpeg", "kind": "IMAGE", "size": 482113,
  "width": 3024, "height": 4032,
  "url": "/api/v1/files/…/content?expires=1791568800&signature=…",
  "thumbnailUrl": "/api/v1/files/…/thumbnail?expires=1791568800&signature=…",
  "urlExpiresAt": "2026-10-09T19:00:00Z" }
```

- **Any file is taken**, up to the app's `maxFileBytes`, ten to a message.
- **What a file is, is read from its bytes**, not from its name or the type the upload claimed. `kind` is `IMAGE`, `VIDEO` or `AUDIO` for what was recognised as something a browser plays, and `FILE` for everything else. Only the first three are shown in place. The rest, an HTML page or an SVG among them, is a download.
- **Images** (JPEG, PNG, GIF) are measured and given a JPEG preview of 320 pixels, turned the way the photo was taken. WebP is measured.
- **`url` and `thumbnailUrl` need no credentials**, so they work in an `<img>` or a `<video>`. They are signed and good for an hour or a little more. Every time a message is read it comes with fresh ones, and `GET /files/{fileId}` gives fresh ones for one file. Ranges are served.
- **Who may read a file**: its uploader, anybody who can read a message it is attached to, and your server. A user can attach only a file they uploaded or can already read, which is what forwarding is.
- A file that no message carries is cleared away a day after it was uploaded.
- A user with a token of their own uploads up to 1 GB in 24 hours. The upload that goes past it is still taken, and the next is answered `429` until enough of the day has gone by. Your server is not held to it.
- `507` means there is no room for files just now, and `503`, with `Retry-After`, that the store cannot be reached. Try again later.

## Errors and limits

Every error is an [RFC 9457](https://datatracker.ietf.org/doc/html/rfc9457) problem. Match on `type`, show `detail`, and quote `trace_id` when asking why: it finds the request in the server's log, and is your own `X-Request-Id` when you sent one.

```json
{ "type": "/errors/not-found", "title": "Not Found", "status": 404,
  "detail": "Conversation not found: 261009174512A1B2C3D4E5F6", "trace_id": "9f2c41d07a6b3e58" }
```

**Problem types**

| Type | Status | What it means |
|---|---|---|
| `bad-request` | 400 | The request makes no sense as it is. `detail` says what. |
| `validation` | 400 | With `errors: [{ "field": "members[0].id", "message": "must not be blank" }]`. |
| `unauthorized` | 401 | No credentials, or not accepted. |
| `forbidden` | 403 | Signed in, and not allowed this. |
| `not-found` | 404 | Not there, or not for the caller to know of: the two are answered alike. |
| `conflict` | 409 | Does not fit what is on record, such as leaving a direct conversation. |
| `unprocessable` | 422 | Well formed and cannot be carried out, such as naming a user who does not exist. |
| `too-large` | 413 | An upload over the limit, or a body larger than the server reads (4 MB of JSON). |
| `rate-limited` | 429 | With `Retry-After` and `retry_after_ms`. |
| `internal-error` | 500 | The server's fault. Nothing of its inside is in the answer. |
| `unavailable` | 503 | Something the server needs cannot be reached just now. Try again after `Retry-After`. |
| `insufficient-storage` | 507 | No room for files on the server just now. Try the upload again later. |

### Rate limits

- **A user with a token**: Held per user: 30 messages at once and 120 a minute (sending, editing, reacting), 10 uploads at once and 30 a minute and 1 GB of them in 24 hours, 20 other changes at once and 60 a minute.
- **Your server with its API key**: Held for the whole app, far above a person, acting as a user or not: 600 messages at once and 6,000 a minute, 120 uploads at once and 1,200 a minute, 600 other changes at once and 6,000 a minute (the tokens it asks for included), and five test deliveries of a webhook at once and ten a minute.

> **If your app reaches them** They are there so that no app can take the server for itself, however it came by its key. An app that reaches them needs them raised: tell us.

## Questions people ask

### Do our users need a Bonga account?

No. Bonga knows them by the ids your application already uses: your server asks for a token for a user, and that user exists from then on. Nobody signs up twice.

### Does Bonga send push notifications?

Your server does, with what Bonga tells it. Every new message comes by webhook with its recipients, and whether each was connected, muted or mentioned; whoever was not connected is the one to notify.

### Can a conversation belong to an order or a booking?

Yes. Your server opens it under your own id, such as order-10247, with the customer and the rider in it, posts notes into it, and closes it when the order is done. It can still be read afterwards.

### Is Bonga an SMS or WhatsApp service?

No. Bonga carries chat inside your own web and mobile apps, between your users and over the internet; it does not send SMS or WhatsApp messages.

## Next step

Request a demo: https://kweepa.com/contact?service=bonga · Email: ops@kweepa.com
Provider: Kweepa Technologies, Nakuru, Kenya (https://kweepa.com/)
