Skip to content
All documentation pages

Code snippets: HTML, JavaScript, Python, Java, PHP, Go, curl

Use the Minecraft skin render API from HTML, JavaScript, TypeScript, Python, Java (Bukkit, Paper), PHP, Go and curl, with ETag handling for cheap revalidation.

There is no official client library and you do not need one: the API is a URL. These snippets show the idiomatic way to build that URL and, where it matters, to read the image back with caching. All of them use Notch’s UUID 069a79f444e94726a5befca90e38aaf5.

HTML

Set width and height to the size you request so the layout is stable before the image arrives. Body renders are 0.625 wide per unit of height by default (size=256 gives 160×256).

html
<img src="https://skinrender.dev/render/069a79f444e94726a5befca90e38aaf5/body?size=256&pose=wave"
     width="160" height="256"
     alt="Notch waving" loading="lazy" decoding="async">

<!-- a face avatar; a multiple of 8 keeps every texture pixel the same width -->
<img src="https://skinrender.dev/render/069a79f444e94726a5befca90e38aaf5/face?size=64"
     width="64" height="64" alt="Notch" style="image-rendering: pixelated">

JavaScript / TypeScript

A small builder that drops empty values and encodes everything. It works in browsers, Node, Deno, Bun and Cloudflare Workers.

ts
type RenderType = "face" | "head" | "bust" | "body" | "front" | "back" | "skin" | "cape";
type Params = Record<string, string | number | boolean | null | undefined>;

export function skinUrl(id: string, type: RenderType = "body", params: Params = {}): string {
  // identifiers are [A-Za-z0-9_-] plus an optional "texture:" prefix; keep the colon unescaped
  const segment = encodeURIComponent(id).replace(/%3A/gi, ":");
  const url = new URL(`/render/${segment}/${type}`, "https://skinrender.dev");
  for (const [key, value] of Object.entries(params)) {
    if (value === undefined || value === null || value === "") continue;
    url.searchParams.set(key, String(value));
  }
  return url.toString();
}

skinUrl("069a79f444e94726a5befca90e38aaf5", "body", { pose: "wave", size: 256 });
// https://skinrender.dev/render/069a79f444e94726a5befca90e38aaf5/body?size=256&pose=wave

Fetching the bytes, with the render time from the response headers:

ts
const res = await fetch(skinUrl("853c80ef3c3749fdaa49938b674adae6", "head", { size: 128 }));
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
const png = new Uint8Array(await res.arrayBuffer());
console.log(res.headers.get("x-render-ms"), res.headers.get("x-cache"));

In React, Vue or Svelte the URL is simply the src; there is nothing to await.

Python

python
import requests

BASE = "https://skinrender.dev"

def skin_url(identifier: str, kind: str = "body", **params) -> str:
    query = {k: v for k, v in params.items() if v is not None}
    req = requests.Request("GET", f"{BASE}/render/{identifier}/{kind}", params=query)
    return req.prepare().url

def fetch_skin(identifier: str, kind: str = "body", **params) -> bytes:
    r = requests.get(f"{BASE}/render/{identifier}/{kind}", params=params, timeout=10)
    r.raise_for_status()          # 400 / 404 / 502 bodies are plain text
    return r.content

png = fetch_skin("069a79f444e94726a5befca90e38aaf5", "body", pose="wave", size=256)
with open("notch.png", "wb") as f:
    f.write(png)

With Pillow, Image.open(BytesIO(png)) gives you an RGBA image to composite onto your own graphics.

Java (Bukkit / Paper)

A URL builder with no dependencies beyond the JDK, plus reading the result into a BufferedImage. Do the network call off the main thread (an async scheduler task) so the server never blocks on it.

java
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.StringJoiner;
import java.util.UUID;
import javax.imageio.ImageIO;

public final class SkinRender {
    private static final String BASE = "https://skinrender.dev";
    private static final HttpClient HTTP = HttpClient.newHttpClient();

    public static String url(UUID uuid, String type, Map<String, String> params) {
        StringJoiner query = new StringJoiner("&", params.isEmpty() ? "" : "?", "");
        params.forEach((k, v) -> query.add(k + "=" + URLEncoder.encode(v, StandardCharsets.UTF_8)));
        return BASE + "/render/" + uuid.toString().replace("-", "") + "/" + type + query;
    }

