Introduction
The Loki Messages API turns a real US or EU phone number — or an
email alias — into a programmable iMessage conversation surface.
The base URL for every call is
https://api.lokimessages.com; all requests and
responses are JSON.
The mental model is small: chats contain
messages, messages are made of text and media
parts, and everything that happens inbound —
replies, reactions, read receipts, poll votes — reaches your
backend as a webhook.
quickstart — first message in one call
curl -X POST https://api.lokimessages.com/api/chats \
-H "Authorization: Bearer lok_live_…" \
-H "Content-Type: application/json" \
-d '{"to":["+15559876543"],"message":{"parts":[{"type":"text","value":"hi 👋"}]}}'
Authentication
Every request is authenticated with a workspace-scoped API key
in the Authorization header:
Bearer lok_live_…. Create and revoke keys in the
dashboard. Keys carry the scope of your workspace's lines — a
key can only send from numbers it owns.
Treat keys like passwords: server-side only, never in client
code, rotated if exposed.
Errors
Errors are JSON with a single error message and a
conventional status code: 400 for invalid input,
401 for a missing or bad key, 403 when
a key touches a line it doesn't own, 404 for
missing resources. Primitives the iMessage protocol doesn't
expose (editing sent messages, changing group membership) return
an honest 501 rather than pretending.
error shape
{ "error": "'to' is required" }
Webhooks
Point a webhook URL at your backend in the dashboard and every
inbound event arrives as structured JSON, typically in under a
second: new messages, reactions, read receipts, and poll votes.
Sender, chat, and attachments are already resolved — media
comes with a download URL, no second round-trip.
message.received
{
"event": "message.received",
"chat_id": "cht_a1b2c3d4e5f6",
"message": {
"id": "A1B2C3D4-E5F6-7890-ABCD-EF1234567890",
"direction": "inbound",
"sender_handle": { "handle": "+15559876543" },
"parts": [ { "type": "text", "value": "great, walking over now" } ]
}
}
Respond with a 2xx quickly and process async — slow webhook
handlers are the most common integration bug we see.
Unsupported operations
A handful of routes exist for API completeness but return an
honest 501 Not Implemented because the iMessage
protocol doesn't expose the underlying primitive. Don't build
against these — they will not start working with a plan change:
-
PUT /api/chats/:chatId — renaming a chat or
changing its icon
-
POST /api/chats/:chatId/participants and
DELETE /api/chats/:chatId/participants — adding or
removing group members after creation
-
POST /api/chats/:chatId/share_contact_card —
sharing a contact card
-
DELETE /api/messages/:messageId — unsending a
message
-
PATCH /api/messages/:messageId — editing a sent
message
-
POST /api/chats/:chatId/voicememo — sending a
voice memo
Chats
A chat is a conversation — direct or group — on one of your lines. Chats are created implicitly when someone texts you, or explicitly when you start one.
GET /api/chats
List chats on your workspace's lines, most recent first. Paginated with an opaque cursor.
Parameters
| cursor | query · optional | Cursor from a previous response's next_cursor. |
| limit | query · optional | Page size. Sensible default; capped server-side. |
Request
curl https://api.lokimessages.com/api/chats \
-H "Authorization: Bearer lok_live_…"
Response
{
"chats": [
{
"id": "cht_a1b2c3d4e5f6",
"display_name": null,
"service": "iMessage",
"is_group": false,
"handles": [
{ "id": "+15559876543", "handle": "+15559876543", "is_me": false }
]
}
],
"next_cursor": "MTA0Mg=="
} POST /api/chats
Create a chat and send its first message in one call. Pass one recipient for a direct chat or several for a group. The first message is required — that's what materializes the thread on the network.
Parameters
| from | string · optional | The line (your number) to send from. Defaults to your workspace's line when the API key is scoped to one. |
| to | string[] · required | Recipient handles — phone numbers or Apple ID emails. More than one creates a group. |
| message.parts | part[] · required | Message content. Text parts: { "type": "text", "value": "…" }. Media parts: { "type": "media", "attachment_id": "…" } or { "type": "media", "url": "…" }. |
| message.service | "iMessage" | "SMS" · optional | Transport for the chat. Defaults to iMessage. |
| auto_type | boolean · optional | Show a natural typing indicator before the message lands. |
Request
curl -X POST https://api.lokimessages.com/api/chats \
-H "Authorization: Bearer lok_live_…" \
-H "Content-Type: application/json" \
-d '{
"to": ["+15559876543"],
"message": { "parts": [ { "type": "text", "value": "Hello! How are you?" } ] }
}' Response
{
"chat": {
"id": "cht_a1b2c3d4e5f6",
"service": "iMessage",
"is_group": false,
"handles": [ { "id": "+15559876543", "handle": "+15559876543", "is_me": false } ],
"message": {
"id": "A1B2C3D4-E5F6-7890-ABCD-EF1234567890",
"service": "iMessage",
"parts": [ { "type": "text", "value": "Hello! How are you?" } ]
}
}
} GET /api/chats/:chatId
Fetch a single chat by ID, including its participant handles and service.
Messages
Messages are parts-based: a message is an ordered list of text and media parts. Everything you send and everything you receive uses the same shape.
POST /api/chats/:chatId/messages
Send a message into an existing chat. Combine text and media parts in one message; reference uploaded attachments by ID or pass a public URL.
Parameters
| message.parts | part[] · required | Ordered text and media parts. |
| message.reply_to | string · optional | Message ID to reply to — creates a native iMessage thread. |
| auto_type | boolean · optional | Show a typing indicator first, scaled to message length. |
Request
curl -X POST https://api.lokimessages.com/api/chats/$CHAT/messages \
-H "Authorization: Bearer lok_live_…" \
-H "Content-Type: application/json" \
-d '{
"message": {
"parts": [
{ "type": "text", "value": "Here is the menu 👇" },
{ "type": "media", "attachment_id": "att_8f2k…" }
]
}
}' Response
{
"message": {
"id": "A1B2C3D4-E5F6-7890-ABCD-EF1234567890",
"direction": "outbound",
"service": "iMessage",
"sent_at": "2026-06-11T18:30:00Z",
"delivered_at": null,
"read_at": null
}
} GET /api/chats/:chatId/messages
Page through a chat's history, newest first, with the same cursor pagination as chats. Each message carries direction, sender handle, parts, and delivery/read timestamps.
GET /api/messages/:messageId
Fetch one message by its GUID — useful for resolving webhook events into full objects.
GET /api/messages/:messageId/thread
Read a message's full reply thread as one resource: the root, every reply, in order.
Reactions
Tapbacks, programmable. React with the six classics or any emoji, and receive every reaction your users send as a webhook event.
POST /api/messages/:messageId/reactions
Add or remove a reaction on a message. React with one of the six classic tapbacks or with any emoji.
Parameters
| type | string · required* | One of the classic tapbacks: love, like, dislike, laugh, emphasis, question. Use "custom" together with custom_emoji for an emoji tapback. Either type or emoji is required. |
| emoji | string · required* | A single emoji for a custom tapback, e.g. "🔥". Shorthand for type "custom" + custom_emoji. Either type or emoji is required. |
| custom_emoji | string · optional | The emoji to apply when type is "custom". |
| remove | boolean · optional | Set true to take the reaction back instead of adding it. Equivalent to operation "remove". |
| operation | "add" | "remove" · optional | Whether to add or remove the reaction. Defaults to "add". |
Request
curl -X POST https://api.lokimessages.com/api/messages/$MSG/reactions \
-H "Authorization: Bearer lok_live_…" \
-H "Content-Type: application/json" \
-d '{ "emoji": "🔥" }' Response
{
"ok": true,
"message_id": "A1B2C3D4-E5F6-7890-ABCD-EF1234567890",
"reaction": "🔥",
"action": "add"
} Returns 202 Accepted — the reaction is dispatched to the network asynchronously.
Typing & read receipts
The small signals that make a thread feel alive. Show the dots while your system thinks; mark chats read so your number behaves like a person, not a void.
POST /api/chats/:chatId/typing
Show the typing indicator in a chat. It clears automatically when you send, or explicitly with the DELETE below.
DELETE /api/chats/:chatId/typing
Hide the typing indicator without sending.
POST /api/chats/:chatId/read
Mark the chat read — the sender sees their read receipt, exactly as if a person opened the thread.
GET /api/chats/:chatId/read-status
Per-participant read state for a chat.
GET /api/messages/:messageId/read-status
Delivery and read state for one message.
Polls
Native iMessage polls — the cleanest structured input a text thread can collect. Votes come back as webhook events as they happen.
POST /api/chats/:chatId/polls
Create a poll in a chat with 2–10 options.
Parameters
| title | string · required | The question. |
| options | string[] · required | 2 to 10 non-empty choices. |
Request
curl -X POST https://api.lokimessages.com/api/chats/$CHAT/polls \
-H "Authorization: Bearer lok_live_…" \
-H "Content-Type: application/json" \
-d '{ "title": "Run tomorrow?", "options": ["6am", "7am", "rest day"] }' Response
{ "ok": true, "poll": { "id": "pol_3c9d…", "title": "Run tomorrow?" } } Attachments
Upload once, send anywhere. Create an attachment, push its bytes, then reference it from any number of messages as a media part. Inbound media arrives in webhooks with a download URL.
POST /api/attachments/upload
Register an attachment (filename + content type) and receive its ID and an upload target.
Request
curl -X POST https://api.lokimessages.com/api/attachments/upload \
-H "Authorization: Bearer lok_live_…" \
-H "Content-Type: application/json" \
-d '{ "filename": "menu.pdf", "content_type": "application/pdf" }' Response
{ "attachment": { "id": "att_8f2k…", "filename": "menu.pdf" } } PUT /api/attachments/:id/content
Upload the raw bytes for an attachment you registered.
GET /api/attachments/:id
Fetch attachment metadata.
GET /api/attachments/:id/download
Download an attachment's bytes — including media your users sent you.
DELETE /api/attachments/:id
Delete an attachment you no longer need.
Locations
The primitive behind every "where are you?" flow: ask a handle for their location and read back what they share.
POST /api/locations/request
Ask a handle to share their location. They get the native share prompt; what they share becomes readable below.
Parameters
| handle | string · required | Phone number or Apple ID email to ask. |
| from | string · optional | The line to ask from, when your key spans several. |
Request
curl -X POST https://api.lokimessages.com/api/locations/request \
-H "Authorization: Bearer lok_live_…" \
-H "Content-Type: application/json" \
-d '{ "handle": "+15559876543" }' GET /api/locations
List the latest shared locations across your handles.
POST /api/locations/batch
Fetch the latest shared location for several handles in one call. Handles that have shared nothing are omitted from the result.
Parameters
| handles | string[] · required | The handles (phone numbers or Apple ID emails) to look up. |
| from | string · optional | The line to read from, when your key spans several. |
Request
curl -X POST https://api.lokimessages.com/api/locations/batch \
-H "Authorization: Bearer lok_live_…" \
-H "Content-Type: application/json" \
-d '{ "handles": ["+15559876543", "+15551234567"] }' GET /api/locations/:handle
The most recent location a specific handle has shared with you.