> ## Documentation Index
> Fetch the complete documentation index at: https://docs.blujay.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Media

> Send photos, videos and files by uploading them once or from a public URL, and download media you receive.

A message can carry files as `media` parts. You can upload a file once and reuse it by its `attachment_id`, or point Blujay at a public HTTPS URL for a one-off send. Media you receive arrives as `media` parts too, and you download it through the API.

> [!WARNING]
>
> Media is rejected until the contact has replied to your number. A `media` part in a first message or in any follow-up before the reply returns `400` [`PRE_REPLY_CONTENT_BLOCKED`](/errors/pre-reply-content-blocked), and nothing is sent. See [Sending rules](/messaging/sending-rules.md).

## Choose how to send a file

| | Upload first | Send from a URL |
| - | - | - |
| Best for | Files you send more than once, or files that aren't images or videos | One-off images, videos and contact cards already online |
| File types | Any supported file | Images, videos and vCard (`.vcf`) files |
| Size | Up to 100 MiB | Up to 10 MiB |
| Part | `{ "type": "media", "attachment_id": "att_abc123" }` | `{ "type": "media", "url": "https://example.com/photo.jpg" }` |

Each `media` part sets exactly one of `attachment_id` or `url`. A message can have up to 40 `media` and `vcard` parts.

## Upload a file

### 1. Declare the file

Tell Blujay the file's name, type and exact size in bytes:

```bash theme={null}
curl https://api.blujay.io/v1/attachments \
  -H "Authorization: Bearer blu_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "filename": "return-label.pdf",
    "content_type": "application/pdf",
    "size_bytes": 48213
  }'
```

```json 201 Created theme={null}
{
  "status": "success",
  "data": {
    "attachment_id": "att_abc123",
    "upload_url": "https://uploads.example.com/att_abc123?signature=example",
    "upload_expires_at": "2026-10-14T17:15:00.000Z",
    "upload_headers": {
      "Content-Type": "application/pdf",
      "Content-Length": "48213"
    },
    "download_url": "/v1/attachments/att_abc123/content"
  },
  "trace_id": "trc_3e81f0c2d7a6",
  "timestamp": "2026-10-14T17:00:00.000Z"
}
```

`content_type` is a lowercase MIME type without parameters, such as `image/jpeg`. `filename` is a plain file name with no folder path.

`upload_url` is a signed URL on Blujay's storage, not on `api.blujay.io`. The host is returned in the response; allow it in your egress rules rather than hardcoding one.

### 2. PUT the raw bytes

Send the file's raw bytes to `upload_url` with exactly the `upload_headers` from the response. Don't use `multipart/form-data`, and don't add your API key: the URL already authorizes this one upload.

```bash theme={null}
curl -X PUT "UPLOAD_URL_FROM_THE_RESPONSE" \
  -H "Content-Type: application/pdf" \
  -H "Content-Length: 48213" \
  --data-binary @return-label.pdf
```

The URL expires after 15 minutes. If it expires before you upload, declare the file again.

### 3. Send it

Use the `attachment_id` in a `media` part. There's no separate "complete" call.

```bash theme={null}
curl https://api.blujay.io/v1/chats/chat_abc123/messages \
  -H "Authorization: Bearer blu_your_api_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: return-label-1042" \
  -d '{
    "message": {
      "parts": [
        { "type": "text", "text": "Here is your return label." },
        { "type": "media", "attachment_id": "att_abc123" }
      ]
    }
  }'
```

The first time you send or read the attachment, Blujay checks the uploaded bytes against what you declared:

* If the upload hasn't finished, you get `409` [`ATTACHMENT_NOT_READY`](/errors/attachment-not-ready). Retry once the `PUT` completes.
* If the bytes don't match the declared type, you get `422` [`ATTACHMENT_UPLOAD_INVALID`](/errors/attachment-upload-invalid). Declare the file again and upload the correct bytes.
* If the bytes don't match the declared `size_bytes`, the storage service refuses the `PUT` itself with a `403` and an XML body (the signed URL is bound to the exact size). Nothing is stored and the attachment stays `409` [`ATTACHMENT_NOT_READY`](/errors/attachment-not-ready). Declare the file again with the right size and upload again.

After that, reuse the same `attachment_id` in as many messages as you like.

At declaration, a file over 100 MiB returns `413` [`ATTACHMENT_TOO_LARGE`](/errors/attachment-too-large). Executables, scripts, HTML, SVG and XML files return `415` [`UNSUPPORTED_ATTACHMENT_TYPE`](/errors/unsupported-attachment-type). If your workspace has too many uploads waiting for their bytes, you get `429` [`ATTACHMENT_CAPACITY_EXCEEDED`](/errors/attachment-capacity-exceeded); finish or abandon some uploads and retry.

