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""
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.pngupload.jsonchart-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:

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_KEYin 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
- Add hosted images to Markdown and README files.
- Host images for HTML email.
- Resize or convert the hosted chart.
For an agent, follow the integration procedure. For human setup, open the start page. Read the service evaluation before you select a host.