Attachments
Send a photo or file by link, and receive and download the ones people send you.
A photo or file goes out by link: a message.send whose payload is
kind: "media" with an https url. The platform fetches the link itself,
hands the file to the line, and only then writes the message — a command body
never carries bytes. There is no upload step: a payload that names a staged
upload (media.transferId) or carries attachments is refused with a 422
naming the field. Several files as one message is a
multipart.
Sending a photo or file
{
"kind": "media",
"url": "https://example.com/orders/4821/receipt.pdf"
}Prop
Type
Nothing else goes beside url on an iMessage line: a caption is
422 validation_failed at payload.caption, and an effect or a
subject at that key — send the words as a text of their own.
What the platform checks before it sends
The link is fetched inside the request, and the file has to pass before the
message exists: https only, at most 8 MiB, from a server that answers
promptly, every redirect re-checked and none into a private network, and only
an image, a PDF, common audio or video, or a vCard contact card (.vcf)
whose declared type matches its bytes. A link the platform cannot use is 422 validation_failed at
payload.url, and the entry's reason says why:
reason | What happened |
|---|---|
media_fetch_refused | Not https, a redirect into a private network, or a scheme it will not follow |
media_fetch_failed | The server did not answer, or not in time |
media_too_large | Over 8 MiB |
unsupported_media_type | Not an image, a PDF, common audio or video, or a vCard — or a type that does not match the bytes |
While your project, or the platform, is already sending as many files as it
can at once, the answer is 503 service_busy with retryAfter. Neither a 422 above nor that 503 writes a message, so the same Idempotency-Key may be sent again — unlike a 503 upstream_unavailable after the transport was asked, after which the key is spent.
Sending a contact card
A .vcf file goes out the same way, and the recipient sees a contact card
they can save. Serve it as text/vcard (or text/x-vcard), or with no
specific type; the file must be a whole vCard, from BEGIN:VCARD to
END:VCARD, in UTF-8. A card goes alone: it cannot be a part of a
multipart.
{
"kind": "media",
"url": "https://example.com/team/support.vcf"
}Where a photo can go
A media link travels on the line's conversation path and nowhere else —
the path a thread takes once the transport has confirmed it is a conversation
with that one contact, which happens the first time the contact writes into
it, and the path a group chat the transport has reported takes, where the file
reaches every member. Into any other thread it is refused before anything is written, at
target.conversationId or the recipient key with
reason: conversation_not_confirmed: send the person a text first. See
which path a send takes.
In test mode the sandbox fetches nothing: a media link into a confirmed test conversation is
refused by name at payload.url, and into any other thread with the live line's reason: conversation_not_confirmed. Test the file against a real handset.
curl https://api.mapier.ai/v1/commands/message.send \
-H "Authorization: Bearer $MAPIER_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-4821-receipt" \
-d '{
"target": { "kind": "conversation", "conversationId": "a0000000-0000-4000-8000-000000000001" },
"payload": { "kind": "media", "url": "https://example.com/orders/4821/receipt.pdf" }
}'A 202 means the file reached the line; the command frame says whether the
line sent it.
Stickers
A sticker — one emoji, or an image by https link — has its own guide:
Stickers. The full field list, bounds and 422
rules are on message.send → sticker.
Receiving attachments
When someone sends you a photo, a video or a voice memo, the inbound message
frame on the response stream carries attachmentIds, ordered oldest first and
empty when the message has no media, and attachments: the same files in the
same order, with the mimeType, filename and byteSize the sender's device
declared (each may be null) and isSticker when the channel said. Fetch each
with GET /v1/attachments/:id, whose content-type is
read from the bytes.
{
"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": ["8f2c1a4e-0000-4000-8000-0000000000a1", "8f2c1a4e-0000-4000-8000-0000000000a2"],
"attachments": [
{
"id": "8f2c1a4e-0000-4000-8000-0000000000a1",
"mimeType": "image/heic",
"filename": "IMG_0421.HEIC",
"byteSize": 123456
},
{
"id": "8f2c1a4e-0000-4000-8000-0000000000a2",
"mimeType": "image/jpeg",
"filename": "IMG_0422.JPG",
"byteSize": 88421
}
],
"messageId": "0192a7c4-6a02-7e11-8c4d-2b9f7e3a5d22"
}Each id is fetched on its own from GET /v1/attachments/:id. The response is
the raw bytes — there is no JSON envelope — so the headers remain what you
learn about the landed file.
curl -sS -D - -o photo.bin \
https://api.mapier.ai/v1/attachments/8f2c1a4e-0000-4000-8000-0000000000a1 \
-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. |
The path takes a single id segment. There are no sub-paths 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 https://api.mapier.ai/v1/attachments/8f2c1a4e-0000-4000-8000-0000000000a1 \
-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. On an iMessage line the download detects the landed
bytes and returns content-type: image/heic — 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 the GET's 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 = "https://api.mapier.ai";
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 project's, or still transferring
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(`${res.status} ${body.error?.code ?? "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 a minute per
key, and a burst of a shared photo album will reach that. A 429 carries a
Retry-After header, in whole seconds and never below 1. The budget is the
key's, so every process using that key shares it — give a busy downloader a
key of its own.
Errors
The full table is on GET /v1/attachments/:id. A
404 deliberately collapses three different situations. An id that belongs to
another project is indistinguishable from one that never existed. 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.