Create a fixed Matplotlib PNG, close the figure, then upload the file as the multipart file field. The Python script verifies the public URL and prints Markdown and HTML with useful alt text.

imgd.dev charges a one-time $1 per GB of storage and has no free tier. Fund the account before the first upload. Every imgd.dev image is public. Do not put confidential metrics, customer data, or internal financial data in the chart.

What makes the chart deterministic

The example uses fixed month labels, fixed request values, fixed colors, fixed ticks, and a fixed 1200 by 675 output size. It uses no random input.

Font rendering can differ across operating systems. The chart content and layout inputs remain fixed.

The save operation finishes before the upload code opens the PNG for reading. plt.close also releases the Matplotlib figure in every path.

Install the example

Save these dependencies in examples/workflows/python-chart/requirements.txt:

matplotlib==3.11.1
requests==2.34.2

Create a local environment and install them:

cd examples/workflows/python-chart
python3 -m venv .venv
. .venv/bin/activate
python -m pip install -r requirements.txt

Complete chart and upload script

Save this file as examples/workflows/python-chart/chart_to_url.py:

#!/usr/bin/env python3

from __future__ import annotations

import html
import json
import os
import re
import sys
from pathlib import Path
from typing import Any
from urllib.parse import urlparse

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
import requests

UPLOAD_URL = "https://imgd.dev/v1/upload"
ALT_TEXT = (
    "Line chart of monthly report requests from January through June, "
    "rising from 120 to 260."
)


class UploadFailure(RuntimeError):
    pass


def required_env(name: str) -> str:
    value = os.environ.get(name)
    if not value:
        raise UploadFailure(f"Set the {name} environment variable.")
    return value


def create_chart(path: Path) -> None:
    months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"]
    report_requests = [120, 148, 171, 205, 232, 260]

    path.parent.mkdir(parents=True, exist_ok=True)
    plt.rcParams.update({"font.family": "DejaVu Sans", "font.size": 11})
    figure, axis = plt.subplots(figsize=(8, 4.5), dpi=150)
    try:
        axis.plot(
            months,
            report_requests,
            color="#2563eb",
            marker="o",
            linewidth=2.5,
        )
        axis.set_title("Monthly report requests")
        axis.set_xlabel("Month")
        axis.set_ylabel("Requests")
        axis.set_ylim(0, 300)
        axis.set_yticks([0, 50, 100, 150, 200, 250, 300])
        axis.grid(axis="y", color="#d1d5db", linewidth=0.8)
        figure.tight_layout()
        figure.savefig(
            path,
            format="png",
            dpi=150,
            metadata={"Software": "imgd.dev Python chart example"},
        )
    finally:
        plt.close(figure)

    if not path.is_file() or path.stat().st_size == 0:
        raise UploadFailure("The chart file was not created.")


def parse_payload(response: requests.Response) -> dict[str, Any]:
    try:
        payload = response.json()
    except ValueError as error:
        raise UploadFailure(f"imgd.dev returned invalid JSON: {error}") from error

    if not isinstance(payload, dict):
        raise UploadFailure("The imgd.dev response must be a JSON object.")
    return payload


def upload_chart(path: Path, key: str) -> dict[str, Any]:
    with path.open("rb") as image_file:
        files = {"file": (path.name, image_file, "image/png")}
        with requests.post(
            UPLOAD_URL,
            headers={"Authorization": f"Bearer {key}"},
            files=files,
            timeout=(10, 60),
        ) as response:
            payload = parse_payload(response)
            if response.status_code not in (200, 202):
                code = payload.get("error", "unknown_error")
                fix = payload.get("fix", "Read the response body.")
                raise UploadFailure(
                    f"Upload failed with HTTP {response.status_code} and {code}: {fix}"
                )

    image_hash = payload.get("hash")
    public_url = payload.get("url")
    if not isinstance(image_hash, str) or re.fullmatch(r"[0-9a-f]{64}", image_hash) is None:
        raise UploadFailure("The success response has no valid image hash.")
    if not isinstance(public_url, str):
        raise UploadFailure("The success response has no public URL.")

    parsed = urlparse(public_url)
    if (
        parsed.scheme != "https"
        or parsed.netloc != "i.imgd.dev"
        or parsed.path != f"/i/{image_hash}"
        or parsed.params
        or parsed.query
        or parsed.fragment
    ):
        raise UploadFailure("The success response has an unexpected public URL.")

    return payload


def verify_public_url(url: str) -> None:
    with requests.get(
        url,
        headers={"Accept": "image/*"},
        timeout=(10, 60),
        stream=True,
    ) as response:
        status_code = response.status_code
        content_type = response.headers.get("Content-Type", "")
        first_chunk = next(response.iter_content(chunk_size=16), b"")
        if not response.ok or not content_type.startswith("image/") or not first_chunk:
            raise UploadFailure(
                "Public URL verification failed with "
                f"HTTP {response.status_code} and Content-Type {content_type}."
            )

    print(
        f"Verified {url} with HTTP {status_code} and Content-Type {content_type}.",
        file=sys.stderr,
    )


