Lauki / API Open the app
v1 · personal API keys

Build your own Lauki client.

Your account, your interface. One personal API key gives a program the same access you have in the app — read your chats, send and receive messages in realtime, upload media, manage groups.

Contents
  1. Get your key
  2. Authentication
  3. Rate limits
  4. REST reference
  5. Realtime
  6. Full example
  7. Errors
  8. Limits of a key
  9. Security
  10. Changelog
Base URLs
REST https://api.lauki.chat/v1
Realtime wss://gw.lauki.chat/v1
Media https://cdn.lauki.chat
Ground rules
JSON everywhere · IDs are ULIDs (time-sortable) · timestamps are Unix milliseconds · one key per account · the key acts as you.

Get your key

  1. Open lauki.chat, tap your avatar → Settings.
  2. Scroll to API access → Create API key.
  3. Copy it. The full key is shown once. Afterwards Settings only shows the prefix (lk_live_a1B2…), when it was created and when it was last used (updated at most once a minute).
Treat it like your password. The key has full access to your account. Lost or leaked? Regenerate in Settings — the old key dies the same second. Revoke removes it entirely. Only one key exists at a time.

Authentication

Send the key as a bearer token on every REST call. It is accepted anywhere the app’s own session token is.

curl https://api.lauki.chat/v1/chats \
  -H "Authorization: Bearer lk_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"

Keys are 40 characters: lk_live_ + 32 base62. Only a SHA-256 of the key is stored — lose it and you regenerate. An unknown or revoked key gets 401 { "error": "invalid_api_key" }. For the realtime socket pass it as the key query parameter (see Realtime). How keys and sessions are protected: Security.

Rate limits

Per key — the app’s own sessions are unaffected.

limiton exceed
REST120 req/min and 5,000 req/day (fixed windows from the first request)429 { "error": "rate_limited", "retry_after": <sec> } + Retry-After
WS sockets2 concurrent per keyupgrade rejected 429 too_many_sockets
WS msg.send30/min per key{ "t": "error", "id", "d": { "code": "rate_limited" } } — other frames unaffected
# every REST response carries the budget
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
X-RateLimit-Limit-Day: 5000
X-RateLimit-Remaining-Day: 4871

# over the limit → 429 + when to try again
HTTP/1.1 429 Too Many Requests
Retry-After: 23
{ "error": "rate_limited", "retry_after": 23 }

Back off for retry_after seconds. Need more? Use the socket for reads — every new message is pushed to you, so polling is never necessary.

REST reference

Base https://api.lauki.chat/v1. Requests with a body are application/json. Errors are { "error": "<code>" } with a matching HTTP status — see Errors.

You

GET/users/me

Who am I.

{ "user": { "user_id": "01M...", "phone": "+91…", "username": "sowmay", "name": "Sowmay",
            "photo_url": "https://cdn.lauki.chat/01M...", "created_at": 1756900000000, "last_seen": 1756990000000 } }
PATCH/users/me

Update name, username (^[a-z0-9_]{3,32}$), photo_url (a URL from Media). Send only what changes → { "user" }. Errors: invalid_username, name_required, nothing_to_update, 409 username_taken.

Chats

GET/chats

Every chat you’re in, newest activity first, with the last message, your unread count and — for DMs — the peer.

{ "chats": [ {
    "chat_id": "01M...", "kind": "dm" | "group", "title": null, "photo_url": null, "description": null,
    "created_by": "01M...", "created_at": 1756990000000,
    "role": "member" | "admin", "pinned_at": null, "muted": 0, "unread": 2,
    "peer": { "user_id": "01M...", "username": "lauki", "name": "Lauki", "photo_url": "…", "last_seen": 1756990000000 },
    "last_message": { message object, see below }
} ] }
POST/chats

Open a DM (get-or-create) or make a group.

{ "kind": "dm", "peer_user_id": "01M..." }
{ "kind": "group", "title": "Weekend plan", "member_ids": ["01M...", "01M..."] }
→ 200 { "chat": { … }, "created": true | false }   // an existing DM comes back with created:false
PATCH/chats/:chat_id

