mapier docs
Response stream

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:

  • targetMessageExternalId on reaction.tapback, to react to it.
  • replyToMessageExternalId on a rich_text send, 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.

On this page