Render Markdown to an image

Send Markdown in the markdown field and the API returns it as an image, styled by one of three themes. Use it for release notes in a chat message, a README card, a changelog entry for social media or a summary in a report. This guide shows the request, compares the themes and gives a second route, Markdown converted to HTML by you, for when you need your own fonts and colors. More guides are on the guides hub.

The request

markdown replaces url in a POST body. Send exactly one of url, html or markdown.

curl

curl -sS --fail -X POST https://api.renderscreenshot.com/v1/screenshot \
  -H "Authorization: Bearer $RS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
      "markdown": "# Sprint 14 summary\n\nThree items shipped:\n\n- **Search** now indexes PDF files\n- *Export* supports CSV\n- Fixed the timezone bug in reminders\n\n> Next review: Friday.\n\n| Area | Status |\n|---|---|\n| API | Done |\n| Docs | In review |",
      "markdown_theme": "github",
      "viewport": {
        "width": 800,
        "height": 600
      }
    }' \
  -o release.png

Node.js

import { writeFile } from 'node:fs/promises';

const body = {
  "markdown": "# Sprint 14 summary\n\nThree items shipped:\n\n- **Search** now indexes PDF files\n- *Export* supports CSV\n- Fixed the timezone bug in reminders\n\n> Next review: Friday.\n\n| Area | Status |\n|---|---|\n| API | Done |\n| Docs | In review |",
  "markdown_theme": "github",
  "viewport": {
    "width": 800,
    "height": 600
  }
};

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(body),
});

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

await writeFile('release.png', Buffer.from(await res.arrayBuffer()));

Python

import json
import os
import sys
import urllib.error
import urllib.request

body = {
    'markdown': '# Sprint 14 summary\n\nThree items shipped:\n\n- **Search** now indexes PDF files\n- *Export* supports CSV\n- Fixed the timezone bug in reminders\n\n> Next review: Friday.\n\n| Area | Status |\n|---|---|\n| API | Done |\n| Docs | In review |',
    'markdown_theme': 'github',
    'viewport': {
        'width': 800,
        'height': 600,
    },
}

req = urllib.request.Request(
    'https://api.renderscreenshot.com/v1/screenshot',
    data=json.dumps(body).encode(),
    headers={
        'Authorization': f"Bearer {os.environ['RS_API_KEY']}",
        'Content-Type': 'application/json',
    },
    method='POST',
)

try:
    with urllib.request.urlopen(req, timeout=90) as res:
        with open('release.png', 'wb') as f:
            f.write(res.read())
except urllib.error.HTTPError as err:
    sys.exit(f'{err.code} {err.read().decode()}')

Ruby

require 'json'
require 'net/http'

body = {
  markdown: "# Sprint 14 summary\n\nThree items shipped:\n\n- **Search** now indexes PDF files\n- *Export* supports CSV\n- Fixed the timezone bug in reminders\n\n> Next review: Friday.\n\n| Area | Status |\n|---|---|\n| API | Done |\n| Docs | In review |",
  markdown_theme: 'github',
  viewport: {
    width: 800,
    height: 600
  }
}

uri = URI('https://api.renderscreenshot.com/v1/screenshot')
request = Net::HTTP::Post.new(uri)
request['Authorization'] = "Bearer #{ENV.fetch('RS_API_KEY')}"
request['Content-Type'] = 'application/json'
request.body = body.to_json

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', read_timeout: 90) do |http|
  http.request(request)
end
abort("#{response.code} #{response.body}") unless response.is_a?(Net::HTTPSuccess)

File.binwrite('release.png', response.body)

PHP

<?php

$body = [
    'markdown' => "# Sprint 14 summary\n\nThree items shipped:\n\n- **Search** now indexes PDF files\n- *Export* supports CSV\n- Fixed the timezone bug in reminders\n\n> Next review: Friday.\n\n| Area | Status |\n|---|---|\n| API | Done |\n| Docs | In review |",
    'markdown_theme' => 'github',
    'viewport' => [
        'width' => 800,
        'height' => 600,
    ],
];

$ch = curl_init('https://api.renderscreenshot.com/v1/screenshot');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . getenv('RS_API_KEY'),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 90,
]);

$image = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($image === false || $status !== 200) {
    fwrite(STDERR, $status . ' ' . ($image === false ? curl_error($ch) : $image) . "\n");
    exit(1);
}

file_put_contents('release.png', $image);

Go

package main

import (
    "bytes"
    "io"
    "log"
    "net/http"
    "os"
)

const body = `{
  "markdown": "# Sprint 14 summary\n\nThree items shipped:\n\n- **Search** now indexes PDF files\n- *Export* supports CSV\n- Fixed the timezone bug in reminders\n\n> Next review: Friday.\n\n| Area | Status |\n|---|---|\n| API | Done |\n| Docs | In review |",
  "markdown_theme": "github",
  "viewport": {
    "width": 800,
    "height": 600
  }
}`

func main() {
    req, err := http.NewRequest(http.MethodPost, "https://api.renderscreenshot.com/v1/screenshot", bytes.NewBufferString(body))
    if err != nil {
        log.Fatal(err)
    }
    req.Header.Set("Authorization", "Bearer "+os.Getenv("RS_API_KEY"))
    req.Header.Set("Content-Type", "application/json")

    res, err := http.DefaultClient.Do(req)
    if err != nil {
        log.Fatal(err)
    }
    defer res.Body.Close()

    data, err := io.ReadAll(res.Body)
    if err != nil {
        log.Fatal(err)
    }
    if res.StatusCode != http.StatusOK {
        log.Fatalf("%d %s", res.StatusCode, data)
    }

    if err := os.WriteFile("release.png", data, 0o644); err != nil {
        log.Fatal(err)
    }
}