Group metadata — title, description, photo_url. Admins only. Group errors: title_required, unknown_member, group_full, agent_not_addable.

POST/chats/:chat_id/settings

Your per-chat settings: { "pinned"?: bool, "muted"?: bool } → { "chat_id", "pinned_at", "muted" }.

Messages

GET/chats/:chat_id/messages?limit=50&before=<msg_id>

Newest first. limit 1–200 (default 50). Page backwards by passing the oldest msg_id you have as before. Deleted messages never appear; a reply to a deleted message has reply_to: null.

{ "messages": [ {
    "msg_id": "01M...", "chat_id": "01M...", "sender_id": "01M...",
    "kind": "text" | "image" | "video" | "file" | "voice" | "system",
    "text": "hey", "reply_to": null, "edited_at": null, "created_at": 1756990000000,
    "media": null | { "id": "01M...", "url": "https://cdn.lauki.chat/01M...", "mime": "image/jpeg", "size": 182003,
                      "name": null, "w": 1080, "h": 2400, "duration": null, "thumb": null, "album": null },
    "reactions": [ { "user_id": "01M...", "emoji": "❤️" } ]   // via GET only
} ] }
POST/messages

Send a text message. Pass your own ULID as client_id to make retries idempotent (a repeat returns 200 { "id", "dedup": true }).

{ "chat_id": "01M...", "text": "on my way", "reply_to": "01M...", "client_id": "01M..." }
→ 201 { "id": "01M..." }

Text supports **bold** and `code`. Max 8,192 characters. To send media, edit, delete, mark read or type, use the socket — those are realtime frames.

POST/messages/:msg_id/react

{ "emoji": "❤️" } — toggle: same emoji again removes it, a different one replaces it (one reaction per person per message). Allowed: ❤️ 👍 😂 😮 😢 🙏 🔥. Returns { "ok": true, "emoji": "❤️" | null }; everyone in the chat gets a msg.reacted frame.

GET/messages/search?q=<text>&chat_id=&before=&limit=

Full-text search across your chats (or one chat). Newest first; limit up to 50. Pass next back as before to page.

{ "q": "invoice", "next": "01M..." | null,
  "results": [ { "msg_id": "01M...", "chat_id": "01M...", "sender_id": "01M...", "kind": "text", "text": "…",
                 "snippet": "…the ⁨invoice⁩ is paid…", "created_at": 1756990000000 } ] }

Search is limited to 60 requests/min.

Media

Three steps: register → upload bytes → send a message that references it. In a device-ready chat the app seals media on the device first (enc_v: 4 on register; the PUT body is ciphertext and the key rides inside the sealed message) — the server stores and serves those bytes unchanged. Programs using an API key see the same signed links; opening device-sealed media needs the chat's key, which lives on your devices, not on the server. The one exception is Lauki: in a chat that includes him, your devices wrap the chat key to his key as well, and that key is held by our server so he can answer; every open is logged under his name.

POST/media/upload-url
{ "mime": "image/jpeg", "size": 182003, "name": "photo.jpg" }   // size, name optional
→ 200 { "media_id": "01M...", "method": "PUT",
        "upload_url": "https://api.lauki.chat/v1/media/01M...", "url": "https://cdn.lauki.chat/01M..." }
PUT/media/:media_id

Raw bytes as the body, Content-Type = the file’s mime, Content-Length required. Same bearer key. Caps: image 25 MB · video 50 MB · audio 10 MB · other 100 MB (413 too_large).

curl -X PUT "$UPLOAD_URL" -H "Authorization: Bearer $KEY" \
  -H "Content-Type: image/jpeg" --data-binary @photo.jpg
→ 200 { "media_id": "01M...", "url": "https://cdn.lauki.chat/01M..." }

Then send it over the socket:

{ "t": "msg.send", "id": "<your ulid>",
  "d": { "chat_id": "01M...", "kind": "image" | "video" | "file" | "voice", "media_id": "01M...",
         "text": "caption", "reply_to": null,
         "meta": { "w": 1080, "h": 2400, "duration": null, "thumb_id": null, "album": null } } }

