resourcepack.ai
DOCS

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");
}

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.