Attachments and stickers
Sending media is not available yet; receiving and downloading it works today.
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.
{
"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 header | What it tells you |
|---|---|
content-type | Sniffed from the bytes themselves, not from a stored MIME type. |
content-length | Size in bytes. |
etag | The content's SHA-256, quoted. Immutable — the bytes never change. |
cache-control | private, max-age=31536000, immutable. |
content-disposition | inline; 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
| Status | Body | Cause |
|---|---|---|
400 | invalid_id | The path segment could not be decoded. |
401 | unauthenticated | Missing or unusable credential. |
404 | not_found | Unknown id, another account's id, or one whose transfer has not finished. |
405 | method_not_allowed | Anything but GET. |
429 | rate_limited | Over 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.