Java

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;

public class Capture {
    public static void main(String[] args) throws Exception {
        String body = """
            {
              "markdown": "# Sprint 14 summary\\n\\nThree items shipped:\\n\\n- **Search** now indexes PDF files\\n- *Export* supports CSV\\n- Fixed the timezone bug in reminders\\n\\n> Next review: Friday.\\n\\n| Area | Status |\\n|---|---|\\n| API | Done |\\n| Docs | In review |",
              "markdown_theme": "github",
              "viewport": {
                "width": 800,
                "height": 600
              }
            }
            """;

        HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.renderscreenshot.com/v1/screenshot"))
            .header("Authorization", "Bearer " + System.getenv("RS_API_KEY"))
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(body))
            .build();

        HttpResponse<byte[]> response = HttpClient.newHttpClient()
            .send(request, HttpResponse.BodyHandlers.ofByteArray());
        if (response.statusCode() != 200) {
            System.err.println(response.statusCode() + " " + new String(response.body()));
            System.exit(1);
        }

        Files.write(Path.of("release.png"), response.body());
    }
}

The Markdown is a normal JSON string, so line breaks are \n. Every example builds the body with the language's JSON encoder. Do not build the JSON by string concatenation: one quote or backslash in the Markdown breaks the request.

Themes

markdown_theme Look
light (default) Light background, dark text
github GitHub-flavored Markdown styling
dark Dark background, light text

The theme sets the page styles: 32 px padding, a system font stack and a content column of at most 980 px. The image size comes from the viewport, as with any capture. Any other theme name returns invalid_parameter.

Size the image to the content

A Markdown document has no fixed height. Two settings decide the output:

  • viewport.width sets the text width. 600 to 900 px reads well. Wider lines are hard to read in a chat preview.
  • capture.mode: "full_page" makes the image as tall as the content. Without it, the image is cut at viewport.height.

For a fixed-size card, such as 1200x630 for a social preview, keep the Markdown short enough to fit and do not use full_page.

What Markdown input supports

From the content input reference:

Feature Markdown input HTML input
Headings, lists, emphasis, tables, block quotes, strikethrough, footnotes Yes You write the HTML
Fenced code blocks Yes, monospaced, without syntax highlighting Yes, with your own highlighter
Your own fonts and colors No, choose a theme Yes
Images Yes, with absolute URLs in Markdown image syntax (![alt](https://...)) Yes, with absolute URLs
Raw HTML inside the Markdown No, it is stripped Yes
JavaScript No Yes

If you need syntax highlighting, your own fonts or raw HTML, use the second route.

Second route: convert to HTML yourself

Convert the Markdown to HTML in your code, wrap it in your own CSS and send it as html. You control fonts, colors, spacing and images. Tested on 2026-10-15 with Node.js 22.18 and [email protected] against a local test server.

npm install [email protected]
// Convert Markdown to HTML locally, wrap it in your own CSS, and render it as an image.
// Usage: node render-md.mjs notes.md notes.png
import { readFile, writeFile } from 'node:fs/promises';
import { marked } from 'marked';

const [input = 'notes.md', output = 'notes.png'] = process.argv.slice(2);
const markdown = await readFile(input, 'utf8');

const html = `<!doctype html><html><head><meta charset="utf-8"><style>
body { margin: 0; padding: 40px 48px; background: #fff; color: #1f2328;
       font: 16px/1.6 system-ui, sans-serif; max-width: 704px; }
h1 { font-size: 28px; border-bottom: 1px solid #d1d9e0; padding-bottom: 8px; }
blockquote { margin: 0; padding: 0 16px; color: #59636e; border-left: 4px solid #d1d9e0; }
table { border-collapse: collapse; } th, td { border: 1px solid #d1d9e0; padding: 6px 12px; }
</style></head><body>${marked.parse(markdown)}</body></html>`;

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({ html, viewport: { width: 800, height: 600 }, capture: { mode: 'full_page' } }),
});

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

await writeFile(output, Buffer.from(await res.arrayBuffer()));

The max-width of 704 px plus 96 px of side padding fills the 800 px viewport exactly.

Caution: marked passes raw HTML in the Markdown through to the output. Use this route for Markdown you wrote. For Markdown from users, sanitize the HTML before you send it, or use the markdown field, which does not run scripts.

Which route to use

You need Use
A quick image of notes or a table, one of three looks markdown + markdown_theme
Your brand fonts and colors Convert to HTML
Images inside the document Convert to HTML
Untrusted Markdown from users markdown

Troubleshooting

Symptom Cause Fix
invalid_parameter, only one input allowed The body has url or html as well as markdown Send one input field.
Text cut off at the bottom Fixed viewport height Add capture.mode: "full_page".
Literal \n in the image The newlines were escaped twice Build the body with a JSON encoder from a normal string.
Images in the Markdown are missing A relative image path, or an image written as raw <img> HTML (stripped) Use ![alt](https://...) with an absolute URL.
invalid_parameter on markdown_theme A theme name other than light, github or dark Use one of the three themes.
Long lines, small text in previews Viewport too wide Use a width of 600 to 900 px.

Related in this topic

Was this page helpful?