You get an ack, then msg.new fans out to everyone with media: { id, url, mime, size, name, w?, h?, duration?, thumb?, album? } — url is on cdn.lauki.chat, public and immutable. Put w/h in meta for images and video so clients can reserve the right box; album = one ULID shared by 2+ media messages to group them.

People

GET/users/lookup?username=sowmay

Exact username → { "user": { … } } or 404 user_not_found.

GET/users/search?q=sow

Username prefix, or an exact E.164 phone → { "users": [ { "user_id", "username", "name", "photo_url", "last_seen" } ], "q": "name" | "phone" }. Up to 8; excludes you, blocked and deleted accounts; 60/min.

POST/contacts/match

{ "phones": ["+919…", "+1…"] } (E.164) → { "users": [ { "user_id", "phone", "username", "name", "photo_url" } ] } — only registered numbers come back.

GET/users/blocked
POST/users/:user_id/block · DELETE to unblock

Blocked people can’t message you and you can’t message them. GET /users/blocked → { "users" }.

POST/moderation/report

{ "target_user"?: "01M...", "target_msg"?: "01M...", "reason": "…" } (one target required) → { "ok": true }.

Groups & invites

GET/chats/:chat_id/members

{ "members": [ { "user_id", "username", "name", "photo_url", "last_seen", "role", "joined_at" } ] }

POST/chats/:chat_id/members

{ "user_ids": ["01M..."] } → { "ok", "added" } — add people (admins; ≤500 members). DELETE /chats/:chat_id/members/:user_id removes one (admin), or yourself to leave. Lauki can’t be added (agent_not_addable) — just @lauki him in a message.

POST/chats/:chat_id/members/:user_id/role

{ "role": "admin" | "member" }

GET/chats/:chat_id/invite · POST to create

One active invite link per group → { "invite": { "token", "url", "uses", "created_at" } | null } (any member). POST (admin) returns the existing link or mints one → { "invite", "created" }. POST /chats/:chat_id/invite/revoke → { "invite" } — kills the old link and hands back a fresh one.

GET/invite/:token · POST /invite/:token/join

Preview a link, then join → { "ok", "chat", "joined" }. Joining is always an explicit call — never automatic.

GET/link-preview?url=

Open-graph preview for a URL → { "title", "description", "image", "site" } or {}.

Realtime (WebSocket)

Everything that happens in your account is pushed to you over one socket, in order, with a per-user sequence number you can resume from.

WSSwss://gw.lauki.chat/v1?key=lk_live_…[&after_seq=<n>]

Same frames as the app’s own device socket; your device id is apikey:<prefix>. Send {"t":"ping"} every 25 s; the server answers pong. Persist the highest contiguous seq and reconnect with after_seq (or send sync.replay) to receive everything you missed, in order.

Envelope

// server → you
{ "t": "msg.new", "seq": 1042, "ts": 1756990000123, "d": { …payload } }

// you → server (id = your ULID, makes the frame idempotent)
{ "t": "msg.send", "id": "01M...", "d": { …payload } }

// the reply to anything you send
{ "t": "ack",   "id": "01M...", "d": { "msg_id": "01M...", "seq": 1043 } }
{ "t": "error", "id": "01M...", "d": { "code": "not_a_member", "message": "…" } }

Frames you send

tdwhat
msg.send{chat_id, kind, text?, media_id?, reply_to?, meta?: {w, h, duration, thumb_id, album}}Send. kind = text · image · video · file · voice. media_id required for non-text.
msg.edit{chat_id, msg_id, text}Edit your own message.
msg.delete{chat_id, msg_id, for_everyone}true: your message, gone for everyone. false: any message, hidden for you only.
msg.read{chat_id, up_to_msg_id}Mark read up to a message.
typing{chat_id, on}Typing indicator; auto-expires.
focus{focused, chat_id?}A focused key socket on a chat suppresses push for that chat. Send nothing if you want phones to keep buzzing.
sync.replay{after_seq}Ask for everything after a sequence number.
ping{}Heartbeat.

