Response stream

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:

  • targetMessageExternalId on reaction.tapback, to react to it.
  • attachToMessageExternalId on a sticker send, to stick an emoji onto it. A reply is different: it names Mapier's messageId at target.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.

On this page