Developer guide

Add chat to your app with 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.

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

  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.

  2. Keep the key on your server

    Environment

    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).

  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.

curl

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

  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.

    Request

    POST /api/v1/tokens
    Authorization: Bearer bga_…
    
    { "userId": "42", "name": "Alice Wanjiku", "avatarUrl": "https://…/alice.png",
      "ttlSeconds": 3600 }
    

    Answer

    { "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.
  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.

    JavaScript

    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() }));
    

  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.

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.

curl

# 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, Kweepa registers it for your app and no token of Bonga's is needed. This is what is registered:

Issuer

{ "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:

  1. Fetch the list

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

  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.

curl

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

Delivery

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:

message.created

{ "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

// 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

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.

curl

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\"]}"

A file

{ "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 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.

Error

{ "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.

Common questions

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.

Ready to add chat?

Tell us what your application is and who needs to talk to whom. We will make your app and send its first API key.

Ask for an app