Frames you receive

td
msg.newFull message object (same shape as REST).
msg.edited{chat_id, msg_id, text, edited_at}
msg.deleted{chat_id, msg_id} — gone for everyone.
msg.hidden{chat_id, msg_id} — you hid it on another client.
receipt.read{chat_id, user_id, up_to_msg_id}
typing{chat_id, user_id, on}
presence{user_id, online, last_seen}
msg.reacted{chat_id, msg_id, user_id, emoji | null}
chat.updatedFull chat object — created, renamed, members or settings changed.
sync.reset{from_seq} — your cursor is too old to replay; refetch via REST, then continue from the current seq.
pongHeartbeat reply.

Error frame codes: bad_json · bad_id · bad_kind · empty_message · media_required · not_a_member · blocked · rate_limited · internal.

Full example

Node 22+ (built-in fetch and WebSocket; on Node 20 add npm i ws). Says hello to Lauki, then answers “ping” with “pong” in every chat you’re in.

const KEY = process.env.LAUKI_KEY;                       // lk_live_…
const H = { Authorization: `Bearer ${KEY}` };

const { chats } = await fetch('https://api.lauki.chat/v1/chats', { headers: H }).then(r => r.json());
const lauki = chats.find(c => c.peer?.username === 'lauki');   // everyone has a Lauki DM

await fetch('https://api.lauki.chat/v1/messages', {
  method: 'POST', headers: { ...H, 'content-type': 'application/json' },
  body: JSON.stringify({ chat_id: lauki.chat_id, text: 'hello from my own client' }),
});

const ws = new WebSocket(`wss://gw.lauki.chat/v1?key=${KEY}`);
ws.onmessage = async (e) => {
  const f = JSON.parse(e.data);
  if (f.t !== 'msg.new' || f.d.kind !== 'text') return;
  if (f.d.text.toLowerCase() === 'ping') {
    await fetch('https://api.lauki.chat/v1/messages', {
      method: 'POST', headers: { ...H, 'content-type': 'application/json' },
      body: JSON.stringify({ chat_id: f.d.chat_id, text: 'pong', reply_to: f.d.msg_id }),
    });
  }
};
setInterval(() => ws.readyState === 1 && ws.send('{"t":"ping"}'), 25_000);

Errors

statuserrormeaning
400chat_id_and_text_required · invalid_username · bad_emoji · peer_user_id_required · cannot_dm_self · title_requiredBad input — check the body.
401invalid_api_keyUnknown, revoked, malformed key — or the account was deleted.
401unauthorizedNo or invalid bearer.
403not_a_member · blocked · admin_onlyYou can’t do that in this chat.
403api_key_forbiddenThat route isn’t available to API keys — see below.
404chat_not_found · peer_not_found · user_not_found · media_not_found · not_foundNo such thing.
409username_takenPick another.
413too_largeMedia over the cap.
429rate_limitedSlow down; honour Retry-After.

What a key can’t do

A key is you, minus the things that would let a leaked key take the account away from you. These return 403 api_key_forbidden:

Nothing else is gated. Same permissions as you in the app. Messages you send with a key look exactly like messages you send by hand.

Security

What actually protects your account and messages today — no more, no less.

Found something? Message @lauki in the app or email hi@lauki.ai. We fix security reports first.

Changelog

Sep 6 2026 — Sealed notification previews. In a device-locked chat the sender's client seals the notification preview per recipient device (GET /v1/chats/:id/push-keys, previews on msg.send); the server relays it and never sees the text. Adversarial release test published (e2e-p5-adversarial.py).

Sep 6 2026 — Device-locked chats. In a chat where every member device is paired, messages are sealed on the sender's device and older history is re-sealed by a member device; the API returns those rows as opaque e2e:3:… envelopes it cannot open. GET /v1/chats/:id/e2e reports rows_total/rows_v3.

Sep 4 2026 — Personal API keys. One key per account, create/regenerate/revoke in Settings → API access. REST + realtime access with per-key rate limits.