Markdown to Image API: Render Markdown as PNG

Send Markdown, get a PNG, JPEG, WebP or PDF back. Three themes (light, github, dark), raw HTML stripped, full-page sizing and a public CDN link per image.

By Simon Chiu · Updated

RenderScreenshot turns Markdown into an image with one POST request. Put the text in the markdown field, pick a theme, and the API returns a PNG (or JPEG, WebP or PDF) of the formatted document. You do not run a Markdown parser, a stylesheet or a browser yourself.

curl -sS --fail-with-body -X POST https://api.renderscreenshot.com/v1/screenshot \
  -H "Authorization: Bearer $RS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "markdown": "## Deploy finished\n\n| Service | Version |\n|---|---|\n| api | 2.4.0 |\n| web | 2.4.1 |\n\nRollback: `make rollback`",
    "markdown_theme": "github",
    "viewport": { "width": 640, "height": 300, "scale": 2 },
    "capture": { "mode": "full_page" }
  }' \
  -o deploy.png

Use it for release notes posted to Slack or Discord, README cards for a project gallery, changelog entries on social media, AI chat answers saved as pictures, and report sections that must look the same in every email client. The same key also captures live URLs and your own HTML.

Why send Markdown and not HTML

Markdown input exists for text you do not want to design. With HTML input you own the template: fonts, colors, escaping, every edge case. With Markdown you pick one of three looks and send the text as it is. Two consequences matter in practice:

  • No template code to maintain. A changelog file, a GitHub issue body or an LLM answer already is Markdown. You pass it through unchanged.
  • Untrusted text is safe by default. Raw HTML inside the Markdown is stripped before the page is built, and Markdown input never runs scripts. A user who types <img src=x onerror=...> into a comment gets plain text in the image, not code in the renderer. With HTML input you must sanitize that yourself.

Three themes, three typical jobs

markdown_theme takes light (the default), github or dark. Each theme sets the colors; the layout is the same for all three: 32 px of padding, a system font stack and a content column of at most 980 px. The table pairs each theme with the job it suits.

Theme Good for
github README cards, project galleries, docs snippets
dark Release notes in chat, posts for dark-mode feeds
light Report sections, email images, printable PDFs

A request body for each job, ready to copy:

{"markdown": "# acme-cli\n\nDeploy from your terminal.\n\n```\nnpm i -g acme-cli\n```", "markdown_theme": "github", "viewport": {"width": 800, "height": 420}}

{"markdown": "## 2.4.0\n\n- Added `--dry-run`\n- Fixed token refresh", "markdown_theme": "dark", "viewport": {"width": 720, "height": 400, "scale": 2}, "capture": {"mode": "full_page"}}

{"markdown": "### Weekly KPIs\n\n| Metric | Value |\n|---|---|\n| Signups | 412 |", "markdown_theme": "light", "output": {"format": "pdf"}}

The theme name is checked: anything other than the three values returns invalid_parameter. In a deploy script that is what you want. A typo such as "Dark" fails the job on the first run, instead of quietly posting light images that nobody asked for.

How a Markdown request flows

flowchart LR
  A[Your app] -->|POST markdown + markdown_theme| B[RenderScreenshot API]
  B -->|validate, strip raw HTML,<br/>apply theme CSS| C{cache.available and a<br/>render with the same options?}
  C -->|yes, 0 credits| E[Stored image]
  C -->|no| D[Headless Chrome<br/>on Cloudflare]
  D --> F[R2 storage + CDN]
  F --> E
  E -->|image bytes + X-Cache-URL| A

The API turns your Markdown into a themed HTML page before the browser sees it, so the browser only ever loads a static document. From there the request behaves like any other capture: the viewport sets the canvas, capture.mode decides the height, and the result is stored so that identical requests can reuse it (caching).

Every image response carries an X-Cache-URL header, a CDN link to the same image that needs no key. That link matters for Markdown in particular. markdown is a POST-only field, and signed URLs only work for GET and HEAD, so you cannot put a Markdown render straight into an <img> or og:image tag. Render once on your server, store the X-Cache-URL, and serve that until X-Cache-Expires. The response headers reference lists both.

Integration: one release note, two languages

This script takes the newest section of a CHANGELOG.md and posts it as a dark image. No SDK builder has a Markdown entry point today, so it sends the JSON body directly; the raw HTTP guide covers the headers and error format.

// Node 20+: render the newest CHANGELOG section as a dark PNG.
import { readFile, writeFile } from 'node:fs/promises';

