Inbound message events
The message frame, its attachments, the ids it gives you, and a worked auto-reply.
When someone sends a message to one of your lines, an event: message frame appears
on the response stream. That is the inbound bubble. It is not the only
inbound event: three more are frames you ask
for with ?events=: locations adds a contact's shared location as
event: location, moderation adds their
edits and unsends as event: moderation,
and polls adds a poll's moments as event: poll.
A message you missed while disconnected comes back when you reconnect with
Last-Event-ID. To read a
thread's past, use
GET /v1/conversations/{conversationId}/messages.
id: 0192a7c4-6a02-7e11-8c4d-2b9f7e3a5d10
event: message
data: {"conversationId":"a0000000-0000-4000-8000-000000000001","from":"+14155550100","text":"is it here yet?","externalId":"guid-1","replyToExternalId":null,"occurredAt":"2026-08-24T18:03:11.000Z","attachmentIds":[],"attachments":[],"messageId":"0192a7c4-6a02-7e11-8c4d-2b9f7e3a5d22"}The frame
Prop
Type
Not every message has text
text carries the body only when the message is plain text and that text is not
blank. A message that is an attachment on its own arrives with text: null and
a populated attachmentIds. A poll can also leave a message with no text, or
with only U+FFFD, in a conversation: there is nothing to answer.
text is always present in the JSON and can be null. Code that reaches straight for
event.text.toLowerCase() throws on the first photo someone sends you. Branch on the pair — text,
attachments, or both — rather than assuming a body is there.
A message with no text and no attachment
A null text does not promise an attachment either. On a live line a chat
background set or removed in a conversation with one person also arrives as a
message from that person with no text: a message frame whose text is
null. It arrives whoever made the change — your own
chat_background.set or
chat_background.remove, or the person on their
own phone. It is stored in the conversation and counted as a received message.
Nobody wrote anything, so there is nothing to answer. Skip a received message
that has no text and no attachment rather than reply to it. A handler that
answers every message frame answers this one too, and the person gets a reply
to a change of background.
In a group chat the line's own change files nothing, and a test line files no such message: a rehearsal never shows your handler one.
The two ids, and which is which
The frame carries two identifiers that are easy to confuse and are not interchangeable.
conversationId is a Mapier UUID identifying the thread. It is what you
persist against your own records — a customer, a ticket, a case — and what you
put in a conversation target to send anything back later. It works for
direct messages and group chats alike: a message someone writes in a group
arrives under the group's own conversationId, with isGroup: true, and a
send to that id reaches every member — see Groups.
externalId is the transport's id for this one message. You use it when a
command needs to point at a specific message rather than a thread:
targetMessageExternalIdonreaction.tapback, to react to it.attachToMessageExternalIdon astickersend, to stick an emoji onto it. A reply is different: it names Mapier'smessageIdattarget.replyToMessageId— see Threaded replies.
The message an externalId names has to still be in the line's
bounded history.
A message that arrived moments ago certainly is; one you stored a week ago may
not be, and a command pointing at it settles stale_target.
occurredAt is a send time
occurredAt comes from iMessage and records when the message was sent, not
when Mapier received it or when your process read it. The gap is usually
milliseconds and occasionally is not — a phone that was offline delivers late,
and your stream can be reconnecting.
It is the closest thing to an ordering key you have on a message, and it is
worth sorting by for display. It is not a guarantee: the stream makes no
ordering promise, and nothing in the payload lets you detect that a message is
missing from a window. After a drop, resume with Last-Event-ID.
Downloading attachments
attachmentIds is ordered oldest first and is an empty array — never null —
when the message had none. attachments names the same files in the same
order, with what the sender's device declared: mimeType (or null),
filename, byteSize and isSticker, which is absent when the channel did
not say — never read that as false.
const BASE = "https://api.mapier.ai";
for (const id of event.attachmentIds) {
const res = await fetch(`${BASE}/v1/attachments/${id}`, {
headers: { Authorization: `Bearer ${process.env.MAPIER_API_KEY}` },
});
if (res.status === 404) {
retryLater(id); // usually still transferring — see the callout below
continue;
}
if (!res.ok) throw new Error(`attachment ${id}: ${res.status}`);
const contentType = res.headers.get("content-type"); // sniffed from the bytes
const etag = res.headers.get("etag"); // quoted sha256 of the content
await save(id, contentType, new Uint8Array(await res.arrayBuffer()));
}Two properties of the download worth designing around. The content-type is
sniffed from the bytes rather than declared, so a HEIC photo comes back as HEIC
and is never transcoded for you. And the URL is permanent but unsigned — it
is safe to store, because every request needs your credential, and unsafe to
hand to a browser or a third party for exactly the same reason.
A 404 on an attachment id from a message that arrived moments ago usually means the transfer has
not finished yet, not that the id is wrong. Retry it rather than discarding the id.
Voice memos and effects
isVoiceMemo: true marks a voice memo: the recording is among its attachments,
and text may carry the channel's transcript. effect is the bubble or screen
effect the sender chose, by its short name. Both are absent when there was none
or the channel did not say.
service is the service an iMessage line received the message over: iMessage,
or SMS or RCS when the sender's phone sent it through their carrier instead.
It is absent when the line did not say — a Mac from before the field, another
channel, or a service it does not name — so never read absent as iMessage.
Only inbound
Your own outbound messages never appear here. Sending a message produces a
command event reporting how the send
settled, and nothing else — no echo of the message you sent.
A full transcript, both ways, is the
history read. The stream's one-way nature also means an auto-reply cannot trigger itself. Your reply is outbound, so
it never comes back around as a message event.
A worked auto-reply
Given the parsed event, replying is a single message.send into the same
conversationId. It is plain text; to thread it under what they asked, name the
frame's messageId at target.replyToMessageId (see
Threaded replies).
const BASE = "https://api.mapier.ai";
type InboundMessage = {
conversationId: string;
from: string;
text: string | null;
externalId: string;
replyToExternalId: string | null;
occurredAt: string;
attachmentIds: string[];
messageId: string;
isGroup?: boolean;
};
async function autoReply(event: InboundMessage) {
// No text, no reply: an attachment on its own, the message a chat background
// change leaves, or a poll's row (null, or a lone U+FFFD).
if (!event.text?.trim() || event.text === "\uFFFD") return;
const res = await fetch(`${BASE}/v1/commands/message.send`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.MAPIER_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": `autoreply:${event.externalId}`,
},
body: JSON.stringify({
target: { kind: "conversation", conversationId: event.conversationId },
payload: { text: "Thanks — someone will pick this up shortly." },
}),
});
const { commandId, disposition } = await res.json();
// 202 means queued. Watch the stream for the settlement before claiming sent.
console.log("queued reply", commandId, disposition);
}The first line is the skip. This handler answers words, so a message with no text gets no reply — a photo on its own, and the message with no text and no attachment that a chat background change leaves. A handler that also answers attachments still skips that one.
The Idempotency-Key is doing real work there. It is derived from the inbound
message's GUID, so a second attempt to answer that same message is adopted
rather than executed: the replay comes back 202 with the same commandId and
disposition: "adopted", and no duplicate is sent. Note that disposition
appears on that response body only — it is not a field on the settlement event.
You need that key the moment you run more than one consumer. Concurrent streams are a broadcast: every connection receives every event, so two workers on one project both see the same inbound message and both try to answer it. A conversation-target send honours the key, so the second attempt is adopted. Without a key, the customer gets the reply twice.
Finally, resist the temptation to treat the 202 as the end of the story. It
means the reply was queued. Whether it was actually sent arrives later, on the
same stream you are already reading — as a succeeded command frame: the
transport's settlement of the send, not Apple's Delivered.
Related
Edits and unsends
A contact's later edit or unsend of this message, as a moderation frame.
Reactions
A contact's tapbacks arrive as webhooks, not on the stream.
The poll frame
A poll in the conversation, its choices and its votes.
Addressing a conversation
What to do with the conversationId you stored.
GET /v1/attachments/:id
Downloading each attachmentIds entry. Content-Type is sniffed bytes.
Receipts and delivery verdicts
Delivered, read and not delivered — the stream's receipt and delivery_failed frames, the webhooks that carry the same facts, and the question you can ask about one message now.
Location events
A contact sharing their location with the line — when the share starts, each new position, and when it ends. On request only.