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.
REST
https://api.lauki.chat/v1Realtime
wss://gw.lauki.chat/v1Media
https://cdn.lauki.chatJSON everywhere · IDs are ULIDs (time-sortable) · timestamps are Unix milliseconds · one key per account · the key acts as you.
Get your key
- Open lauki.chat, tap your avatar → Settings.
- Scroll to API access → Create API key.
- 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).
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.
| limit | on exceed | |
|---|---|---|
| REST | 120 req/min and 5,000 req/day (fixed windows from the first request) | 429 { "error": "rate_limited", "retry_after": <sec> } + Retry-After |
| WS sockets | 2 concurrent per key | upgrade rejected 429 too_many_sockets |
WS msg.send | 30/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
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 } }
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
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 }
} ] }
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
Group metadata — title, description, photo_url. Admins only. Group errors: title_required, unknown_member, group_full, agent_not_addable.
Your per-chat settings: { "pinned"?: bool, "muted"?: bool } → { "chat_id", "pinned_at", "muted" }.
Messages
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
} ] }
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.
{ "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.
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.
{ "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..." }
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
Exact username → { "user": { … } } or 404 user_not_found.
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.
{ "phones": ["+919…", "+1…"] } (E.164) → { "users": [ { "user_id", "phone", "username", "name", "photo_url" } ] } — only registered numbers come back.
Blocked people can’t message you and you can’t message them. GET /users/blocked → { "users" }.
{ "target_user"?: "01M...", "target_msg"?: "01M...", "reason": "…" } (one target required) → { "ok": true }.
Groups & invites
{ "members": [ { "user_id", "username", "name", "photo_url", "last_seen", "role", "joined_at" } ] }
{ "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.
{ "role": "admin" | "member" }
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.
Preview a link, then join → { "ok", "chat", "joined" }. Joining is always an explicit call — never automatic.
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.
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
| t | d | what |
|---|---|---|
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
| t | d |
|---|---|
msg.new | Full 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.updated | Full 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. |
pong | Heartbeat 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
| status | error | meaning |
|---|---|---|
| 400 | chat_id_and_text_required · invalid_username · bad_emoji · peer_user_id_required · cannot_dm_self · title_required | Bad input — check the body. |
| 401 | invalid_api_key | Unknown, revoked, malformed key — or the account was deleted. |
| 401 | unauthorized | No or invalid bearer. |
| 403 | not_a_member · blocked · admin_only | You can’t do that in this chat. |
| 403 | api_key_forbidden | That route isn’t available to API keys — see below. |
| 404 | chat_not_found · peer_not_found · user_not_found · media_not_found · not_found | No such thing. |
| 409 | username_taken | Pick another. |
| 413 | too_large | Media over the cap. |
| 429 | rate_limited | Slow 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:
- Log in or request OTP codes —
/auth/* - View, regenerate or revoke the API key itself —
/me/api-key*(Settings, or a session token, only) - Delete the account —
DELETE /account - Push subscriptions and device registration —
/push/*,/devices - Lauki’s own agent surface and client diagnostics —
/agent/*,/diag
Security
What actually protects your account and messages today — no more, no less.
- Transport. Everything travels over TLS —
https://api.lauki.chatandwss://gw.lauki.chat. Plain HTTP is never served. - Login. Phone number + one-time code. Codes expire in 5 minutes, 5 wrong tries burn the code, and a phone can request at most 5 codes an hour. Lauki never stores your code — the verification provider generates and checks it.
- Sessions. A signed token per device, valid 30 days. Log out revokes that device's session on the server immediately — the token stops working on the next request; deleting the account kills every session everywhere.
- API keys. One per account, only its SHA-256 is stored, 120 requests/min and 5,000/day. Regenerate or revoke instantly in Settings → API access; a key can never log in, touch the key itself, or delete the account (what a key can’t do).
- Media. Files live on
cdn.lauki.chatunder unguessable IDs. Links are not authenticated — anyone holding the exact URL can open it, so treat a media link like the file itself. - Where messages live. On our servers (sealed — in a device-ready chat only your devices hold the key; otherwise the server holds it and every open is logged) and in your browser’s local database (IndexedDB). Details: Privacy & Security. Don’t paste secrets into chats you don’t control.
- Deleting. Delete for everyone hides the message from everyone in the chat, on every device, immediately — the app never shows it again. The row itself is retained on our servers, and a media file's link keeps working for anyone who already has it. Delete account is OTP-confirmed: your name, phone, username and photo are removed, every session, device, push subscription and block is purged and your number is freed. Messages you already sent stay in those chats as “Deleted account” — delete them first if you want them gone.
- Block & report. Block from any profile — blocked people can’t message you. Report a message or user from its menu (
POST /v1/moderation/report); reports reach a human.
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.