const markdown = await readFile('CHANGELOG.md', 'utf8');
const section = markdown.split(/^## /m)[1]; // the newest release
if (!section) throw new Error('no "## " release heading in CHANGELOG.md');

const res = await fetch('https://api.renderscreenshot.com/v1/screenshot', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.RS_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    markdown: `## ${section}`,
    markdown_theme: 'dark',
    viewport: { width: 720, height: 400, scale: 2 },
    capture: { mode: 'full_page' },
  }),
  signal: AbortSignal.timeout(60_000),
});

if (!res.ok) {
  console.error(res.status, await res.text());
  process.exit(1);
}

await writeFile('release.png', Buffer.from(await res.arrayBuffer()));
console.log('Public link:', res.headers.get('x-cache-url'));
# Python 3.9+: pip install requests
import os
import sys

import requests

with open('CHANGELOG.md', encoding='utf-8') as f:
    parts = f.read().split('\n## ')
if len(parts) < 2:
    sys.exit('no "## " release heading in CHANGELOG.md')

res = requests.post(
    'https://api.renderscreenshot.com/v1/screenshot',
    headers={'Authorization': f"Bearer {os.environ['RS_API_KEY']}"},
    json={
        'markdown': '## ' + parts[1],
        'markdown_theme': 'dark',
        'viewport': {'width': 720, 'height': 400, 'scale': 2},
        'capture': {'mode': 'full_page'},
    },
    timeout=60,
)
if res.status_code != 200:
    sys.exit(f'{res.status_code} {res.text}')

with open('release.png', 'wb') as f:
    f.write(res.content)
print('Public link:', res.headers.get('X-Cache-URL'))

Both scripts were checked on 2026-10-18 (Node 22.18, Python 3.12 with requests 2.34) against a local stand-in server that validates the body, including the error path for a bad theme name. Ruby, PHP, Go and Java versions of a Markdown request are in the Markdown rendering guide.

Settings that decide how the image looks

  • Width sets the line length. viewport.width is the text column width plus padding. For chat previews and social posts, 600 to 800 px keeps lines short enough to read on a phone.
  • capture.mode: "full_page" for documents, a fixed height for cards. A changelog has no fixed length, so let the image grow with the content. For a 1200x630 social card, keep the text short and leave full_page out; the image is cut at viewport.height. Details in capture options.
  • viewport.scale: 2 for screens with dense pixels. Text at scale 1 looks soft on most phones. Scale 2 doubles the pixel size of the file; the layout does not change.
  • WebP for chat, PNG for archives, PDF for print. Set output.format (output options).
  • Break long code lines. Fenced blocks do not wrap. A line wider than the column is cut at the edge of the code box, and an image cannot scroll, so wrap commands with \ or raise the width.
  • Use absolute image URLs. ![alt](https://...) works; a relative path has nothing to resolve against.

Pricing

A Markdown render uses one credit, like any other capture, and failed renders are free (credits and billing). The Free plan includes 150 credits every month with no card. A team that posts 30 release notes and 400 daily-report images a month needs 430 credits, which fits in Starter (2,500 credits for $10). That is $0.0233 per image at that volume, and the unused credits leave room to grow. All plans are on the pricing page.

When Markdown input is the wrong choice

  • You need your brand. Custom fonts, colors, logos or syntax highlighting need HTML input. Convert the Markdown yourself and send the result to the HTML to image API; the Markdown to image tutorial shows that route next to a self-hosted one, with timings.
  • The source is a web page, not Markdown. Capture the URL directly. If you need the page's text as Markdown for an LLM, that is a different job, covered in convert a website to Markdown.
  • You render a handful of images inside an app that already runs Chrome. A local Puppeteer call is enough, and the tutorial above has a tested script.

For everything else, create a free account, copy the curl command at the top of this page and replace the Markdown with your own.

Frequently asked questions

Which Markdown themes does the API support?
Three: light (the default), github and dark. Set markdown_theme in the POST body. Any other value returns invalid_parameter, so a typo fails loudly instead of falling back to a default look.
Is it safe to render Markdown that my users wrote?
Markdown input is the safer of the two text inputs. Raw HTML inside the Markdown is stripped, so a script tag or an onerror attribute never reaches the browser, and Markdown input never runs JavaScript.
Can I put a Markdown render in an og:image tag with a signed URL?
Not directly. markdown and markdown_theme are POST options, and signed URLs work only for GET and HEAD. Render once with POST, then use the X-Cache-URL from the response as the public image link until it expires.
Does the Markdown output have syntax highlighting?
No. Fenced code blocks render in a monospaced font without colors. If you need highlighted code, your own fonts or raw HTML, convert the Markdown to HTML in your code and send it in the html field.
How many credits does one Markdown image cost?
One credit per successful render, the same as a URL or HTML capture. Failed renders are not charged, and a repeat request can reuse an earlier render for 0 credits when you send cache.available.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.