    public static BufferedImage fetch(UUID uuid, String type, Map<String, String> params)
            throws IOException, InterruptedException {
        HttpRequest req = HttpRequest.newBuilder(URI.create(url(uuid, type, params))).GET().build();
        HttpResponse<byte[]> res = HTTP.send(req, HttpResponse.BodyHandlers.ofByteArray());
        if (res.statusCode() != 200) {
            throw new IOException(res.statusCode() + ": " + new String(res.body(), StandardCharsets.UTF_8));
        }
        return ImageIO.read(new java.io.ByteArrayInputStream(res.body()));
    }
}

// In a plugin:
// Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
//     BufferedImage img = SkinRender.fetch(player.getUniqueId(), "head", Map.of("size", "128"));
//     ... draw it on a map, upload it, etc.
// });

For an in-game map, MapPalette.imageToBytes takes a 128×128 BufferedImage, which is exactly head?size=128.

PHP

php
<?php
function skinUrl(string $id, string $type = 'body', array $params = []): string {
    $params = array_filter($params, fn($v) => $v !== null && $v !== '');
    $query = $params ? '?' . http_build_query($params) : '';
    return 'https://skinrender.dev/render/' . rawurlencode($id) . '/' . $type . $query;
}

echo '<img src="' . htmlspecialchars(skinUrl('069a79f444e94726a5befca90e38aaf5', 'head', ['size' => 96])) . '" '
   . 'width="96" height="96" alt="Notch" loading="lazy">';

// Fetch server-side and store the file:
$png = file_get_contents(skinUrl('069a79f444e94726a5befca90e38aaf5', 'body', ['pose' => 'wave', 'size' => 256]));
file_put_contents('notch.png', $png);

Go

go
package skinrender

import (
	"fmt"
	"io"
	"net/http"
	"net/url"
)

const base = "https://skinrender.dev"

func URL(id, kind string, params map[string]string) string {
	q := url.Values{}
	for k, v := range params {
		if v != "" {
			q.Set(k, v)
		}
	}
	u := fmt.Sprintf("%s/render/%s/%s", base, url.PathEscape(id), kind)
	if len(q) > 0 {
		u += "?" + q.Encode()
	}
	return u
}

func Fetch(id, kind string, params map[string]string) ([]byte, error) {
	res, err := http.Get(URL(id, kind, params))
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()
	body, err := io.ReadAll(res.Body)
	if err != nil {
		return nil, err
	}
	if res.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("skinrender: %d %s", res.StatusCode, body)
	}
	return body, nil
}

// png, err := skinrender.Fetch("069a79f444e94726a5befca90e38aaf5", "body", map[string]string{"pose": "wave", "size": "256"})

curl

bash
# save an image
curl -o notch.png "https://skinrender.dev/render/069a79f444e94726a5befca90e38aaf5/body?size=256&pose=wave"

# look at the headers only (render time, cache status, skin source, ETag)
curl -sI "https://skinrender.dev/render/853c80ef3c3749fdaa49938b674adae6/head?size=128"

# see the plain-text reason for a 400
curl -s "https://skinrender.dev/render/069a79f444e94726a5befca90e38aaf5/head?size=99999"

ETag and If-None-Match

Every response carries an ETag. If you keep a copy of an image, send the tag back and the API answers 304 Not Modified with no body when nothing changed, which is almost always: renders are deterministic and only change when the player changes skin.

bash
curl -sI "https://skinrender.dev/render/069a79f444e94726a5befca90e38aaf5/body?size=256&pose=wave" | grep -i etag
# etag: "…"

curl -s -o /dev/null -w "%{http_code}\n" \
  -H 'If-None-Match: "…paste the tag…"' "https://skinrender.dev/render/069a79f444e94726a5befca90e38aaf5/body?size=256&pose=wave"
# 304
ts
const cached = await store.get(url);   // { etag, bytes } or undefined
const res = await fetch(url, { headers: cached ? { "If-None-Match": cached.etag } : {} });
if (res.status === 304) return cached!.bytes;
const bytes = new Uint8Array(await res.arrayBuffer());
await store.set(url, { etag: res.headers.get("etag")!, bytes });
return bytes;

Browsers do this automatically for <img> tags. Details on every cache layer and header are on caching and limits.