> For the complete documentation index, see [llms.txt](https://docs.monolithforensics.com/monolith/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.monolithforensics.com/monolith/monolith-api/notes-api/note-images.md).

# Note Images

Notes can embed images. Images are stored in the case's file storage under `Cases/{case}/Documents/Note Images/`, tracked as note attachments (with MD5/SHA-1/SHA-256 hashes), and served back through short-lived signed URLs whenever a note is read.

{% hint style="info" %}
**Constraints (all upload paths):**

* Allowed types: `image/png`, `image/jpeg`, `image/gif`, `image/webp`
* Maximum size: **25 MB**
  {% endhint %}

There are two ways images get into a note:

1. **Automatic ingestion** — embed an external URL or a base64 data URI directly in the note content you write; the server converts it into an attachment for you. See [External and inline images in note content](#external-and-inline-images-in-note-content-automatic-ingestion).
2. **Explicit upload endpoints**, from highest- to lowest-level:

| Endpoint                       | Use when                                                                                      |
| ------------------------------ | --------------------------------------------------------------------------------------------- |
| `POST /v1/notes/{uuid}/images` | You have the image bytes locally. **Recommended for API clients** — one call does everything. |

***

## External and inline images in note content (automatic ingestion)

You don't have to call an upload endpoint to get an image into a note. Whenever note content is written — `POST /v1/notes`, `PATCH /v1/notes/{uuid}`, or `PATCH /v1/notes/{uuid}/append` — the server scans the incoming `note_data` for images it doesn't already track and converts them into normal note attachments:

* `<img src="https://...">` — downloaded server-side, with the same protections as `download-image-from-url` (public hosts only, image content types only, 25 MB cap)
* `<img src="data:image/png;base64,...">` — decoded and stored (base64 data URIs only)

Each ingested tag is rewritten in place to standard attachment markup (`src=""` + `data-uuid` + `alt`), and from then on behaves exactly like an uploaded image: stored under the note's case, hashed, and served through a fresh signed URL every time the note is read.

**Why this happens:** stored notes and their version history should only reference content the system controls. An external URL can change or vanish after the note is written — silently altering what a historical note appears to say — and every read of a hotlinked image tells the third-party host who is viewing the note, and when. Ingestion pins the image bytes at the moment of writing.

Details worth knowing:

* **Idempotent** — images already carrying a `data-uuid` are left untouched, so re-sending content or appending to a note never re-downloads anything.
* **Deduplicated** — the same `src` appearing multiple times in one write is stored once, with every tag pointing at the same attachment.
* **Bounded** — at most **10 images per write** are ingested, within an overall time budget of \~30 seconds. Images beyond the cap are reported (see below), not silently dropped.
* **Reads never fetch** — ingestion only runs when content is written.
* Unresolved external images render as broken/blank in PDF and DOCX exports; the exporters never fetch remote URLs.

### When ingestion fails: `unresolved_images`

A failed image **never fails the write**. The original tag stays in the content exactly as sent, and the response gains an `unresolved_images` array describing each image that was left behind (the field is absent when everything ingested cleanly):

```json
{
  "data": { "...": "the created/updated note" },
  "unresolved_images": [
    {
      "src": "https://example.com/photos/device-front.jpg",
      "reason": "Image request failed with status 404"
    }
  ]
}
```

Common reasons: the host was unreachable or returned an error, the URL resolves to a blocked (internal) address, the content type is not an allowed image type, the image exceeds 25 MB, or the write contained more than 10 new images. `data:` URI srcs are truncated in the report to keep responses small.

An unresolved external image is retried the next time the note's full body is rewritten via `PATCH` (its tag still has no `data-uuid`, so it is a candidate again). Appends only scan the appended fragment, not the existing body.

### Example — create a note with an external image

```python
create = session.post(f"{BASE_URL}/notes", json={
    "case_uuid": case_uuid,
    "note_tag": "OSINT findings",
    "note_data": (
        "<p>Profile photo published by the account:</p>"
        '<img src="https://example.com/profiles/photo.jpg" />'
    ),
})
create.raise_for_status()
note = create.json()

# Always check for images the server could not ingest
for image in note.get("unresolved_images", []):
    print(f"NOT ingested: {image['src']} — {image['reason']}")
```

### Example — append a locally generated image as a data URI

Useful for automation that produces charts or screenshots: no separate upload call, and the server extracts the base64 payload into an attachment instead of bloating the stored note.

```python
import base64

with open("timeline-chart.png", "rb") as f:
    b64 = base64.b64encode(f.read()).decode()

result = session.patch(f"{BASE_URL}/notes/{note_uuid}/append", json={
    "note_data": f'<p>Reconstructed timeline:</p><img src="data:image/png;base64,{b64}" />',
})
result.raise_for_status()
```

### Example — fall back to a direct upload when ingestion fails

If the server can't reach a source (for example, it requires credentials the server doesn't have), fetch the bytes yourself and use the upload endpoint, embedding the returned markup:

```python
import requests

result = session.patch(f"{BASE_URL}/notes/{note_uuid}/append", json={
    "note_data": html_with_images,
})
result.raise_for_status()

for failed in result.json().get("unresolved_images", []):
    if not failed["src"].startswith("http"):
        continue

    # Fetch with our own credentials/network access...
    img = requests.get(failed["src"], timeout=30)
    img.raise_for_status()

    # ...store it as a note attachment...
    upload = session.post(
        f"{BASE_URL}/notes/{note_uuid}/images",
        files={"file": ("image", img.content,
                        img.headers.get("Content-Type", "image/png"))},
    )
    upload.raise_for_status()

    # ...and embed the attachment markup. (The original tag with the
    # unreachable src is still in the note body; replace it with a full
    # PATCH of note_data if you want it gone.)
    session.patch(f"{BASE_URL}/notes/{note_uuid}/append", json={
        "note_data": upload.json()["markup"],
    }).raise_for_status()
```

***

## Upload an image to a note (recommended)

Uploads the image, hashes and stores it, records the attachment, and returns ready-to-embed `<img>` markup — one multipart request.

```http
POST /v1/notes/{uuid}/images
Content-Type: multipart/form-data
```

### Request

| Part   | Type | Required | Description                                                                |
| ------ | ---- | -------- | -------------------------------------------------------------------------- |
| `file` | file | **yes**  | The image. The part's content type must be one of the allowed image types. |

The note (path parameter `uuid`) must be linked to a case — the image is stored under that case.

### Response — `201 Created`

```json
{
  "success": true,
  "uuid": "mhvXdrZT4jP5T8vBxuvm75",
  "filename": "mhvXdrZT4jP5T8vBxuvm75.png",
  "size": 482113,
  "url": "https://storage.example.com/Cases/2026-0042/Documents/Note%20Images/...&X-Amz-Signature=...",
  "markup": "<img class=\"monolith-image\" data-uuid=\"mhvXdrZT4jP5T8vBxuvm75\" alt=\"mhvXdrZT4jP5T8vBxuvm75.png\" src=\"\" />"
}
```

* `url` — signed download URL for immediate use (expires).
* `markup` — the `<img>` tag to place in the note body. Its `src` is intentionally empty: whenever the note is read with `include_content=true`, the server rewrites `src` to a fresh signed URL, matching the attachment by `data-uuid`. Embed the markup as-is; don't bake the signed `url` into the note.

### Example — upload an image and embed it in the note

```python
note_uuid = "0f8fad5b-d9cb-469f-a165-70867728950e"

with open("scene-photo.png", "rb") as f:
    upload = session.post(
        f"{BASE_URL}/notes/{note_uuid}/images",
        files={"file": ("scene-photo.png", f, "image/png")},
    )
upload.raise_for_status()
image = upload.json()

# Embed the returned markup by appending it to the note body
session.patch(f"{BASE_URL}/notes/{note_uuid}/append", json={
    "note_data": f"<p>Scene photo:</p>{image['markup']}",
}).raise_for_status()
```

***

## Errors

{% hint style="info" %}
Automatic ingestion failures are **not** HTTP errors: the note write still succeeds (`200`/`201`) and each failed image is reported in the response's `unresolved_images` array instead. The statuses below apply to the explicit upload endpoints.
{% endhint %}

| Status | Body                                                                                                         | Cause                                                                                               |
| ------ | ------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------- |
| `400`  | `{"message": "Unsupported image type"}`                                                                      | Content type not in the PNG/JPEG/GIF/WebP allowlist.                                                |
| `400`  | `{"message": "Image exceeds maximum allowed size"}`                                                          | Larger than 25 MB.                                                                                  |
| `400`  | `{"message": "Image file is empty"}`                                                                         | Zero-byte upload.                                                                                   |
| `400`  | `{"message": "Invalid multipart upload"}`                                                                    | Malformed multipart body on `/{uuid}/images`.                                                       |
| `400`  | `{"message": "Note is not linked to a case"}`                                                                | Upload target note has no case, so there is no storage location.                                    |
| `400`  | `{"message": "Case storage not provisioned"}`                                                                | The case has no storage path yet.                                                                   |
| `400`  | `{"message": "filename must not contain path separators", ...}`                                              | `filename` contains `/`, `\`, or `..`.                                                              |
| `400`  | Fetch-specific message                                                                                       | `download-image-from-url`: unreachable URL, non-image content, blocked internal address, too large. |
| `404`  | `{"message": "Note not found"}` / `{"message": "Note not found for case"}` / `{"message": "Case not found"}` | Bad `note_uuid` / `case_uuid`, or the note is not in the given case.                                |
| `401`  | `Unauthorized`                                                                                               | Missing or invalid API key.                                                                         |
