- A conversation is
- A chat — a person or a group
- A send is
- Immediate (201)
- Good to know
- Reach back through the most recent ~1,000 messages of a conversation. Sending a file through the API is not supported yet (422 unsupported_operation).
API reference
One REST API for every channel you've connected — WhatsApp, Telegram, LinkedIn and Gmail today, more to come. The channel is a field on an account, not a different set of endpoints, so code you write for one works for the rest.
- Base URL
https://messengeragent.dev/api/v1- Machine-readable
- openapi.json — for client generators, Postman and AI agents.
Quickstart
- Create an API key on the API page. It's shown once — copy it somewhere safe.
- Connect at least one account (WhatsApp, LinkedIn or Gmail) in the dashboard.
- Call the API:
export API_KEY="amk_..." # from the API page
# 1. Which accounts do I have?
curl https://messengeragent.dev/api/v1/accounts \
-H "Authorization: Bearer $API_KEY"
# 2. Send a message (the Idempotency-Key makes a retry safe)
curl -X POST https://messengeragent.dev/api/v1/accounts/ACCOUNT_ID/messages \
-H "Authorization: Bearer $API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"to": "+15551230099", "text": "Hello from the API"}'Authentication
Send your key in the Authorization header (X-API-Key: amk_… works too). Keys start with amk_. We store only a fingerprint, so a lost key can't be recovered — revoke it and create another.
- Read — list accounts, conversations, messages and contacts.
- Send — send messages. Each one spends a credit, so withhold it from a reporting job.
- Embed — make links that show an account's chat in an iframe on your site. The link can read and send, so give the permission only to the key your server uses for that.
- Accounts — create accounts, sign them in and out, and delete them. Enterprise plan only, and kept apart from Send so a key that can message people cannot also add or remove accounts.
A key can only do what you can, on your own accounts. Everything a key sends appears in your activity log, named by key. Keep it on a server — never in a browser or a mobile app.
Channels
The same calls work on every channel. These are the differences that matter — each account also reports them under capabilities, so code can branch on a fact rather than guess.
telegram
- A conversation is
- A chat — a person, a group or a channel
- A send is
- Immediate (201), or queued (202) when Telegram asks for a wait
- Good to know
- Only on accounts where Telegram is enabled. Accounts can be created and signed in through the API, by QR code or by phone number and login code, then the two-step verification password if the account has one (Enterprise plan). Conversation ids are Telegram chat ids and message ids are unique per chat, so they look like chatId:messageId. A forum supergroup (isForum: true) has no messages of its own: list its topics, and read and send in a topic by its id like any conversation. To start a conversation, "to" can be a chat id, a @username, or a phone number with a leading "+". Text is up to 4,096 characters. A message can carry one file (PDF, PNG or JPEG, up to 10 MB), and then its text is a caption of up to 1,024 characters; the file alone is a message.
- A conversation is
- A message thread
- A send is
- Queued (202) — delivered at a pace LinkedIn tolerates
- Good to know
- Reads are real page loads and are paced: they can answer 429 with Retry-After. The newest ~200 messages are reachable. Text can have line breaks (see Line breaks under Send a message). A send can carry one file (PDF, PNG or JPEG, up to 10 MB) into a conversation that has started. Credits are spent when the message is actually sent. Accounts can be created and signed in through the API (Enterprise plan).
gmail
- A conversation is
- A Gmail thread
- A send is
- Immediate (201)
- Good to know
- Messages carry a subject, who they are from and to, and whether they are inbound or outbound; a new email needs a subject. Search the whole mailbox by sender, recipient, subject, direction and date. Reading never marks mail as read. The newest 50 messages of a thread are reachable. Connect, re-check, replace the app password of and delete mailboxes through the API (Enterprise plan). Sending a file through the API is not supported yet (422 unsupported_operation).
Conversation ids are opaque: store them as returned and send them back unchanged.
GET List accounts
/accounts · needs read
Every account you've connected, on every channel. status is connected, needs_attention or disconnected whatever the channel calls it. GET /accounts/{id} returns one.
{
"accounts": [
{
"id": "01a115a8-…",
"channel": "whatsapp",
"name": "Test WhatsApp",
"identifier": "+15559990000",
"status": "connected",
"canSend": true,
"capabilities": {
"contacts": true,
"startConversation": true,
"requiresSubject": false,
"asyncSend": false,
"maxMessagesPerRead": 100,
"maxTextLength": 10000,
"attachments": false,
"maxAttachmentBytes": 0,
"searchMessages": false,
"updateCredentials": false
},
"createdAt": "2026-10-07T09:19:08+00:00"
}
]
}GET List conversations
/accounts/{id}/conversations?limit=50 · needs read
Most recent activity first, up to limit (1–100). There's no paging for conversations. On WhatsApp one person is always one conversation, even if they have two identities.
{
"conversations": [
{
"id": "15551230001@c.us",
"name": "Ada Lovelace",
"isGroup": false,
"lastMessage": { "text": "message number 30", "at": "2026-10-07T09:18:53+00:00", "fromMe": false },
"unreadCount": 2
}
]
}GET List messages
/accounts/{id}/conversations/{conversationId}/messages?limit=30&before= · needs read
Newest first. type is one of text, image, video, audio, document, sticker, contact, poll, location, email, other. status is the channel's own delivery state in lower case (sent, delivered, read, failed; on LinkedIn also queued, sending; on Gmail unread). Attachments are listed, not downloaded, in v1.
{
"messages": [
{
"id": "M30",
"conversationId": "15551230001@c.us",
"direction": "inbound",
"fromMe": false,
"type": "text",
"text": "message number 30",
"subject": null,
"sender": { "name": "Ada Lovelace" },
"status": "delivered",
"at": "2026-10-07T09:18:53+00:00",
"attachments": []
}
],
"hasMore": true,
"nextBefore": "M21"
}GET Search messages
/accounts/{id}/messages?from=&to=&subject=&direction=&since=&until=&limit=&before= · needs read · email
Messages from the whole mailbox, not one thread, newest first. Every filter is optional and they combine. An account's capabilities.searchMessages says whether its channel can; today that is email.
from— the sender: an address or part of one (ada@,@example.com). Inbound mail from that person.to— a recipient on the To or Cc line. Mail sent to that person (a Bcc is not visible to the people it reached).direction—inbound(the account received it) oroutbound(the account sent it). Leave it out for both. Anything else is a 400, not a silently ignored filter.subject— text in the subject.since/until— ISO 8601, by whole day in UTC:sinceincludes its day,untildoes not.limit— 1 to 50 (25 by default). Page back withbefore, thenextBeforeof the last page.
Every message says which way it went: direction is inbound or outbound and fromMe says the same as a boolean — in a search and in a thread alike. Email messages also carry sender.address, to and cc, and each hit's conversationId is its thread, so you can read the whole exchange. Searching never marks anything read, and costs no credit.
# Everything that sender wrote to you, newest first
curl "https://messengeragent.dev/api/v1/accounts/ACCOUNT_ID/messages?from=ada@example.com&direction=inbound&limit=25" \
-H "Authorization: Bearer $API_KEY"
# Everything you sent to a recipient since the 1st (the To or Cc line)
curl "https://messengeragent.dev/api/v1/accounts/ACCOUNT_ID/messages?to=grace@example.com&direction=outbound&since=2026-10-01" \
-H "Authorization: Bearer $API_KEY"
# The next page: pass the previous page's nextBefore
curl "https://messengeragent.dev/api/v1/accounts/ACCOUNT_ID/messages?from=ada@example.com&before=48213" -H "Authorization: Bearer $API_KEY"{
"messages": [
{
"id": "48230",
"conversationId": "1847203948572", // the thread: list it with /conversations/{id}/messages
"direction": "inbound", // or "outbound"
"fromMe": false,
"type": "email",
"subject": "Invoice 114",
"text": "Hi — the invoice is attached.",
"sender": { "name": "Ada Lovelace", "address": "ada@example.com" },
"to": [{ "address": "you@gmail.com" }],
"cc": [{ "address": "grace@example.com", "name": "Grace" }],
"status": "unread",
"at": "2026-10-09T08:14:00+00:00",
"attachments": [{ "fileName": "invoice.pdf", "contentType": "application/pdf", "size": 48211 }]
}
],
"hasMore": true,
"nextBefore": "48230"
}POST Send a message
/accounts/{id}/conversations/{conversationId}/messages · needs send
Body: { "text": "…" }. On Gmail you may send "html" instead; it's cleaned of scripts and styles before it goes out, and a plain-text version is made from it. The Idempotency-Key header is required.
curl -X POST https://messengeragent.dev/api/v1/accounts/ACCOUNT_ID/conversations/CONVERSATION_ID/messages \
-H "Authorization: Bearer $API_KEY" \
-H "Idempotency-Key: order-1001-shipped" \
-H "Content-Type: application/json" \
-d '{"text": "Your order shipped"}'201 means it was sent:
HTTP/1.1 201 Created
{ "id": "WAMID.…", "conversationId": "15551230001@c.us", "status": "sent" }202 means it was accepted and will be sent — LinkedIn paces its messages. Read the conversation to follow it: the message appears with the status queued, then sending, then sent — or failed, which is also how running out of credits shows up on LinkedIn.
HTTP/1.1 202 Accepted
{ "id": "q-01a115b1…", "conversationId": "Mi1hYmMvZGVmKz09", "status": "queued" }Line breaks (LinkedIn)
A LinkedIn message may contain line breaks, in text (JSON "\n", or a plain newline in a multipart field). Each one is typed as a new paragraph in LinkedIn's composer, never as Enter, so a message is never sent early. Before anything is queued the text is normalised:
\r\nand\rbecome\n; three or more line breaks in a row become two; line breaks around the whole message are dropped.- So the same message with other line endings is the same message: under one
Idempotency-Keyit replays the first answer rather than being refused as a different request. The stored text, and what a conversation shows, is the normalised one. - At most 20 line breaks (counted after collapsing) — more is
400 too_many_line_breaks— and at most 8000 characters (capabilities.maxTextLength). - No other control characters: a tab, NUL or DEL is
400 invalid_request. - A message is judged before the
Idempotency-Keyis used, so a refused one leaves the key free for the corrected message. - Gmail and WhatsApp take their text as it always did; this is about LinkedIn.
# Line breaks: write "\n" in JSON. Three paragraphs, sent as three paragraphs.
curl -X POST https://messengeragent.dev/api/v1/accounts/ACCOUNT_ID/conversations/CONVERSATION_ID/messages \
-H "Authorization: Bearer $API_KEY" \
-H "Idempotency-Key: intro-ada-1" \
-H "Content-Type: application/json" \
-d '{"text": "Hi Ada,\n\nI enjoyed your talk on queues.\n\nCould we speak next week?"}' On Gmail the returned id is the message's RFC 5322 Message-ID; messages listed in a thread carry Gmail's own ids. They name the same email; they are not interchangeable.
POST Start a conversation
/accounts/{id}/messages · needs send
Message someone you haven't talked to yet. to is a phone number on WhatsApp, a profile username or URL on LinkedIn, and one or more comma-separated email addresses on Gmail (subject is required there, and only there). The response is the same as a send, and its conversationId is the id to read it by.
# WhatsApp: a phone number LinkedIn: a username or profile URL Gmail: addresses + a subject
curl -X POST https://messengeragent.dev/api/v1/accounts/ACCOUNT_ID/messages \
-H "Authorization: Bearer $API_KEY" \
-H "Idempotency-Key: welcome-42" \
-H "Content-Type: application/json" \
-d '{"to": "ada@example.com", "subject": "Hello", "text": "Nice to meet you."}'POST Send a file
/accounts/{id}/messages and /accounts/{id}/conversations/{conversationId}/messages · needs send
Both send routes also accept multipart/form-data: the same fields as the JSON body (to, subject, text, html) plus one file in a part named file. A JSON request works exactly as before. The answer is the usual one: 202 queued (LinkedIn) with the same { id, conversationId, status }, and the file goes out with the message through the same queue.
# Same fields as the JSON body, plus one file part named "file". The Idempotency-Key header works as it always does.
curl -X POST https://messengeragent.dev/api/v1/accounts/ACCOUNT_ID/conversations/CONVERSATION_ID/messages \
-H "Authorization: Bearer $API_KEY" \
-H "Idempotency-Key: cv-ada-1" \
-F "text=Here is my CV" \
-F "file=@cv.pdf;type=application/pdf"
# Starting a conversation takes to / subject / text / html and the same file part:
curl -X POST https://messengeragent.dev/api/v1/accounts/ACCOUNT_ID/messages \
-H "Authorization: Bearer $API_KEY" \
-H "Idempotency-Key: cv-ada-2" \
-F "to=ada-lovelace" \
-F "text=Hello Ada" \
-F "file=@cv.pdf;type=application/pdf"- Which channels. LinkedIn, Telegram and Gmail (on Telegram the file goes at once, and its text is a caption of up to 1,024 characters; a file alone is a message; on Gmail the file is the email's attachment, on a new email and on a reply alike, and the email still needs its text or HTML). WhatsApp answers
422 unsupported_operationwhen a file is attached. Branch on the account'scapabilities.attachments(a boolean) andcapabilities.maxAttachmentBytes(an integer; 0 where files are not supported) rather than on the channel's name. - Types and size.
application/pdf,image/pngandimage/jpeg, up to 10 MB (10,485,760 bytes). Any other type is422 attachment_type_not_allowed; a larger file is413 attachment_too_large. The file's own bytes are checked as well as the declared type — a PDF must begin with%PDF-— and a mismatch is alsoattachment_type_not_allowed. - If the file can't be stored. The answer is
503 attachment_storage_unavailablewith aRetry-After— "The file could not be stored. Nothing was queued." — a different code fromchannel_unavailable(linkedin-api unreachable), so you can tell them apart. The key stays free, so retry with the sameIdempotency-Key. While file storage isn't set up,capabilities.attachmentsisfalse(andmaxAttachmentBytes0), so you can see it before sending; sending a file anyway gets this same code. - Shape. More than one file, a file in a part with another name, a file with no file name, or an empty file is
400 invalid_request. A multipart body with no file is a plain send. On LinkedIn the text may be left out when a file is attached. - LinkedIn takes a file only into a conversation that exists. To someone you have never messaged, the first message can carry text and nothing else, so a file sent to them is refused at once with
422 attachment_needs_conversation(not accepted and failed later). Send the text first, then the file into the returned conversation once that message has gone. - Retries. With an
Idempotency-Key, the same key and the same file (same name, size and content) replays the first result withIdempotent-Replayed: trueand sends nothing. The same key with a different file, or with no file, is422 idempotency_key_reused. Only the file's name, size and a SHA-256 of its content are kept to tell requests apart, never the file. - Credits. A message with a file costs the same as one without: one credit, spent when it is actually sent.
- Privacy. The activity log records that a file was attached, its size and its type. Not its name, not its contents. The file name is treated as personal data and is not logged.
GET List contacts
/accounts/{id}/contacts?search=&limit=50&offset=0 · needs read
WhatsApp and LinkedIn. conversationId opens the conversation with that person. On Gmail this answers 422 unsupported_operation.
{
"total": 124,
"offset": 0,
"limit": 50,
"contacts": [
{ "id": "01a1…", "name": "Ada Lovelace", "identifier": "+15551230001", "conversationId": "15551230001@c.us", "updatedAt": "2026-10-01T10:00:00+00:00" }
]
}POST Create an account
/accounts · needs accounts · Enterprise plan
Creates an account. channel is required — linkedin (created, then signed in), telegram (created, then signed in by QR code or phone number) and gmail (connected with its app password) can be set up this way; an account's capabilities.createAndAuthenticate says whether its channel can. The Idempotency-Key header is required, so a retry can't create two. On any other plan this is a 403 plan_required.
curl -X POST https://messengeragent.dev/api/v1/accounts \
-H "Authorization: Bearer $API_KEY" \
-H "Idempotency-Key: onboard-acme-sales" \
-H "Content-Type: application/json" \
-d '{"channel": "linkedin", "name": "Acme sales"}'HTTP/1.1 201 Created
{
"id": "01a115b1-…",
"channel": "linkedin",
"name": "Acme sales",
"identifier": null,
"status": "disconnected",
"canSend": false,
"capabilities": { "createAndAuthenticate": true, "asyncSend": true, … },
"createdAt": "2026-10-07T09:20:11+00:00"
}POST Connect an email account
/accounts · "channel": "gmail" · needs accounts · Enterprise plan
An email account is a Gmail mailbox connected with a Google app password (Google Account → Security → 2-Step Verification → App passwords). Send email and appPassword (spaces are ignored) and optionally name. There is no sign-in step: the password is checked with Google, stored encrypted, and the account answers 201 already connected. The password is never returned, logged or written to the activity log.
- Refused password:
422 invalid_credentials, nothing is stored and theIdempotency-Keystays free. Google unreachable:503 channel_unavailable. - Already connected (by you):
409 account_already_exists. A bad or missing address or password:400 invalid_request. - Replace the password —
PUT /accounts/{id}/credentialswith{ "appPassword": "…" }. Checked with Google first; a refusal changes nothing. It also brings back an account that went toneeds_attentionbecause Google revoked the old one.capabilities.updateCredentialssays which channels have a credential to replace. - Re-check —
POST /accounts/{id}/checkasks Google whether it still accepts the password and answers the account:connected, orneeds_attention(that is a state, not an error). Safe to repeat. - Delete —
DELETE /accounts/{id},204. The stored password is erased. There is no session at Google to end, so the app password itself stays valid until its owner revokes it at Google. Sign-in and sign-out do not apply to email: they answer422 unsupported_operation. - Sending email is the ordinary send: reply into a thread, or start a new email with
to,subjectandtextorhtml. One credit each.
# Connect a mailbox: Google's app password for it (16 letters). It is checked with Google first.
curl -X POST https://messengeragent.dev/api/v1/accounts \
-H "Authorization: Bearer $API_KEY" \
-H "Idempotency-Key: connect-support-inbox" \
-H "Content-Type: application/json" \
-d '{"channel": "gmail", "name": "Support", "email": "support@yourcompany.com", "appPassword": "abcd efgh ijkl mnop"}'
# → 201 the usual account: "channel": "gmail", "identifier": "support@yourcompany.com", "status": "connected"
# → 422 invalid_credentials if Google refuses the password (nothing is stored), 409 account_already_exists
# The password was revoked, or you are rotating it
curl -X PUT https://messengeragent.dev/api/v1/accounts/ACCOUNT_ID/credentials \
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{"appPassword": "wxyz wxyz wxyz wxyz"}'
# Does Google still accept it? Answers the account: "connected", or "needs_attention".
curl -X POST https://messengeragent.dev/api/v1/accounts/ACCOUNT_ID/check -H "Authorization: Bearer $API_KEY"
# Remove it. 204. The stored password is erased.
curl -X DELETE https://messengeragent.dev/api/v1/accounts/ACCOUNT_ID -H "Authorization: Bearer $API_KEY"POST Adopt a signed-in account
/accounts · with remoteAccountId · needs accounts · Enterprise plan · LinkedIn
If the person has already signed in to LinkedIn somewhere else, don't make them do it twice. Add remoteAccountId — the id of their account at linkedin-api — to Create an account. Instead of creating a new account that has to be signed in, it takes over the one that exists: there is no sign-in and no credentials are involved. The answer is the usual account (201), already connected if their session is live. An account's capabilities.adoptExisting says whether its channel supports this.
- Same linkedin-api tenant. The account must be visible to the linkedin-api key this service uses — the one your own linkedin-api calls use too. An id from any other tenant is indistinguishable from an id that doesn't exist: a tenant mismatch shows up as
404 remote_account_not_found. - One account, one owner. An id that is already linked here — to you or to any other customer — is
409 remote_account_already_linked, so no one can adopt an account that isn't theirs. An account you delete stays reserved: adopt it again only under a new id. - Status. What linkedin-api says becomes the account's status:
logged_in→connected,session_expired→needs_attention,logged_out→disconnected. When a session ends the account becomesneeds_attention, and it returns toconnectedby itself once the person signs in again at linkedin-api — you call nothing here. - Webhook. Adopting registers this service's webhook on the account at linkedin-api, as for any account. That replaces any webhook your tenant had registered on that account — don't register your own on accounts you hand over.
- Sign-out and delete work as for any account. Delete also deletes the account at linkedin-api, so an adopted account is gone there too; if it's already gone, the delete still succeeds.
- Errors. A blank, overlong (over 64 characters) or malformed id — only letters, digits,
-and_— is422 invalid_request. If linkedin-api can't be reached it is503 channel_unavailable: retry with the sameIdempotency-Key. A retry that already succeeded replays the first answer and never makes a second account.
# The person already signed in at linkedin-api, which gave you their account id. Attach it — no sign-in here.
curl -X POST https://messengeragent.dev/api/v1/accounts \
-H "Authorization: Bearer $API_KEY" \
-H "Idempotency-Key: adopt-ada-1" \
-H "Content-Type: application/json" \
-d '{"channel": "linkedin", "name": "Ada", "remoteAccountId": "7d2f5c1e9a"}'
# → 201, the usual account: "status": "connected", "canSend": true, "capabilities": { "adoptExisting": true, … }POST Sign in an account
/accounts/{id}/authentication · needs accounts · Enterprise plan
Signs the account in with the account owner's own login — for LinkedIn, their email and password. The answer is an authentication: an attempt that may pause for a code or an approval before it ends. It expires, and a new attempt is a new one.
in_progress— a browser is signing in. Poll.awaiting_code— LinkedIn sent a verification code. Submit it.awaiting_approval— waiting for the owner to approve on their phone. Keep polling.succeeded— the account is signed in; itsstatusis nowconnected.failed— it didn't work.reasonandmessagesay why (a wrong password, a CAPTCHA that needs the owner to sign in on their own device first, a timeout).
# 1. Start. This is slow — a real browser signs in, up to about a minute. Allow ~90 s.
curl -X POST https://messengeragent.dev/api/v1/accounts/ACCOUNT_ID/authentication \
-H "Authorization: Bearer $API_KEY" \
-H "Idempotency-Key: signin-acme-1" \
-H "Content-Type: application/json" \
-d '{"email": "owner@acme.com", "password": "••••••••"}'
# → { "id": "9f3c…", "status": "awaiting_code", "message": "LinkedIn sent a verification code. Submit it to continue." }
# 2. If it is awaiting_code, answer it.
curl -X POST https://messengeragent.dev/api/v1/accounts/ACCOUNT_ID/authentication/9f3c…/code \
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{"code": "123456"}'
# → 202 { "id": "9f3c…", "status": "in_progress" }
# 3. Poll until it settles.
curl https://messengeragent.dev/api/v1/accounts/ACCOUNT_ID/authentication/9f3c… -H "Authorization: Bearer $API_KEY"
# → { "id": "9f3c…", "status": "succeeded" }The password is forwarded and forgotten: it is never stored, logged, written to the activity log, or used to match a retry. Starting a sign-in needs an Idempotency-Key; if your request times out, retry with the same key and you get the same authentication back rather than a second sign-in. A sign-in that failed frees its key, so start again with it. Each step is in your activity log.
POST Sign in a Telegram account
/accounts/{id}/authentication · needs accounts · Enterprise plan
The same authentication, with Telegram's own steps. Create the account first ("channel": "telegram"), then start a sign-in either way: "method": "qr" returns a code to scan in Telegram, and "method": "phone" with phoneNumber has Telegram send a login code. Either may then ask for the account's two-step verification password. Starting again abandons the sign-in under way, so it is also the way back from a number you got wrong.
awaiting_scan—qrCodeis adata:image/pngaddress to show. It is replaced every half minute or so; keep polling and show the newest.awaiting_code— Telegram sent a login code to the account.POST …/authentication/{id}/code.awaiting_password— the account has two-step verification.POST …/authentication/{id}/passwordwith{"password": "…"}.succeeded/failed— as above. A wrong code, password or number is a422 invalid_credentialswith Telegram's own words, and the sign-in stays on the same step; a flood wait is a429withRetry-After.
# 1a. By QR code. The answer carries the code to scan, as a data: address you can put in an <img>.
curl -X POST https://messengeragent.dev/api/v1/accounts/ACCOUNT_ID/authentication \
-H "Authorization: Bearer $API_KEY" -H "Idempotency-Key: tg-signin-1" -H "Content-Type: application/json" \
-d '{"method": "qr"}'
# → { "id": "…", "status": "awaiting_scan", "qrCode": "data:image/png;base64,…", "message": "Scan the QR code in Telegram: Settings → Devices → Link Desktop Device. …" }
# 1b. Or by phone number: Telegram sends a login code to the account.
curl -X POST https://messengeragent.dev/api/v1/accounts/ACCOUNT_ID/authentication \
-H "Authorization: Bearer $API_KEY" -H "Idempotency-Key: tg-signin-2" -H "Content-Type: application/json" \
-d '{"method": "phone", "phoneNumber": "+1 555 123 4567"}'
# → { "id": "…", "status": "awaiting_code" }
# 2. Answer the code, and — if the account has two-step verification — its password.
curl -X POST https://messengeragent.dev/api/v1/accounts/ACCOUNT_ID/authentication/AUTH_ID/code -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" -d '{"code": "12345"}'
# → { "status": "awaiting_password" } (or "succeeded")
curl -X POST https://messengeragent.dev/api/v1/accounts/ACCOUNT_ID/authentication/AUTH_ID/password -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" -d '{"password": "••••••••"}'
# 3. Poll until it settles. While awaiting_scan the qrCode is replaced every half minute or so: show the newest.
curl https://messengeragent.dev/api/v1/accounts/ACCOUNT_ID/authentication/AUTH_ID -H "Authorization: Bearer $API_KEY"
# → { "status": "succeeded" }The phone number, code and password are forwarded and forgotten: never stored, logged, written to the activity log, or used to match a retry. An authentication here has one id, the account's own, because an account has one sign-in at a time. Sign-out and deletion work as for any account; deleting removes the account at Telegram's gateway first, and does nothing if that can't be done.
GET Resolve a recipient
/accounts/{id}/resolve?to= · needs read
Telegram only. Turns what a person would type to address someone — @ada, ada, https://t.me/ada, or a phone number with a leading + — into the conversation id the conversation and message routes use for them, so you can open a chat knowing only a username. It sends nothing, spends no credit and creates no chat. A bare numeric chat id is returned as it is. to can be up to 64 characters.
curl "https://messengeragent.dev/api/v1/accounts/ACCOUNT_ID/resolve?to=@ada" -H "Authorization: Bearer $API_KEY"
# → { "conversationId": "777000123", "name": "Ada Lovelace", "username": "ada" }
# Then read the chat with the id you were given.
curl "https://messengeragent.dev/api/v1/accounts/ACCOUNT_ID/conversations/777000123/messages" -H "Authorization: Bearer $API_KEY"404 not_found— "No such Telegram user." Nothing is remembered about a miss, so asking again later works.400 invalid_request—tois missing, blank or too long.422 unsupported_operation— the account's channel cannot do this; checkcapabilities.resolveConversation.429 rate_limited— Telegram asked for a pause; waitRetry-Afterseconds. Lookups have a lower limit than other reads (20 a minute per key) because each one reaches Telegram; a person found is remembered for about ten minutes.
GET List a forum's topics
/accounts/{id}/conversations/{conversationId}/topics · needs read
Telegram only. A forum is a supergroup split into topics: the conversation has isForum: true and no messages of its own, and each topic does. A topic's id is a conversation id like any other, so read it and send into it with the ordinary conversation routes. Pinned topics first, then newest activity first. A conversation that isn't a forum is a 422 unsupported_operation; an account's capabilities.topics says whether its channel has topics at all.
curl "https://messengeragent.dev/api/v1/accounts/ACCOUNT_ID/conversations?limit=50" -H "Authorization: Bearer $API_KEY"
# → { "conversations": [ { "id": "-1001919633522", "name": "Flow Dev", "isGroup": true, "isForum": true, … } ] }
curl "https://messengeragent.dev/api/v1/accounts/ACCOUNT_ID/conversations/-1001919633522/topics" -H "Authorization: Bearer $API_KEY"
# → { "topics": [ { "id": "-1001919633522_1", "name": "General", "isGeneral": true, "isPinned": false, "isClosed": false, "unreadCount": 3, "lastMessage": { … } },
# { "id": "-1001919633522_5968", "name": "News", … } ] }
# A topic's id is a conversation id: read it, and send into it, like any other.
curl "https://messengeragent.dev/api/v1/accounts/ACCOUNT_ID/conversations/-1001919633522_5968/messages" -H "Authorization: Bearer $API_KEY"POST Sign out an account
/accounts/{id}/sign-out · needs accounts · Enterprise plan
Ends the channel's session. The account, its conversations and its contacts stay, and it can be signed in again later. The answer is the account, now disconnected. Repeating it is harmless, so no Idempotency-Key is needed. If the channel can't be reached you get an error, never "signed out" — the session may still be live.
DELETE Delete an account
/accounts/{id} · needs accounts · Enterprise plan
Removes the account and ends its session at the channel. It answers 204 with no body. If the channel can't be reached, nothing is deleted and you get a 503 to retry, so a 204 can be relied on: no session is left behind. Deleting an account that is already gone is a 404, so a retry after a timeout is safe to treat as success. What the account did stays in your activity log.
# Sign out: the account stays, disconnected
curl -X POST https://messengeragent.dev/api/v1/accounts/ACCOUNT_ID/sign-out -H "Authorization: Bearer $API_KEY"
# Delete: 204, or a 503 if the channel can't be reached (nothing was deleted — retry)
curl -X DELETE https://messengeragent.dev/api/v1/accounts/ACCOUNT_ID -H "Authorization: Bearer $API_KEY"POST Embed a chat
/accounts/{id}/embed · needs embed · LinkedIn and Telegram
Returns a link for an <iframe> on your own site: the account's conversations, with reading and sending, drawn the way the channel draws them: LinkedIn's look for a LinkedIn account, and Telegram's (chat list, bubbles, forum topics, files and reactions) for a Telegram account. The link's address names its channel (/embed/linkedin/… or /embed/telegram/…); use whatever the API returned. Ask for it from your server, so the API key never reaches a browser, and hand the browser only the link.
- One account, one site. The link opens only the account it was made for, and only inside the
originyou name (https://app.yourcompany.com— https, no path, no wildcard;http://localhost:…works while you build). Any other site that frames it is refused by the browser. - Short-lived. It expires after
expiresInSeconds— an hour by default, at most a day — and sooner if the key that made it is revoked. Make a fresh one each time you show the chat. - It is a secret. Anyone holding the link can read and send until it expires, so don't log it or put it in a page other people can read. It is a one-time-per-page-view credential, not a permanent address.
- Sends are real. A message or file written in the frame is sent from the account and uses a credit, exactly as in the dashboard. Add
?theme=darkor?theme=lightto the link to choose the look. - Updates. The frame has no live push yet. A Telegram chat checks every ten seconds; LinkedIn's about every two minutes, because each refresh is a real page load there. Messages you send show up at once.
The frame tells your page when it is ready and when the link has expired, with postMessage to your origin only. Other channels return 422 unsupported_operation; check capabilities.embed on the account.
# 1. On your server — never in the browser, the API key must stay secret
curl -X POST https://messengeragent.dev/api/v1/accounts/ACCOUNT_ID/embed \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{ "origin": "https://app.yourcompany.com", "expiresInSeconds": 3600 }'
# → 201
# {
# "url": "https://…/embed/linkedin/emb_CfDJ8…",
# "expiresAt": "2026-10-07T13:00:00Z",
# "origin": "https://app.yourcompany.com"
# }
# 2. In your page, give the browser only that URL (a Telegram account's link is …/embed/telegram/emb_…)
<iframe src="https://…/embed/linkedin/emb_CfDJ8…" title="Chat"
style="width:100%;height:640px;border:0" allow="clipboard-write"></iframe>
// 3. Optional: hear what the chat tells your page (frameUrl is the link from step 1)
window.addEventListener('message', (e) => {
if (e.origin !== new URL(frameUrl).origin || e.data?.source !== 'agent-messenger') return
if (e.data.type === 'expired') reloadWithAFreshLink() // ask your server for a new link
})Pagination
Messages page backwards by message, not by time: pass nextBefore from one page as before on the next. Because a cursor is a message id, messages sent in the same second are never skipped at a page boundary. When hasMore is false you've reached the oldest message that's reachable. A before that's no longer reachable is a 422, not a silent restart. Contacts page with limit and offset.
# First page, newest first
curl "https://messengeragent.dev/api/v1/accounts/ACCOUNT_ID/conversations/CONVERSATION_ID/messages?limit=10" ...
# → "hasMore": true, "nextBefore": "M21"
# Older: pass nextBefore as "before"
curl "https://messengeragent.dev/api/v1/accounts/ACCOUNT_ID/conversations/CONVERSATION_ID/messages?limit=10&before=M21" ...Idempotency
Networks fail halfway. Every send — and every account you create or start signing in — needs an Idempotency-Key header — any unique value for that message, up to 128 characters, such as a UUID or your own order id. If you don't hear back, send the same request with the same key: you get the first result back, marked Idempotent-Replayed: true, and nothing is sent twice.
- The same key with a different body is refused (422) — a new message needs a new key. For a send with a file, the file counts as part of the body: its name, size and a SHA-256 of its content are compared, never the bytes.
- Keys are remembered for 24 hours and belong to the API key that used them.
- A refused send (no credits, account signed out, bad input) frees its key, so you can fix the cause and try again with it.
- One honest limit: if a channel fails after it may already have sent — a timeout reaching WhatsApp, say — the answer is an error and the key is freed, so a retry could in rare cases send twice. Check the conversation before retrying such a failure.
Errors
Errors are application/problem+json with a stable code. Branch on the code; the detail is for a person reading a log and may be reworded.
HTTP/1.1 402 Payment Required
Content-Type: application/problem+json
{
"title": "Payment required",
"status": 402,
"detail": "This month's credits are used up. Messages can be sent again when the plan renews or is upgraded.",
"code": "credit_limit_reached"
}| code | HTTP | Meaning |
|---|---|---|
| missing_api_key | 401 | No key was sent. |
| invalid_api_key | 401 | The key is wrong, revoked or expired. |
| insufficient_scope | 403 | The key lacks the read or send permission this call needs. |
| not_found | 404 | No such account or conversation — or it is not yours; the two look the same. |
| invalid_request | 400 / 422 | The request is malformed, or a pagination cursor is no longer reachable. For a file: more than one, not in a part named file, no file name, empty, or a multipart body that cannot be read. |
| unsupported_operation | 422 | The channel cannot do this — for example contacts on Gmail. Check the account's capabilities first. |
| attachment_type_not_allowed | 422 | The file is not a PDF, PNG or JPEG, or its content is not the type it says it is. |
| attachment_too_large | 413 | The file is over the limit in capabilities.maxAttachmentBytes (10 MB). |
| attachment_needs_conversation | 422 | LinkedIn takes a file only into a conversation that has started. Send the text first, the file once that conversation exists. |
| account_not_ready | 409 | The account is signed out or needs a new password. Fix it in the dashboard. |
| credit_limit_reached | 402 | This month's credits are used up. |
| invalid_credentials | 422 | Connecting or replacing an email account: Google refused the app password. Nothing was stored. |
| account_already_exists | 409 | Connecting an email account: that mailbox is already connected. |
| attachment_storage_unavailable | 503 | The file could not be stored, so nothing was queued. Not the channel being down: wait for Retry-After and send again with the same Idempotency-Key. While storage is not set up, capabilities.attachments is false. |
| too_many_line_breaks | 400 | A LinkedIn message has more than 20 line breaks (counted after runs of blank lines are collapsed). Other control characters in a message are invalid_request. |
| remote_account_not_found | 404 | Adopting an account: linkedin-api has no account with that id for this service's key. It must exist under the same linkedin-api tenant. |
| remote_account_already_linked | 409 | Adopting an account: that LinkedIn account is already linked here, to you or to anyone else. |
| plan_required | 403 | Creating, signing in, signing out and deleting accounts through the API is part of the Enterprise plan. |
| account_limit_reached | 403 | Your plan's connected accounts are all in use. Delete one or upgrade to create another. |
| authentication_not_waiting | 409 | You submitted a code, but that sign-in is not waiting for one. Start again. |
| idempotency_key_required | 400 | A send needs an Idempotency-Key header. |
| idempotency_key_reused | 422 | That key was already used for a different request. |
| idempotency_key_in_progress | 409 | The first request with that key is still running. Wait, then retry with the same key. |
| rate_limited | 429 | Too many requests. Wait for Retry-After. |
| channel_unavailable | 502 / 503 | WhatsApp, Telegram, LinkedIn or Gmail could not be reached or refused it. Retry later. |
Rate limits
Each key gets 120 reads and 30 sends per minute, counted separately, so a polling job can't starve your sends. Over the limit you get 429 with a Retry-After header in seconds. LinkedIn adds its own pacing on reads, and holds at most 50 unsent messages per account.
Not in v1
So you don't go looking: webhooks to your server when a message arrives (for now, read on a schedule), campaigns and tags, downloading or uploading attachments through the API (an embedded chat shows and sends them), creating or editing contacts, creating, signing in, signing out and deleting WhatsApp accounts, more than one attachment on an email, and Telegram media, calls and secret chats. For an AI assistant rather than your own code, see connecting an agent.