Inbound message events
The message frame, the ids it gives you, and a worked auto-reply.
When someone sends a message to your account, an event: message frame appears
on the response stream. This is the only way messages reach you — there is no
message history endpoint, no list endpoint and no pagination, so an event you
do not read is an event you cannot get back.
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":[]}The frame
Prop
Type
Not every message has text
text carries the body only when the message is plain text. A message that is
an attachment on its own arrives with text: null and a populated
attachmentIds, and from is likewise null when no sender was recorded.
Both fields are always present in the JSON and either 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.
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 groups alike.
externalId is the iMessage GUID 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.replyToMessageExternalIdon arich_textsend, to reply inline to it.
A GUID for a reply target has to still be in the connector'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, 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.
Attachment ids arrive here and nowhere else
attachmentIds is ordered oldest first and is an empty array — never null,
never absent — when the message had none. Each id is a handle for
GET /v1/attachments/:id, which is a download-only
endpoint guarded by your credential.
const BASE = process.env.MAPIER_BASE_URL!;
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) continue; // still transferring, or not yours
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 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.
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.
That has one practical consequence: if you want a full transcript of a conversation, you have to write down your own sends as you make them. There is no endpoint that will reconstruct it for you afterwards.
It 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. Using rich_text with replyToMessageExternalId makes it a
threaded reply, so the recipient sees it attached to what they asked.
type InboundMessage = {
conversationId: string;
from: string | null;
text: string | null;
externalId: string;
replyToExternalId: string | null;
occurredAt: string;
attachmentIds: string[];
};
async function autoReply(event: InboundMessage) {
if (!event.text?.trim()) return; // null on an attachment-only message
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: {
kind: "rich_text",
text: "Thanks — someone will pick this up shortly.",
replyToMessageExternalId: event.externalId,
},
}),
});
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 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 account 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.