Attachments and stickers
Stage a file, then send it by transferId; plus receiving and downloading inbound media.
Sending an attachment is two steps: stage the bytes to get a transferId, then
send a message.send that references it. A command body never carries a file.
Sending
Stage the file with POST /v1/media. It returns a transferId.
curl $MAPIER_BASE_URL/v1/media \
-H "Authorization: Bearer $MAPIER_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "filename": "photo.jpg", "mimeType": "image/jpeg", "bytesBase64": "..." }'
# → { "transferId": "1f607047-a3e6-4521-8dcd-b3754cf28c74" }Then reference that transferId in an attachment payload. The filename's
extension sets how it renders — .jpg as an image, .vcf as a contact card.
curl $MAPIER_BASE_URL/v1/commands/message.send \
-H "Authorization: Bearer $MAPIER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"target": { "kind": "conversation", "conversationId": "a0000000-0000-4000-8000-000000000001" },
"payload": { "kind": "attachment", "transferId": "1f607047-a3e6-4521-8dcd-b3754cf28c74" }
}'Stage before you send. The bytes must land on the sending Mac before the command runs; a send
referencing media still in flight settles failed on the response
stream.
Sending a contact card
A contact card is just a .vcf sent as an attachment: stage a vCard with
mimeType: "text/vcard" and a .vcf filename, and the recipient gets a
tappable card. This is not
sharing your own name and photo — that is a separate
command that offers your configured iMessage identity, no file involved.
The payload schemas
attachment
Prop
Type
sticker
Sends not available yet
You can stage sticker bytes with POST /v1/media, but connectors do not yet
advertise sticker sends, so this payload settles failed. Attachments send today.
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. There are no sub-paths —
/v1/attachments/att-a1b2/original is not a route — and no size, format or
thumbnail parameter, so a query string on the URL is ignored rather than
selecting 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.