mapier docs
Messages

Attachments and stickers

Sending media is not available yet; receiving and downloading it works today.

Sending attachments is not yet available.

There are two media payload shapes in the message.send schema — attachment and sticker — and neither can be used from /v1 today. Both are documented here so you know what they will look like, followed by the half of the media story that does work: receiving attachments and downloading their bytes.

Why sending does not work

Both payloads identify their media by transferId, a reference to a file already staged in Mapier storage. A Mac-local file path never crosses the wire, and neither do raw bytes in the command body.

That leaves one question — how do you get a transferId? — and /v1 has no answer. There is no upload endpoint. Nothing in the public API accepts a file, so nothing in the public API can produce the identifier both payloads require.

A send referencing a transferId you invented will still return 202. The API does not validate payload, so an unresolvable id fails asynchronously on the response stream. Do not read a 202 here as evidence that these payloads work.

The schemas, for when they land

attachment

Prop

Type

sticker

Prop

Type

Receiving attachments — this part works

When someone sends you a photo, a video or a voice memo, the inbound message event on the response stream carries an attachmentIds array. It is ordered oldest first and empty when the message has no media.

an inbound message event with two photos
{
  "conversationId": "a0000000-0000-4000-8000-000000000001",
  "from": "+14155550100",
  "text": "here's the damage",
  "externalId": "guid-2",
  "replyToExternalId": null,
  "occurredAt": "2026-08-29T18:11:02Z",
  "attachmentIds": ["att-a1b2", "att-c3d4"]
}

Each id is fetched on its own from GET /v1/attachments/:id. The response is the raw bytes — there is no JSON envelope and no metadata endpoint, so everything you learn about the file comes from the response headers.

curl -sS -D - -o photo.bin \
  $MAPIER_BASE_URL/v1/attachments/att-a1b2 \
  -H "Authorization: Bearer $MAPIER_API_KEY"
Response headerWhat it tells you
content-typeSniffed from the bytes themselves, not from a stored MIME type.
content-lengthSize in bytes.
etagThe content's SHA-256, quoted. Immutable — the bytes never change.
cache-controlprivate, max-age=31536000, immutable.
content-dispositioninline; filename="...", sanitised: anything outside letters, digits, underscore, dot, hyphen and space becomes _, truncated to 128 characters.

The path takes a single id segment and nothing else. There are no sub-paths: /v1/attachments/att-a1b2/original is not a route, and there is no size, format or thumbnail parameter to ask for a different rendition.

Conditional requests

The etag is a content hash, so it never changes for a given id. Send it back as If-None-Match and an unchanged attachment answers 304 with no body, which is worth doing if you re-fetch on a retry path.

curl -sI $MAPIER_BASE_URL/v1/attachments/att-a1b2 \
  -H "Authorization: Bearer $MAPIER_API_KEY" \
  -H 'If-None-Match: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"'

Because the hash is stable, most integrations skip conditional requests entirely and store the bytes on first fetch, keyed by attachment id.

The HEIC problem

iPhone photos arrive as HEIC and are never transcoded

A photo taken on an iPhone is usually HEIC. This endpoint detects that and returns content-type: image/heic with the original bytes — it does not convert anything. Most browsers cannot render HEIC, so writing the bytes into an img tag produces a broken image and no error you can catch.

Branch on content-type and convert server-side before you show a photo to anyone in a browser.

A worked download

This fetches one attachment, keeps whatever the sniffed type says, and flags the HEIC case for a conversion step rather than silently storing something the front end cannot display.

import { writeFile } from "node:fs/promises";

const BASE = process.env.MAPIER_BASE_URL!;

const EXTENSIONS: Record<string, string> = {
  "image/jpeg": "jpg",
  "image/png": "png",
  "image/gif": "gif",
  "image/heic": "heic",
};

export async function downloadAttachment(attachmentId: string, dir: string) {
  const res = await fetch(`${BASE}/v1/attachments/${encodeURIComponent(attachmentId)}`, {
    headers: { Authorization: `Bearer ${process.env.MAPIER_API_KEY}` },
  });

  if (res.status === 404) return null; // unknown, another account's, or still transferring
  if (!res.ok) {
    const body = await res.json().catch(() => ({}));
    throw new Error(`${res.status} ${body.error ?? "attachment fetch failed"}`);
  }

  const contentType = res.headers.get("content-type") ?? "application/octet-stream";
  const etag = res.headers.get("etag");
  const bytes = Buffer.from(await res.arrayBuffer());

  const extension = EXTENSIONS[contentType] ?? "bin";
  const path = `${dir}/${attachmentId}.${extension}`;
  await writeFile(path, bytes);

  return {
    path,
    contentType,
    etag,
    needsConversion: contentType === "image/heic",
  };
}

Fetch attachments one at a time per message rather than firing the whole attachmentIds array at once. The endpoint allows 120 requests per rolling 60-second window, and a burst of a shared photo album will reach that. A 429 carries both a Retry-After header and a retryAfter field in the body, in whole seconds and never below 1.

Unconfirmed

How that window is counted is not settled. It is currently applied per client IP rather than per account, so several of your own processes behind one egress address share a budget. Do not design around the limit being yours alone.

Errors

StatusBodyCause
400invalid_idThe path segment could not be decoded.
401unauthenticatedMissing or unusable credential.
404not_foundUnknown id, another account's id, or one whose transfer has not finished.
405method_not_allowedAnything but GET.
429rate_limitedOver 120 requests in the window. Honour Retry-After.

Note that 404 deliberately collapses three different situations. An id that belongs to another account is indistinguishable from one that never existed — existence is never leaked across accounts. If an id from your own stream returns 404, the likely cause is that the transfer has not landed yet; retry shortly.

The URL is permanent, but it is not a share link

An attachment URL never expires and carries no signature. Access is granted by the Authorization header on every single request, which means the URL is safe to store in your database and not safe to put in an img tag, email a customer, or hand to a third party — none of them can send your credential, and you would not want them to.

Proxy the bytes through your own service if a browser needs to display them.

On this page