def embed_snippets(payload: dict[str, Any]) -> tuple[str, str]:
    url = str(payload["url"])
    width = payload.get("width")
    height = payload.get("height")
    if not isinstance(width, int) or not isinstance(height, int):
        raise UploadFailure("The success response has no numeric image dimensions.")

    markdown = f"![{ALT_TEXT}]({url})"
    html_snippet = (
        f'<img src="{html.escape(url, quote=True)}" '
        f'width="{width}" height="{height}" '
        f'alt="{html.escape(ALT_TEXT, quote=True)}">'
    )
    return markdown, html_snippet


def main() -> None:
    key = required_env("IMGD_KEY")
    chart_path = Path(os.environ.get("CHART_PATH", "monthly-report-requests.png")).resolve()
    if chart_path.suffix.lower() != ".png":
        raise UploadFailure("Use a .png CHART_PATH.")

    create_chart(chart_path)
    payload = upload_chart(chart_path, key)
    verify_public_url(str(payload["url"]))
    markdown, html_snippet = embed_snippets(payload)
    upload_json_path = Path(os.environ.get("UPLOAD_JSON_PATH", "upload.json")).resolve()
    serialized_payload = json.dumps(payload, indent=2)
    upload_json_path.write_text(serialized_payload + "\n", encoding="utf-8")

    print(serialized_payload)
    print("\nMarkdown:\n")
    print(markdown)
    print("\nHTML:\n")
    print(html_snippet)


if __name__ == "__main__":
    try:
        main()
    except (UploadFailure, OSError, requests.RequestException) as error:
        print(str(error), file=sys.stderr)
        raise SystemExit(1) from error

The file and both HTTP responses use context managers. They close after success, an API error, a network error, or a parse error.

Run the chart workflow

Keep IMGD_KEY outside the repository. Do not put it in the chart, report, source code, or logs.

cd examples/workflows/python-chart
. .venv/bin/activate
test -n "$IMGD_KEY"
python chart_to_url.py | tee chart-output.txt

The script creates these local files:

  • monthly-report-requests.png
  • upload.json
  • chart-output.txt

A new upload returns HTTP 202. upload.json contains this complete shape:

{
  "hash": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "url": "https://i.imgd.dev/i/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "status": "processing",
  "mime": "image/png",
  "bytes": 73421,
  "width": 1200,
  "height": 675,
  "alt_text": null,
  "unpublish_at": null,
  "note": "the url works immediately, serving a blurred placeholder until the moderation check finishes"
}

An identical chart can return HTTP 200 with "deduplicated": true.

The URL works immediately and can show a blurred image during moderation. The same URL shows the chart after a live result.

Expected report snippets

The script prints Markdown with useful alt text:

![Line chart of monthly report requests from January through June, rising from 120 to 260.](https://i.imgd.dev/i/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef)

It also prints HTML with the returned dimensions:

<img src="https://i.imgd.dev/i/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" width="1200" height="675" alt="Line chart of monthly report requests from January through June, rising from 120 to 260.">

Copy one snippet into the report source. Keep essential conclusions in report text, not only inside the chart.

Verify the metadata

The script checks that the public URL returns a non-empty image response. Use the owner endpoint for the final state:

HASH="$(jq -r '.hash' upload.json)"
curl --fail --silent --show-error \
  -H "Authorization: Bearer $IMGD_KEY" \
  "https://imgd.dev/v1/images/$HASH" | jq .

A live response can contain this JSON:

{
  "hash": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "url": "https://i.imgd.dev/i/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "status": "live",
  "mime": "image/png",
  "bytes": 73421,
  "width": 1200,
  "height": 675,
  "alt_text": "A line chart that shows monthly report requests rising from 120 in January to 260 in June.",
  "category": "safe",
  "nsfw": false,
  "violence": false,
  "filename": "monthly-report-requests.png",
  "unpublish_at": null,
  "published": true,
  "created_at": 1787184000
}

A review state is terminal and stays blurred. A blocked state removes the bytes. Remove a blocked chart from the report.

Recover from upload errors

The script includes the API fix value in its error message.

Status Meaning Recovery
401 The key is missing or invalid. Set the correct IMGD_KEY, then retry.
402 The account has no storage. Buy storage and verify funding. There is no free tier.
402 The chart exceeds the quota. Add the suggested storage or delete unused images.
413 The PNG exceeds 20 MB. Lower the DPI, reduce the figure size, or compress the chart.
415 The body is not an accepted PNG. Keep the PNG output and image/png multipart type together.
429 The account exceeded the upload rate. Wait for the next minute, then retry.

A representative 401 response has this base shape:

{
  "error": "invalid_api_key",
  "fix": "this key is not recognized; create a new account with POST /v1/accounts"
}

Do not create a replacement account in an automated report job. Restore the approved key through your secret process.

Security notes

  • Store IMGD_KEY in an environment variable or a secret manager.
  • Do not put the key in the report source, image URL, or exception log.
  • Treat the chart and URL as public.
  • Aggregate or remove sensitive data before chart creation.
  • Use useful alt text that states the chart's key relation.
  • Remove local output files when local retention is not required.
  • Delete unused hosted images when you need to free quota.

When imgd.dev fits

imgd.dev fits public reports that need a stable chart URL and simple embed markup. The same chart bytes also return the same content hash.

It does not fit confidential reports, row-level customer charts, a free upload, video, or files above 20 MB. Use access-controlled report storage for private data.

Next guides

For an agent, follow the integration procedure. For human setup, open the start page. Read the service evaluation before you select a host.

Sources