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.widthsets 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 atviewport.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 () |
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  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
- HTML to image for the second route with your own templates.
- Emoji rendering, for emoji in your Markdown.
- Create a free account to render your own Markdown.