Origin check
Two unauthenticated endpoints that answer one question: did this file come out of ResourcePack AI?
They exist for marketplaces. If you require AI-generated work to be labelled, or don't accept it at all, you can't reliably tell by looking. We don't support reselling generated packs, and these endpoints let you check for yourself. No key or account is needed.
What an answer means#
The two verdicts are not symmetrical. Don't treat them as if they were.
generated is definitive. The content in front of you was produced here.
There is no heuristic and no confidence score: the hash is either in the
registry or it isn't.
no_record means only "not in our registry". It is not evidence that a
human made the file. It could be somebody's own work, another generator's
output, or ours after an edit.
Why there are no false positives#
A generated verdict is safe to act on because of three rules.
Only our own output is registered. A pack holds work we generated and work its owner brought in, and the two are tracked separately. Imported artwork is never fingerprinted, so a creator who edits their own texture in Studio can't have it called ours.
Generic content is left out. A blank tile, a square of one colour or a model with no geometry of its own is something thousands of people have made independently. Anything below a minimum of distinctness is skipped, so the registry can't hold a hash that belongs to somebody else's work.
Content is registered on the way out. It is fingerprinted when it leaves Studio (exported, pushed, published or downloaded), not when it is generated, so the registry records what could actually have reached you.
Where there is doubt, nothing is registered and the answer is no_record.
Computing the hash#
SHA-256 over the file's canonical content, not its bytes. Re-saving a PNG, stripping metadata, reordering JSON keys or pretty-printing all leave the answer unchanged. Only editing the actual content changes it.
Three forms, chosen by file extension.
.png#
Decode to 8-bit RGBA. Set every fully transparent pixel to 00 00 00 00
first (encoders disagree about what goes in the colour channels under alpha
0). Then hash:
"rpai/v1/png\n" + "<width>x<height>\n" + <RGBA bytes, row-major, 4 per pixel>The dimensions are part of the input, so a 16×32 and a 32×16 texture with the same pixels hash differently.
.json#
Parse it, then serialise canonically: object keys sorted, array order kept
(in a block model, element order is the geometry), numbers rounded to six
decimal places with -0 written as 0, and no whitespace anywhere. Then hash:
"rpai/v1/json\n" + <that string, UTF-8>The rounding absorbs float noise: some editors write 0.3 back out as
0.30000000000000004.
Everything else#
Sounds and any other file are hashed as stored:
"rpai/v1/bytes\n" + <the file's bytes>So a re-encoded .ogg is a different hash and reads as no_record.
The prefixes are hashed, not labels
rpai/v1/png and the others are part of the input to SHA-256, newline
included. Getting one wrong means every lookup silently misses. The format
is frozen: a change would appear as rpai/v2/…, never as an edit to these.
Try it without writing anything#
Both endpoint pages have a Try it button that takes a file: drop in a pack
.zip, a .mcpack, or loose .png / .json / .ogg files, and it hashes
them and fills in the request.
The hashing happens in your browser. The pack never leaves your machine;
only the 64-character hashes are sent. That hasher is tested against ours on
the same files, so its digests are what the registry stores. Hash something
you generated with us and confirm it comes back generated before writing any
code.
Reference implementations#
The same rules in three languages. Each turns a file into the 64 hexadecimal characters the endpoints take. Check yours against Try it on a file you generated with us before you trust it.
// Needs a PNG decoder; everything else is the standard library.
import { createHash } from "node:crypto";
import { PNG } from "pngjs";
function canonicalJson(value) {
if (value === null || value === undefined) return "null";
if (typeof value === "number") {
if (!Number.isFinite(value)) return "null";
const r = Math.round(value * 1e6) / 1e6;
return Object.is(r, -0) ? "0" : String(r);
}
if (typeof value === "boolean") return value ? "true" : "false";
if (typeof value === "string") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
const entries = Object.entries(value)
.filter(([, v]) => v !== undefined)
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`;
}
export function fingerprint(fileName, bytes) {
const name = fileName.toLowerCase();
const sha = createHash("sha256");
if (name.endsWith(".png")) {
const png = PNG.sync.read(bytes); // png.data is RGBA8, row-major
const pixels = Buffer.from(png.data);
for (let i = 0; i < pixels.length; i += 4) {
if (pixels[i + 3] === 0) pixels.fill(0, i, i + 4);
}
sha.update("rpai/v1/png\n");
sha.update(`${png.width}x${png.height}\n`);
sha.update(pixels);
} else if (name.endsWith(".json")) {
sha.update("rpai/v1/json\n");
sha.update(canonicalJson(JSON.parse(bytes.toString("utf8"))), "utf8");
} else {
sha.update("rpai/v1/bytes\n");
sha.update(bytes);
}
return sha.digest("hex");
}// Needs Gson. ImageIO and the rest are the JDK.
import com.google.gson.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;
import javax.imageio.ImageIO;
public final class Fingerprint {
// disableHtmlEscaping is not optional: Gson escapes <, > and & by default,
// and those escapes change the bytes being hashed.
private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().create();
public static String of(String fileName, byte[] bytes) throws Exception {
String name = fileName.toLowerCase(Locale.ROOT);
MessageDigest sha = MessageDigest.getInstance("SHA-256");
if (name.endsWith(".png")) {
BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes));
int w = image.getWidth(), h = image.getHeight();
byte[] pixels = new byte[w * h * 4];
int i = 0;
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
int argb = image.getRGB(x, y); // non-premultiplied ARGB
int a = (argb >>> 24) & 0xff;
if (a == 0) { i += 4; continue; } // fully transparent, four zero bytes
pixels[i++] = (byte) ((argb >> 16) & 0xff);
pixels[i++] = (byte) ((argb >> 8) & 0xff);
pixels[i++] = (byte) (argb & 0xff);
pixels[i++] = (byte) a;
}
}
sha.update("rpai/v1/png\n".getBytes(StandardCharsets.UTF_8));
sha.update((w + "x" + h + "\n").getBytes(StandardCharsets.UTF_8));
sha.update(pixels);
} else if (name.endsWith(".json")) {
JsonElement parsed = JsonParser.parseString(new String(bytes, StandardCharsets.UTF_8));
sha.update("rpai/v1/json\n".getBytes(StandardCharsets.UTF_8));
sha.update(canonical(parsed).getBytes(StandardCharsets.UTF_8));
} else {
sha.update("rpai/v1/bytes\n".getBytes(StandardCharsets.UTF_8));
sha.update(bytes);
}
return HexFormat.of().formatHex(sha.digest());
}
private static String canonical(JsonElement value) {
if (value == null || value.isJsonNull()) return "null";
if (value.isJsonPrimitive()) {
JsonPrimitive p = value.getAsJsonPrimitive();
if (p.isBoolean()) return p.getAsBoolean() ? "true" : "false";
if (p.isNumber()) {
double d = p.getAsDouble();
if (!Double.isFinite(d)) return "null";
double r = Math.floor(d * 1e6 + 0.5) / 1e6; // six decimal places, ties up
if (r == 0) return "0"; // never "-0"
return BigDecimal.valueOf(r).stripTrailingZeros().toPlainString();
}
return GSON.toJson(p.getAsString());
}
if (value.isJsonArray()) {
StringJoiner out = new StringJoiner(",", "[", "]");
for (JsonElement item : value.getAsJsonArray()) out.add(canonical(item));
return out.toString();
}
List<String> keys = new ArrayList<>(value.getAsJsonObject().keySet());
Collections.sort(keys); // code-unit order
StringJoiner out = new StringJoiner(",", "{", "}");
for (String key : keys) {
out.add(GSON.toJson(key) + ":" + canonical(value.getAsJsonObject().get(key)));
}
return out.toString();
}
}# Needs Pillow; everything else is the standard library.
import hashlib
import io
import json
import math
from PIL import Image
def canonical_json(value):
if value is None:
return "null"
if isinstance(value, bool): # before int: bool is an int in Python
return "true" if value else "false"
if isinstance(value, (int, float)):
if isinstance(value, float) and not math.isfinite(value):
return "null"
r = math.floor(value * 1e6 + 0.5) / 1e6 # six decimal places, ties up
return str(int(r)) if r == int(r) else repr(r)
if isinstance(value, str):
return json.dumps(value, ensure_ascii=False)
if isinstance(value, list):
return "[" + ",".join(canonical_json(v) for v in value) + "]"
return (
"{"
+ ",".join(
f"{json.dumps(k, ensure_ascii=False)}:{canonical_json(v)}"
for k, v in sorted(value.items())
)
+ "}"
)
def fingerprint(file_name, data):
name = file_name.lower()
if name.endswith(".png"):
image = Image.open(io.BytesIO(data)).convert("RGBA")
pixels = bytearray(image.tobytes())
for i in range(3, len(pixels), 4):
if pixels[i] == 0: # fully transparent, four zero bytes
pixels[i - 3 : i + 1] = b"\x00\x00\x00\x00"
body = f"{image.width}x{image.height}\n".encode() + bytes(pixels)
return hashlib.sha256(b"rpai/v1/png\n" + body).hexdigest()
if name.endswith(".json"):
body = canonical_json(json.loads(data.decode("utf-8"))).encode()
return hashlib.sha256(b"rpai/v1/json\n" + body).hexdigest()
return hashlib.sha256(b"rpai/v1/bytes\n" + data).hexdigest()Then walk the uploaded pack and ask about what you found:
curl -X POST https://api.resourcepack.ai/v1/origin/lookup \
-H "content-type: application/json" \
-d '{"hashes":["9f2c1d4a7b3e5f8091a2b3c4d5e6f70819a2b3c4d5e6f70819a2b3c4d5e6f708"]}'{
"results": [
{
"hash": "9f2c1d4a7b3e5f8091a2b3c4d5e6f70819a2b3c4d5e6f70819a2b3c4d5e6f708",
"verdict": "generated",
"form": "png",
"kind": "texture",
"firstSeen": "2026-08-14T10:22:06.000Z"
}
],
"matched": 1
}Send up to 256 hashes per call. Hashes are lower-cased and duplicates
collapsed, then every distinct hash gets a result in the order you first sent
it. matched counts the generated ones.
If you'd rather not tell us what you asked#
The lookup endpoint means sending us the hashes of files your users upload. If you'd rather not, use the range endpoint: send the first five characters of a hash, get back every fingerprint we hold that starts with them, and match the rest yourself.
curl https://api.resourcepack.ai/v1/origin/range/9f2c1{
"prefix": "9f2c1",
"suffixes": [
{
"suffix": "d4a7b3e5f8091a2b3c4d5e6f70819a2b3c4d5e6f70819a2b3c4d5e6f708",
"form": "png",
"kind": "texture",
"firstSeen": "2026-08-14T10:22:06.000Z"
}
]
}We learn which of about a million buckets you asked about, never which file.
Rate limits and caching#
60 requests a minute per IP address (there's no key to count against).
At 256 hashes a call that is plenty for a review queue. Over budget answers
429 rate_limited with the wait in the message.
Responses carry cache-control: public, max-age=60.
What the registry holds#
A hash, which of the three forms produced it, what sort of asset it was, and when it was first seen.
No pack, user, prompt or asset id is stored with it, so an answer can't reveal anything about who made the file.
Getting in touch#
If you're integrating this and something is missing (a batch size, a file form, a webhook instead of polling), tell us.
Talk to us
Discord is the fastest route, or support@resourcepack.ai.