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 you don't accept it at all, you currently have no way to tell by looking — and that gets harder as generation gets better, not easier. We don't support reselling generated packs. This is how that stops being a promise we make to our users and becomes something you can check for yourself.
What an answer means#
The two verdicts are not symmetrical, and building on the assumption that they are is the one real way to misuse this.
generated is definitive. It means the bytes in front of you were produced
here. There is no heuristic in the answer and no confidence score, because
there is nothing to be uncertain about: the hash either is 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, and that rests on three rules rather
than on the hash being long.
Only our own output is registered. A pack here holds work we generated and work its owner brought in, and the two are recorded separately from the moment they're written. Imported artwork is never fingerprinted, so a creator who uses the editors on their own texture cannot have it called ours.
Generic content is deliberately left out. A blank tile, a square of one colour, a model with no geometry of its own — these are files thousands of people have independently produced, and registering one would mean answering "we generated this" about a blank square somebody made in Paint. Anything below a floor of distinctness is skipped, so the registry cannot hold a hash that legitimately belongs to somebody else's work.
A registration happens on the way out. Content is fingerprinted when it is exported, pushed or published, not when it is generated — so the registry is a record of what actually left, which is the only content that could ever have reached you.
The direction we are willing to be wrong in is the other one. Where there is
doubt, nothing is registered and the answer is no_record, which claims
nothing about anybody.
Computing the hash#
SHA-256 over the file's canonical content, not its bytes. A byte hash would be defeated by opening a PNG and saving it — which is the first thing anyone laundering an asset would do, and something an innocent pack converter does by accident. Hashing the decoded content means re-compression, metadata stripping, key reordering and pretty-printing all leave the answer alone. What changes it is editing the actual content.
Three forms, chosen by file extension.
.png#
Decode to 8-bit RGBA. Flatten every fully transparent pixel to
00 00 00 00 first: what sits in the colour channels under alpha 0 is
invisible, and encoders disagree about what to put there. Then hash:
"rpai/v1/png\n" + "<width>x<height>\n" + <RGBA bytes, row-major, 4 per pixel>The dimensions are part of the input. Without them a 16×32 texture and a 32×16 one holding the same pixels in the same order would be the same digest.
.json#
Parse it, then serialise canonically: object keys sorted, array order kept
(in a block model the element order is the geometry, so sorting it would make
two different models collide), numbers rounded to six decimal places with -0
folded to 0, and no whitespace anywhere. Then hash:
"rpai/v1/json\n" + <that string, UTF-8>The rounding is what absorbs float noise — a value somebody typed as 0.3
comes back out of some editors as 0.30000000000000004. Six decimals is far
below anything Minecraft can render and far above the noise.
Everything else#
Hash the bytes unchanged:
"rpai/v1/bytes\n" + <the file's bytes>We don't support sounds today.
The prefixes are hashed, not labels
rpai/v1/png and friends are domain separation, and they are part of the
input to SHA-256. Getting one wrong means every lookup misses silently rather
than erroring. The format is frozen — a change would appear as rpai/v2/…
under a new field, 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 — open your network tab and watch. The pack never leaves your machine; the only thing that goes anywhere is 64 hexadecimal characters per file. The hasher doing it is a second, independent implementation of the rules above, held to the real one by a check that runs both over the same files and compares digests, so what you see there is what the registry would have stored.
It's the fastest way to sanity-check the format before writing a line: hash
something you generated with us, and confirm it comes back generated.
Reference implementations#
The same rules in three languages. Each one turns a file into the 64 hexadecimal characters the endpoints take, and each is worth checking against Try it above on a file you generated with us before you trust it on a queue.
// 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 one 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, so a pack of any ordinary size is a handful of requests. Each returns a result in the order you sent it, so you can pair answers to files without matching on the hash yourself.
If you'd rather not tell us what you asked#
The lookup endpoint means sending us the content hashes of files your users upload to you. If you would rather not, use the range endpoint instead: send the first five characters of a hash, get back every fingerprint we hold that starts with them, and match the rest locally.
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"
}
]
}Five characters is small enough that a bucket is a short response, and large enough that what you were really asking about is hidden among everything else sharing those characters. We learn that somebody asked about one of a million buckets and never which file it was.
Rate limits and caching#
Counted per address, at 60 requests a minute — the one part of this API
counted that way rather than per key, because there is no key to count instead.
That is fifteen full packs a minute from one address, which is far above any
review queue. Over budget answers 429 rate_limited with the wait in the
message.
Responses carry cache-control: public, max-age=60. A minute is short enough
that a pack exported moments ago is visible when you check it, and long enough
that a review queue re-checking the same upload doesn't pay for it twice.
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.
There is no pack, no user, no prompt and no asset id in it, and that's a design constraint rather than an omission: the endpoint reading it is unauthenticated, so anything stored there is something an anonymous caller could confirm by guessing a hash. What you need is "yes, ours" and roughly when. Nothing about our users belongs in the answer.
Getting in touch#
If you're integrating this and something is missing — a batch size that doesn't suit your queue, a form you need that isn't covered, a webhook rather than a poll — say so. This exists to be used, and it is easier to change now than after two marketplaces have shipped against it.
Talk to us
Discord is the fastest route, or support@resourcepack.ai.