### Accepted file types

Blujay identifies a file by its bytes, not only by `content_type`, and accepts these:

| Kind | Types |
| - | - |
| Images and videos | Common image and video formats, including JPEG, PNG, HEIC and MP4. |
| Documents | PDF, Word, Excel and PowerPoint (`.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`), EPUB. |
| Audio | M4A/AAC, MP3, WAV, CAF, FLAC, Ogg, WebM. An audio file sends as an ordinary file, not as an iMessage voice memo. |
| Text | vCard (`text/vcard`) and other plain `text/*` types, except HTML and XML. |

A file whose bytes don't match the specific type you declared is rejected with `422` [`ATTACHMENT_UPLOAD_INVALID`](/errors/attachment-upload-invalid) when the upload is checked. A file Blujay can't identify is accepted as `application/octet-stream`; declare it as that. What a service can carry is a separate question; see [What each service can carry](#what-each-service-can-carry).

## Send from a URL

For a one-off image, video or contact card, put a public HTTPS URL in the part:

```bash theme={null}
curl https://api.blujay.io/v1/chats/chat_abc123/messages \
  -H "Authorization: Bearer blu_your_api_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: product-photo-7731" \
  -d '{
    "message": {
      "parts": [
        { "type": "text", "text": "This is the model we have in stock." },
        { "type": "media", "url": "https://example.com/images/model-7731.jpg" }
      ]
    }
  }'
```

```json 202 Accepted theme={null}
{
  "status": "success",
  "data": {
    "chat_id": "chat_abc123",
    "message": {
      "id": "msg_ghi789",
      "direction": "outbound",
      "delivery_status": "queued",
      "preferred_service": null,
      "service": null,
      "from": "+15555550100",
      "to": ["+15555550123"],
      "created_at": "2026-10-14T17:10:00.000Z",
      "parts": [
        { "type": "text", "text": "This is the model we have in stock." },
        { "type": "media", "attachment_id": "att_def456" }
      ]
    }
  },
  "trace_id": "trc_9a4c17e0b2d8",
  "timestamp": "2026-10-14T17:10:00.000Z"
}
```

Blujay checks the URL when you send, then downloads the file after it accepts the message. The response replaces the URL with a new `attachment_id`.

* The URL must be public HTTPS, up to 2,048 characters. URLs with credentials or that point at private addresses are rejected with `422` [`ATTACHMENT_FETCH_FAILED`](/errors/attachment-fetch-failed) before anything is sent.
* The file can be up to 10 MiB and must be an image, a video or a vCard. Serve a vCard as `text/vcard` or `text/x-vcard`, or as a generic binary type with a `.vcf` file name.
* Files disguised as another type are rejected.

If the download fails after acceptance, the message fails with a `failure_code`: `ATTACHMENT_FETCH_FAILED`, `ATTACHMENT_TOO_LARGE` or `UNSUPPORTED_ATTACHMENT_TYPE`. See [Track a message](/messaging/lifecycle.md#failures).

## What each service can carry

The service that carries a message decides which files it can send:

| Service | Files |
| - | - |
| iMessage | Any file you can upload. |
| RCS | Images, videos and vCards. |
| SMS (MMS) | Images, videos and vCards, up to 5 MiB in total per message. |

If any file in a message can't go out on the service that carries it, the whole message fails. Blujay never silently drops, converts or shrinks a file. If you might send to SMS recipients, keep files small and stick to images and videos, or [check the number first](/messaging/choosing-a-service.md).

## Read and download media

Message parts show media with its details once the file is ready:

```json theme={null}
{
  "type": "media",
  "attachment_id": "att_ghi789",
  "filename": "IMG_0412.jpg",
  "content_type": "image/jpeg",
  "size_bytes": 428731,
  "download_url": "/v1/attachments/att_ghi789/content"
}
```

`download_url` is a path on the Blujay API. Download the file from `https://api.blujay.io` plus that path, with your usual `Authorization` header:

```bash theme={null}
curl https://api.blujay.io/v1/attachments/att_ghi789/content \
  -H "Authorization: Bearer blu_your_api_key" \
  -o IMG_0412.jpg
```

The response body is the file itself, with its `Content-Type` and a `Content-Disposition` file name.

To get an attachment's details without downloading it, call `GET /v1/attachments/{attachment_id}`:

```bash theme={null}
curl https://api.blujay.io/v1/attachments/att_ghi789 \
  -H "Authorization: Bearer blu_your_api_key"
```

```json theme={null}
{
  "status": "success",
  "data": {
    "attachment_id": "att_ghi789",
    "status": "ready",
    "filename": "IMG_0412.jpg",
    "content_type": "image/jpeg",
    "size_bytes": 428731,
    "download_url": "/v1/attachments/att_ghi789/content",
    "created_at": "2026-10-14T17:20:00.000Z",
    "ready_at": "2026-10-14T17:20:02.000Z"
  },
  "trace_id": "trc_2f7d90a1c4e3",
  "timestamp": "2026-10-14T17:21:00.000Z"
}
```

### Media in received messages

Media in an inbound message can show up before the file is ready. At first, the part may have only `type` and `attachment_id`. The `message.received` webhook is a snapshot and never changes, and there is no "attachment ready" event. To get the file:

1. Take `attachment_id` from the `message.received` event or the message read.
2. Call `GET /v1/attachments/{attachment_id}`. While the file is still arriving, it returns `409` [`ATTACHMENT_NOT_READY`](/errors/attachment-not-ready).
3. Wait a few seconds and call again, with a growing delay. Give up after a few minutes and log the `attachment_id`.
4. Once the response has `status: ready`, read `filename`, `content_type`, `size_bytes` and `download_url`, then download the file.

Attachment reads count toward the attachment request limit; see [Rate limits](/rate-limits.md).

> [!WARNING]
>
> Only send your API key to `https://api.blujay.io`. Build download links from the `download_url` path yourself rather than following a full URL from a payload.

## Delete an attachment

`DELETE /v1/attachments/{attachment_id}` stops an uploaded attachment from being sent or downloaded again, effective immediately. Messages that already include it keep the reference in their history. Deleting an attachment you already deleted returns `204` again. An `attachment_id` that doesn't exist in your workspace returns `404` [`NOT_FOUND`](/errors/not-found).

```bash theme={null}
curl -X DELETE https://api.blujay.io/v1/attachments/att_abc123 \
  -H "Authorization: Bearer blu_your_api_key"
```

## Limits

Attachment endpoints have their own request limit, separate from other reads and writes. See [Rate limits](/rate-limits.md). If attachment storage is briefly unavailable, you get `503` [`ATTACHMENT_STORAGE_UNAVAILABLE`](/errors/attachment-storage-unavailable); retry shortly.

> [!WARNING]
>
> **Common mistakes**
>
> * Sending media before the contact replies returns `400` [`PRE_REPLY_CONTENT_BLOCKED`](/errors/pre-reply-content-blocked). Send text first.
> * `PUT` the raw bytes to `upload_url` with exactly the `upload_headers`. A `multipart/form-data` body or an extra `Authorization` header makes the upload fail its check.
> * `size_bytes` must be the exact byte count. A mismatch returns `422` [`ATTACHMENT_UPLOAD_INVALID`](/errors/attachment-upload-invalid).
> * A URL send returns `202` before the download. A bad URL shows up later as a `failed` message, not as a `4xx`.
> * `download_url` is a path. Prefix it with `https://api.blujay.io` and send your API key only there.

## Limitations

* No media until the contact replies (`400` [`PRE_REPLY_CONTENT_BLOCKED`](/errors/pre-reply-content-blocked)).
* Uploads up to 100 MiB; URL sends up to 10 MiB and only images, videos and vCards (`413` [`ATTACHMENT_TOO_LARGE`](/errors/attachment-too-large), `415` [`UNSUPPORTED_ATTACHMENT_TYPE`](/errors/unsupported-attachment-type)).
* Executables, scripts, HTML, SVG and XML are never accepted (`415` [`UNSUPPORTED_ATTACHMENT_TYPE`](/errors/unsupported-attachment-type)).
* RCS and SMS carry only images, videos and vCards; SMS/MMS up to 5 MiB per message. A message with a file the service can't carry fails as a whole.
* Voice memos aren't supported. Audio goes out as a file.
* There is no attachment list endpoint and no "attachment ready" event. Poll `GET /v1/attachments/{attachment_id}`.
* Deleting an attachment doesn't remove it from messages that already sent it.

## Next steps

> **[Contact cards](/messaging/contact-cards.md)**
>
> Send a saved vCard as a native contact card.

> **[Send messages](/messaging/send-messages.md)**
>
> Message parts and follow-ups.

> **[Track a message](/messaging/lifecycle.md)**
>
> Statuses and media failure codes.

> **[Receive messages](/messaging/receive-messages.md)**
>
> Handle inbound media as it arrives.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.