> For the complete documentation index, see [llms.txt](https://merithic.gitbook.io/merithic-docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://merithic.gitbook.io/merithic-docs/api-reference/endpoints/unlock.md).

# POST /unlock

Returns an **encrypted payload** that only a genuine license key can decrypt. This is the endpoint to use when it matters.

```
POST https://api.merithic.com/unlock
```

## Why this instead of /check

[`/check`](/merithic-docs/api-reference/endpoints/check.md) answers with a boolean, and a boolean can be faked. `/unlock` returns the data your product actually needs, sealed under the buyer's key.

An attacker who stubs the request and returns `{ "ok": true }` gets **no payload**, so the product has nothing to run with. Forging the response requires the key, and if they have a valid key they're a legitimate buyer.

## Request

| Field        | Type             | Required | Description                                            |
| ------------ | ---------------- | -------- | ------------------------------------------------------ |
| `user_id`    | string or number | yes      | Roblox user ID.                                        |
| `product_id` | string           | yes      | Your product's ID.                                     |
| `key`        | string           | yes      | The buyer's license key.                               |
| `nonce`      | string           | yes      | Fresh random value. 16 to 128 alphanumeric characters. |
| `game_id`    | string or number | no       | Place ID, recorded for alerts.                         |

```json
{
  "user_id": "123456789",
  "product_id": "prod_abc123",
  "key": "the buyer's key",
  "nonce": "a1b2c3d4e5f60718",
  "game_id": "987654321"
}
```

{% hint style="warning" %}
Generate a **new nonce on every call**. It's what makes a captured response useless later. Reusing one turns the whole scheme into a replayable constant.

The nonce must match `^[A-Za-z0-9]+$` and be 16 to 128 characters, or the request is rejected.
{% endhint %}

## Response

```json
{
  "ok": true,
  "exp": 1735689600,
  "cipher": "9f2a...",
  "tag": "4c81..."
}
```

On failure the entire body is `{ "ok": false }` with no detail, deliberately.

| Field    | Description                                             |
| -------- | ------------------------------------------------------- |
| `exp`    | Unix seconds. The payload is valid for 5 minutes.       |
| `cipher` | Hex ciphertext of the seller's unlock payload.          |
| `tag`    | Hex HMAC over the ciphertext. Verify before decrypting. |

## The scheme

Both sides derive the same one-time session key from the license key plus the nonce. No AES is involved, so this is reproducible in pure Luau.

```
sessionKey    = HMAC_SHA256(key, "sk|" .. nonce)                  -> hex
keystream[i]  = HMAC_SHA256(sessionKey, nonce .. "|" .. i)        -> i = 0,1,2...
plaintext     = cipher XOR concat(keystream)
tag           = HMAC_SHA256(sessionKey, nonce .. "|tag|" .. cipherHex)
```

Your product must:

1. Recompute `tag` and compare. If it differs, **stop**, because the response was tampered with.
2. XOR-decrypt `cipher` with the keystream.
3. Confirm the decrypted payload echoes the same `nonce` you sent.
4. Confirm `exp` hasn't passed.

Skipping step 1 or 3 undoes the protection.

## Examples

{% tabs %}
{% tab title="Luau (server)" %}

```lua
local HttpService = game:GetService("HttpService")

local PRODUCT_ID = "prod_abc123"
local KEY = "the buyer's key"

-- Your HMAC-SHA256 implementation. Any correct pure-Luau one works.
local hmacSha256 -- function(keyBytes: string, msg: string) -> string (raw bytes)

local function toHex(s: string): string
	return (s:gsub(".", function(c) return ("%02x"):format(c:byte()) end))
end
local function fromHex(s: string): string
	return (s:gsub("%x%x", function(cc) return string.char(tonumber(cc, 16)) end))
end

local function newNonce(): string
	local out = {}
	for _ = 1, 32 do
		out[#out + 1] = ("%x"):format(math.random(0, 15))
	end
	return table.concat(out)
end

local function unlock(userId: number)
	local nonce = newNonce()

	local ok, raw = pcall(function()
		return HttpService:PostAsync(
			"https://api.merithic.com/unlock",
			HttpService:JSONEncode({
				user_id = tostring(userId),
				product_id = PRODUCT_ID,
				key = KEY,
				nonce = nonce,
				game_id = tostring(game.PlaceId),
			}),
			Enum.HttpContentType.ApplicationJson
		)
	end)
	if not ok then return nil end

	local res = HttpService:JSONDecode(raw)
	if not res.ok then return nil end

	-- sessionKey = HMAC(key, "sk|" .. nonce), used as HEX for later HMACs
	local sessionKeyHex = toHex(hmacSha256(KEY, "sk|" .. nonce))
	local sessionKeyBytes = fromHex(sessionKeyHex)

	-- 1. Verify the tag BEFORE trusting anything
	local expected = toHex(hmacSha256(sessionKeyBytes, nonce .. "|tag|" .. res.cipher))
	if expected ~= res.tag then
		return nil -- tampered or wrong key
	end

	-- 2. XOR-decrypt with the keystream
	local cipherBytes = fromHex(res.cipher)
	local plain, counter, block, bi = {}, 0, "", 1
	for i = 1, #cipherBytes do
		if bi > #block then
			block = hmacSha256(sessionKeyBytes, nonce .. "|" .. counter)
			counter, bi = counter + 1, 1
		end
		plain[i] = string.char(bit32.bxor(cipherBytes:byte(i), block:byte(bi)))
		bi += 1
	end

	local payload = HttpService:JSONDecode(table.concat(plain))

	-- 3. Confirm our nonce came back, and 4. that it hasn't expired
	if payload.nonce ~= nonce then return nil end
	if os.time() > (res.exp or 0) then return nil end

	return payload
end

local payload = unlock(game.Players.LocalPlayer and 0 or 123456789)
if not payload then
	error("Updatr: not licensed")
end
```

{% endtab %}

{% tab title="Node.js" %}

```javascript
import crypto from "node:crypto";

const BASE = "https://api.merithic.com";

function hmac(key, msg) {
  return crypto.createHmac("sha256", key).update(msg, "utf8").digest();
}

export async function unlock({ userId, productId, key }) {
  const nonce = crypto.randomBytes(16).toString("hex");

  const res = await fetch(`${BASE}/unlock`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      user_id: String(userId),
      product_id: productId,
      key,
      nonce,
    }),
  });

  const body = await res.json();
  if (!body.ok) return null;

  // sessionKey is the HEX digest, and later HMACs key off its BYTES
  const sessionKeyHex = hmac(Buffer.from(key, "utf8"), `sk|${nonce}`).toString("hex");
  const sk = Buffer.from(sessionKeyHex, "hex");

  // 1. Verify the tag first
  const expected = hmac(sk, `${nonce}|tag|${body.cipher}`).toString("hex");
  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(body.tag))) {
    return null;
  }

  // 2. XOR-decrypt
  const cipher = Buffer.from(body.cipher, "hex");
  const out = Buffer.alloc(cipher.length);
  let block = Buffer.alloc(0);
  let counter = 0;
  for (let i = 0; i < cipher.length; ) {
    if (block.length === 0) block = hmac(sk, `${nonce}|${counter++}`);
    const take = Math.min(block.length, cipher.length - i);
    for (let j = 0; j < take; j++) out[i + j] = cipher[i + j] ^ block[j];
    block = block.subarray(take);
    i += take;
  }

  const payload = JSON.parse(out.toString("utf8"));

  // 3. Nonce echo, 4. expiry
  if (payload.nonce !== nonce) return null;
  if (Math.floor(Date.now() / 1000) > body.exp) return null;

  return payload;
}
```

{% endtab %}

{% tab title="Python" %}

```python
import hmac, hashlib, json, secrets, time
import requests

BASE = "https://api.merithic.com"

def _hmac(key: bytes, msg: str) -> bytes:
    return hmac.new(key, msg.encode(), hashlib.sha256).digest()

def unlock(user_id: str, product_id: str, key: str):
    nonce = secrets.token_hex(16)

    r = requests.post(f"{BASE}/unlock", json={
        "user_id": str(user_id),
        "product_id": product_id,
        "key": key,
        "nonce": nonce,
    }, timeout=10)

    body = r.json()
    if not body.get("ok"):
        return None

    session_key_hex = _hmac(key.encode(), f"sk|{nonce}").hex()
    sk = bytes.fromhex(session_key_hex)

    # 1. Verify tag
    expected = _hmac(sk, f"{nonce}|tag|{body['cipher']}").hex()
    if not hmac.compare_digest(expected, body["tag"]):
        return None

    # 2. XOR-decrypt
    cipher = bytes.fromhex(body["cipher"])
    out, block, counter, bi = bytearray(), b"", 0, 0
    for byte in cipher:
        if bi >= len(block):
            block = _hmac(sk, f"{nonce}|{counter}")
            counter, bi = counter + 1, 0
        out.append(byte ^ block[bi])
        bi += 1

    payload = json.loads(out.decode())

    # 3. Nonce echo, 4. expiry
    if payload.get("nonce") != nonce:
        return None
    if time.time() > body.get("exp", 0):
        return None

    return payload
```

{% endtab %}
{% endtabs %}

## Common mistakes

**Trusting `ok: true` without decrypting.** This is the one that undoes everything. If you branch on `ok` and never verify the tag, you've built [`/check`](/merithic-docs/api-reference/endpoints/check.md) with extra steps.

**Reusing a nonce.** Cache the response if you must, but generate a fresh nonce per request.

**Failing open on a network error.** If the call can't be reached, decide deliberately whether the product runs. Treating "no response" as "licensed" means blocking the request is a bypass.

## See also

{% content-ref url="/pages/JADHRnSQljLIJCQlnIy5" %}
[POST /check](/merithic-docs/api-reference/endpoints/check.md)
{% endcontent-ref %}


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://merithic.gitbook.io/merithic-docs/api-reference/endpoints/unlock.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
