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

  1. Create an API key on the API page. It's shown once — copy it somewhere safe.
  2. Connect at least one account (WhatsApp, LinkedIn or Gmail) in the dashboard.
  3. 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.

whatsapp

Contacts: Yes
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).

telegram

Contacts: Yes (people you have chatted with)
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.

linkedin

Contacts: Yes
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

Contacts: No
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) or outbound (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: since includes its day, until does not.
  • limit — 1 to 50 (25 by default). Page back with before, the nextBefore of 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\n and \r become \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-Key it 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-Key is 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_operation when a file is attached. Branch on the account's capabilities.attachments (a boolean) and capabilities.maxAttachmentBytes (an integer; 0 where files are not supported) rather than on the channel's name.
  • Types and size. application/pdf, image/png and image/jpeg, up to 10 MB (10,485,760 bytes). Any other type is 422 attachment_type_not_allowed; a larger file is 413 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 also attachment_type_not_allowed.
  • If the file can't be stored. The answer is 503 attachment_storage_unavailable with a Retry-After — "The file could not be stored. Nothing was queued." — a different code from channel_unavailable (linkedin-api unreachable), so you can tell them apart. The key stays free, so retry with the same Idempotency-Key. While file storage isn't set up, capabilities.attachments is false (and maxAttachmentBytes 0), 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 with Idempotent-Replayed: true and sends nothing. The same key with a different file, or with no file, is 422 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 the Idempotency-Key stays 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}/credentials with { "appPassword": "…" }. Checked with Google first; a refusal changes nothing. It also brings back an account that went to needs_attention because Google revoked the old one. capabilities.updateCredentials says which channels have a credential to replace.
  • Re-check — POST /accounts/{id}/check asks Google whether it still accepts the password and answers the account: connected, or needs_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 answer 422 unsupported_operation.
  • Sending email is the ordinary send: reply into a thread, or start a new email with to, subject and text or html. 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 becomes needs_attention, and it returns to connected by 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 _ — is 422 invalid_request. If linkedin-api can't be reached it is 503 channel_unavailable: retry with the same Idempotency-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; its status is now connected.
  • failed — it didn't work. reason and message say 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 — qrCode is a data:image/png address 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}/password with {"password": "…"}.
  • succeeded / failed — as above. A wrong code, password or number is a 422 invalid_credentials with Telegram's own words, and the sign-in stays on the same step; a flood wait is a 429 with Retry-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 — to is missing, blank or too long. 422 unsupported_operation — the account's channel cannot do this; check capabilities.resolveConversation.
  • 429 rate_limited — Telegram asked for a pause; wait Retry-After seconds. 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 origin you 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=dark or ?theme=light to 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"
}
codeHTTPMeaning
missing_api_key401No key was sent.
invalid_api_key401The key is wrong, revoked or expired.
insufficient_scope403The key lacks the read or send permission this call needs.
not_found404No such account or conversation — or it is not yours; the two look the same.
invalid_request400 / 422The 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_operation422The channel cannot do this — for example contacts on Gmail. Check the account's capabilities first.
attachment_type_not_allowed422The file is not a PDF, PNG or JPEG, or its content is not the type it says it is.
attachment_too_large413The file is over the limit in capabilities.maxAttachmentBytes (10 MB).
attachment_needs_conversation422LinkedIn takes a file only into a conversation that has started. Send the text first, the file once that conversation exists.
account_not_ready409The account is signed out or needs a new password. Fix it in the dashboard.
credit_limit_reached402This month's credits are used up.
invalid_credentials422Connecting or replacing an email account: Google refused the app password. Nothing was stored.
account_already_exists409Connecting an email account: that mailbox is already connected.
attachment_storage_unavailable503The 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_breaks400A 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_found404Adopting 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_linked409Adopting an account: that LinkedIn account is already linked here, to you or to anyone else.
plan_required403Creating, signing in, signing out and deleting accounts through the API is part of the Enterprise plan.
account_limit_reached403Your plan's connected accounts are all in use. Delete one or upgrade to create another.
authentication_not_waiting409You submitted a code, but that sign-in is not waiting for one. Start again.
idempotency_key_required400A send needs an Idempotency-Key header.
idempotency_key_reused422That key was already used for a different request.
idempotency_key_in_progress409The first request with that key is still running. Wait, then retry with the same key.
rate_limited429Too many requests. Wait for Retry-After.
channel_unavailable502 / 503WhatsApp, 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.