Skip to content
KeystoneLoot.ioMidnight · Season 2

Documentation

For developers

How the import string is built, so an addon or a tool can read and write it.

Shape

An import string is a prefix followed by a payload:

KeystoneLoot:v3,<base64( zlib( JSON ) )>

The payload is built in three steps: serialise the JSON, compress it with zlib (RFC 1950, deflate with a header), then Base64-encode it. That is standard Base64 with + and /, not the URL-safe variant. To read it, reverse the three steps.

Payload

The JSON is an object keyed by Blizzard's numeric specialisation ids. One string can carry several specs.

{
  "62": [
    { "tier": 3, "itemId": 271874, "gems": [240967], "enchant": 7991 },
    { "tier": 3, "itemId": 239648, "bonusIds": [13751, 13836, 9627] },
    { "tier": 5, "itemId": 251232 },
    { "tier": 3, "itemId": 271564 }
  ]
}

Fields

FieldTypeRequiredDescription
itemIdintegeryesThe item's id.
tierintegernoPriority, 1 to 5. Defaults to 2.
bonusIdsinteger[]noBonus ids applied to the item.
gemsinteger[]noSocketed gem item ids.
enchantintegernoEnchant id.

Tier values

The priorities tier can take. These are the addon's own names for them:

ValueIconMeaning
1Nice to have
2Must have (default)
3Best in Slot
4Transmog
5Catalyst

Four things a table cannot tell you:

  • This site writes tier 3 and tier 5. 3 is what you should wear; 5 is a piece meant for the catalyst, and every one of those has the piece it makes beside it as its own tier 3 entry.
  • bonusIds carries what describes the item: the chain on crafted gear, the Mythic+ label on a keystone drop. The item level is not in there, because your copy sits at the rank you looted it at, not the one the list aims for.
  • enchant is a SpellItemEnchantmentID, not the enchant item's id. That is the value Wowhead's ench parameter and the addon both need.

Empty arrays and nulls are left out when writing rather than sent along.

Examples

Three implementations, depending on where your code runs. All three produce the same string.

JavaScript

Both directions in the browser, no dependencies. Every current browser has CompressionStream, which is why zlib needs no library here:

// A KeystoneLoot:v3 string, built in the browser.
async function encodeV3(table) {
  const bytes = new TextEncoder().encode(JSON.stringify(table));
  // zlib, RFC 1950 (header 0x78 0x9c) — not "deflate-raw".
  const stream = new CompressionStream("deflate");
  const writer = stream.writable.getWriter();
  writer.write(bytes);
  writer.close();
  const packed = new Uint8Array(
    await new Response(stream.readable).arrayBuffer()
  );
  // Standard base64, with + and /, not base64url.
  let binary = "";
  for (const byte of packed) binary += String.fromCharCode(byte);
  return "KeystoneLoot:v3," + btoa(binary);
}

const string = await encodeV3({
  62: [{ tier: 3, itemId: 271874, gems: [240967], enchant: 7991 }],
});

And back:

// And back again.
async function decodeV3(string) {
  const payload = string.slice(string.indexOf(",") + 1);
  const bytes = Uint8Array.from(atob(payload), (c) => c.charCodeAt(0));
  const stream = new DecompressionStream("deflate");
  const writer = stream.writable.getWriter();
  writer.write(bytes);
  writer.close();
  return JSON.parse(await new Response(stream.readable).text());
}

TypeScript

The same thing typed, plus the shape of the payload. The browser variant again, so async:

export type Tier = 1 | 2 | 3 | 4 | 5;

export interface Entry {
  itemId: number;
  tier?: Tier;
  bonusIds?: number[];
  gems?: number[];
  enchant?: number;
}

/** Keyed by Blizzard's numeric specialisation id. */
export type ImportTable = Record<number, Entry[]>;

const PREFIX = "KeystoneLoot:v3,";

export async function encodeV3(table: ImportTable): Promise<string> {
  const bytes = new TextEncoder().encode(JSON.stringify(table));
  const stream = new CompressionStream("deflate");
  const writer = stream.writable.getWriter();
  writer.write(bytes);
  writer.close();
  const packed = new Uint8Array(
    await new Response(stream.readable).arrayBuffer()
  );
  let binary = "";
  for (const byte of packed) binary += String.fromCharCode(byte);
  return PREFIX + btoa(binary);
}

export async function decodeV3(text: string): Promise<ImportTable> {
  const payload = text.slice(text.indexOf(",") + 1);
  const bytes = Uint8Array.from(atob(payload), (c) => c.charCodeAt(0));
  const stream = new DecompressionStream("deflate");
  const writer = stream.writable.getWriter();
  writer.write(bytes);
  writer.close();
  return JSON.parse(await new Response(stream.readable).text()) as ImportTable;
}

Node.js

Server-side, as an ES module. node:zlib does what CompressionStream did, Buffer does the base64, and the string comes out byte for byte identical:

import { deflateSync, inflateSync } from "node:zlib";

const PREFIX = "KeystoneLoot:v3,";

export function encodeV3(table) {
  const json = Buffer.from(JSON.stringify(table), "utf8");
  return PREFIX + deflateSync(json).toString("base64");
}

export function decodeV3(text) {
  const payload = text.slice(text.indexOf(",") + 1);
  const json = inflateSync(Buffer.from(payload, "base64"));
  return JSON.parse(json.toString("utf8"));
}

API

If you would rather not build the string yourself, fetch the finished one:

GET/api/import/:class/:spec

The answer is JSON, here for /api/import/deathknight/unholy:

{
  "class": "deathknight",
  "spec": "unholy",
  "classId": 6,
  "specId": 252,
  "view": "overall",
  "build": null,
  "updated": "2026-08-12T19:31:27Z",
  "string": "KeystoneLoot:v3,eJydkDFuwzAMRe..."
}

Without parameters you get the overall list. Two optional ones:

  • view: overall, mythicplus or raid
  • build: the hero talent build, for specs that publish more than one list.
$ curl "https://keystoneloot.io/api/import/deathknight/unholy?view=raid"
$ curl "https://keystoneloot.io/api/import/priest/discipline?build=Oracle"

updated is the timestamp of the last update. Store it, compare on the next run, and skip the import when nothing has changed.

For the shell there is ?format=plain. You get the string itself and a trailing newline. The fields above are in the headers there: x-keystoneloot-class-id, -spec-id, -view, -build and -updated.

$ curl "https://keystoneloot.io/api/import/mage/fire?format=plain"
KeystoneLoot:v3,eJydkTtuwzAMhu/CmYNIiqSo...

Errors come back in the same shape as hits, so one route needs only one parser. The valid values come as an array:

{
  "error": "Unknown build: Quatsch",
  "available": ["Oracle", "Voidweaver"],
  "docs": "https://keystoneloot.io/en/developers#api"
}

So that nobody has to guess 40 URLs, an index lists every spec, with the available views and builds:

GET/api/import

The lists change once a week at most. Please cache the answers; the headers tell you for how long.

Older formats

v1 and v2 are uncompressed plain text and are no longer supported. Please generate v3 only.

Try it

The Import string editor takes a string apart in the browser and shows what is inside. Handy for checking your own implementation against it.