# Licence API

> Check Shoprocket licence keys from the software you sell: activate, validate and deactivate, offline handling, and the signed request Shoprocket sends to your own key server.

Sell software with a licence key, then let the software check that key with Shoprocket: activate it on a device, validate it on every launch, and free the seat when the buyer moves.

## What it is for

When a product sells with a licence key, every buyer gets one key per unit, by email, on their order page and in their account. The Licence API is how the software you sold finds out whether a key is real and still paid for. Your app calls it directly; there is no seller API key involved and no server of yours in between.

It follows Lemon Squeezy's License API (the same three calls, field names and response blocks), so code written for that works here with two changes: every call also sends the product's `product_secret`, and `meta` never carries the buyer's name or email, so a key posted on a forum does not leak its owner.

A key stops validating on its own when the money behind it goes: a subscription that is past due or paused suspends its keys until it recovers, an ended subscription expires them, and a full refund or the seller's revoke disables them for good.

## Where keys come from

The product's **Licence keys** card in the dashboard sets where its keys come from. All three can be checked through the Licence API.

| Source | How a key is made | Licence API |
| --- | --- | --- |
| **Shoprocket generates them** | One fresh key per unit when the order is paid, in a pattern you set (default `XXXXX-XXXXX-XXXXX-XXXXX`, at least 16 random characters). A one-off sale's key can be set to expire after a number of days. | Always on: it is the only way your software can tell a generated key is real. |
| **A list of keys I paste in** | Each sale takes the next unused key from your list. | Off until you turn on **Let my software check keys with Shoprocket**. |
| **My own key server** | Shoprocket asks your server for each unit's key after the order is paid. See [your own key server](#key-server). | Off until you turn it on. Your server can keep checking its own keys instead. |

The same card sets **Devices per key**, the activation limit. A key keeps the limit it was sold with; changing it affects only keys sold afterwards. With no limit a key still stops at 1,000 activations.

## Calling the API

Three `POST` calls. Send the fields as JSON (`Content-Type: application/json`) or as a form post (`application/x-www-form-urlencoded`, what Lemon Squeezy's examples use). There are no headers to set beyond the content type.

Endpoints

```
POST https://api-v3.shoprocket.io/v3/public/licenses/activate
POST https://api-v3.shoprocket.io/v3/public/licenses/validate
POST https://api-v3.shoprocket.io/v3/public/licenses/deactivate
```

| Field | Type | Description |
| --- | --- | --- |
| `product_secret`required | string | The product's secret, lps\_ followed by 32 characters. Shown on the product's Licence keys card once the API is on. |
| `license_key`required | string | The key the buyer typed in, up to 255 characters. |
| `instance_name` | string | activate only, required there: a label for the device, up to 255 characters, shown to the seller ("Sam's MacBook"). |
| `instance_id` | string | The id activate returned (lki\_...), up to 64 characters. Required by deactivate, optional on validate. |

The product secret is not a password

It ships inside your app, so treat it as something anyone with a copy can read. It says which product a key belongs to (two stores can sell the same key string) and stops a script from trying keys across every store. It is rotated from the same card; copies of your app built with the old secret stop getting answers within about a minute, so ship an update with the new one first.

## Activate

Call it once, when the buyer enters their key on a device. It takes one seat of the key's device limit and returns an `instance`. Store `instance.id` on the device: validate and deactivate both take it.

Request

```
curl -X POST https://api-v3.shoprocket.io/v3/public/licenses/activate \
  -H "Content-Type: application/json" \
  -d '{
    "product_secret": "lps_...",
    "license_key": "7KQ2M-XR4TD-9HB6W-PZC3N",
    "instance_name": "Studio iMac"
  }'
```

200 Response

```
{
  "activated": true,
  "error": null,
  "license_key": {
    "id": "lkey_034Yb1nD6tQ8rWvX2kPz9h",
    "status": "active",
    "key": "7KQ2M-XR4TD-9HB6W-PZC3N",
    "activation_limit": 3,
    "activation_usage": 1,
    "created_at": "2026-10-05T08:10:02.000Z",
    "expires_at": null,
    "test_mode": false
  },
  "instance": {
    "id": "lki_034Yc8sF2mLk4QvT7bNx1e",
    "name": "Studio iMac",
    "created_at": "2026-10-05T08:12:44.000Z"
  },
  "meta": {
    "store_id": "str_033xotqIZ8haHilPe2fnCW",
    "product_id": "prod_034XaQ7m1vPz3TgK8rLw5d",
    "product_name": "Pixel Studio Pro",
    "variant_id": null,
    "variant_name": null,
    "order_id": "ord_034Ya2kH9wRt6PnB4sMv8c"
  }
}
```

| Status | When | Body |
| --- | --- | --- |
| 200 | Activated. | `activated: true`, with `license_key`, `instance` and `meta`. |
| 400 | The key is suspended, expired or disabled, or every seat is taken. Also a missing field. | `activated: false` and `error` saying why. For a key that exists, `license_key` and `meta` are filled in, so you can show `activation_usage` of `activation_limit`. |
| 404 | No key with this string for this product secret. | `activated: false`, `error: "License key not found."`. |

Two devices racing for the last seat cannot both get it. Every call to activate makes a new instance, even from a device that is already activated, so call it once and keep the id rather than on every launch.

## Validate

Call it on every launch. Without `instance_id` it checks only the key; with it, it also confirms this device is still activated.

Request

```
curl -X POST https://api-v3.shoprocket.io/v3/public/licenses/validate \
  -H "Content-Type: application/json" \
  -d '{
    "product_secret": "lps_...",
    "license_key": "7KQ2M-XR4TD-9HB6W-PZC3N",
    "instance_id": "lki_034Yc8sF2mLk4QvT7bNx1e"
  }'
```

200 Response

```
{
  "valid": true,
  "error": null,
  "license_key": {
    "id": "lkey_034Yb1nD6tQ8rWvX2kPz9h",
    "status": "active",
    "key": "7KQ2M-XR4TD-9HB6W-PZC3N",
    "activation_limit": 3,
    "activation_usage": 1,
    "created_at": "2026-10-05T08:10:02.000Z",
    "expires_at": null,
    "test_mode": false
  },
  "instance": {
    "id": "lki_034Yc8sF2mLk4QvT7bNx1e",
    "name": "Studio iMac",
    "created_at": "2026-10-05T08:12:44.000Z"
  },
  "meta": {
    "store_id": "str_033xotqIZ8haHilPe2fnCW",
    "product_id": "prod_034XaQ7m1vPz3TgK8rLw5d",
    "product_name": "Pixel Studio Pro",
    "variant_id": null,
    "variant_name": null,
    "order_id": "ord_034Ya2kH9wRt6PnB4sMv8c"
  }
}
```

| Status | When | Body |
| --- | --- | --- |
| 200 | The key exists. | `valid` says whether it works now. When it is false, `error` says why and `license_key.status` is `suspended`, `expired` or `disabled`. |
| 404 | The instance_id is not activated on this key. | `valid: false`, `error: "This instance is not activated on this license key."`, with `license_key` and `meta`. Treat the device as deactivated and ask for the key again. |
| 404 | No key with this string for this product secret. | `valid: false`, `error: "License key not found."`, everything else null. |
| 400 | A missing field. | `valid: false` and `error`. |

Validate is answered by the Shoprocket edge location nearest the caller, from a copy of the key's state, without waiting on a database. The response header `x-shoprocket-license-source` says which answered: `edge`, or `origin` when the edge did not have what it needed and passed the call on (a key it has not seen yet, or a device activated moments ago somewhere else). Both give the same answer. See [how fresh an answer is](#freshness).

## Deactivate

Frees the seat, for a "sign out of this device" button or before an uninstall. It works on a suspended, expired or disabled key too, so a buyer can always free a seat. There is no `instance` block in the answer.

Request

```
curl -X POST https://api-v3.shoprocket.io/v3/public/licenses/deactivate \
  -H "Content-Type: application/json" \
  -d '{
    "product_secret": "lps_...",
    "license_key": "7KQ2M-XR4TD-9HB6W-PZC3N",
    "instance_id": "lki_034Yc8sF2mLk4QvT7bNx1e"
  }'
```

200 Response

```
{
  "deactivated": true,
  "error": null,
  "license_key": { "activation_usage": 0, ... },
  "meta": {
    "store_id": "str_033xotqIZ8haHilPe2fnCW",
    "product_id": "prod_034XaQ7m1vPz3TgK8rLw5d",
    "product_name": "Pixel Studio Pro",
    "variant_id": null,
    "variant_name": null,
    "order_id": "ord_034Ya2kH9wRt6PnB4sMv8c"
  }
}
```

| Status | When | Body |
| --- | --- | --- |
| 200 | Deactivated. | `deactivated: true`; `license_key.activation_usage` already counts the freed seat. |
| 404 | The instance is not on this key (already deactivated, or someone else's id). | `deactivated: false`, with `license_key` and `meta`. |
| 404 | No key with this string for this product secret. | `deactivated: false`, `error: "License key not found."`. |
| 400 | A missing field, including instance_id. | `deactivated: false` and `error`. |

The seller can also deactivate a device from the key's device list in the dashboard, which is the answer to "I got a new laptop and it says I am out of activations".

## Responses and errors

Every answer, success or not, has the same shape: the call's flag (`activated`, `valid` or `deactivated`), `error` (null on success), and the `license_key` and `meta` blocks, null when they do not apply (a 400, or a key that was not found). `activate` and `validate` also carry an `instance` block; `deactivate` never does. Malformed JSON gets the same shape.

| Field | Type | Description |
| --- | --- | --- |
| `license_key.id` | string | The key's id (lkey\_...), the same id the seller API and licence key webhooks use. |
| `license_key.status` | string | active, suspended, expired or disabled. A key whose expires_at has passed reads expired straight away. |
| `license_key.key` | string | The key itself. |
| `license_key.activation_limit` | integer \| null | Devices allowed. Null means no limit. |
| `license_key.activation_usage` | integer | Devices activated now. |
| `license_key.created_at` | string | When the key was sold (ISO 8601). |
| `license_key.expires_at` | string \| null | When it stops (or stopped) working, if it has an end date: a cancelled or ended subscription, or a key sold with a validity period. |
| `license_key.test_mode` | boolean | True for a key from a test order. |
| `instance` | object \| null | id, name and created_at of the device in the call. activate and validate only. |
| `meta` | object \| null | store_id, product_id, product_name, variant_id, variant_name and order_id. Names are the ones the key was sold under. |

The `error` strings, word for word:

| error | From |
| --- | --- |
| `This license key is suspended: the subscription that pays for it is past due or paused.` | any call on a suspended key (deactivate still succeeds) |
| `This license key has expired.` | any call on an expired key (deactivate still succeeds) |
| `This license key has been disabled.` | any call on a revoked or refunded key (deactivate still succeeds) |
| `This license key has reached its activation limit.` | activate |
| `This instance is not activated on this license key.` | validate, deactivate |
| `License key not found.` | any call |
| `Send product_secret and license_key, as JSON or a form post.` | any call |
| `instance_id must be text of at most 64 characters, and instance_name at most 255.` | any call |
| `Send instance_name: a label for this device.` | activate |
| `Send instance_id: the id activate returned.` | deactivate |

Branch on the status code and `license_key.status`, not on the wording: the sentences are for showing to a person.

**Rate limit.** Each call allows 60 requests a minute from one IP address. Validates answered at the edge never count, so an office full of installs launching at nine is unaffected; what counts is activate, deactivate and any validate the edge passes on (an unknown key always is, which is what stops a script guessing keys). Past the limit the answer is `429` with a `Retry-After` header in seconds and the API's general error body, `{"error": {"code": "rate_limited", "message": "..."}}`, not the licence shape above.

## How fresh an answer is

Activate and deactivate always reach Shoprocket's database. Validate is usually answered at the edge from a copy that Shoprocket updates whenever the key changes, so a change takes a moment to reach every location:

| After | Validate reflects it |
| --- | --- |
| A device activates | At once, everywhere. A location that has not heard of the new instance yet passes the call to the database instead of saying no, so a device activated a second ago at another location is never told it is not activated. |
| A device is deactivated, or the seller revokes the key | Within about a minute, everywhere. |
| A key reaches its expires_at | At once: the edge checks the date itself. |
| A subscription goes past due, is paused, recovers or ends, or the order is refunded | Within about five minutes, plus that minute. |
| A key is sold | At once: the first validate of a new key goes to the database, which publishes it for the next one. |
| The product secret is rotated or the API switched off | Old secrets stop getting answers within about a minute (404). |

So a deactivated device can still validate for up to about a minute somewhere else. If that matters for you, check `activation_usage` on activate rather than trusting validate alone.

## Offline and failures

The answers are not signed, so your app cannot prove an answer offline; what it can do is remember. Keep the last good validate answer (with its `expires_at`) on the device and let the app run on it for a grace period of your choosing when Shoprocket cannot be reached. Keygen, which builds licensing for a living, suggests days rather than hours.

| Your app sees | Means | Do |
| --- | --- | --- |
| 200 and valid: true | Licensed. | Run, and refresh the cached answer. |
| 200 and valid: false | The key exists and does not work now. | Show error. A suspended key may come back by itself once the subscription is paid. |
| 404 with license_key filled in | This device was deactivated. | Ask for the key again (activate). |
| 404 with license_key null | No such key for this product. | Refuse the key. |
| 429, 5xx, a timeout, no network | No answer. | Not a refusal. Use the cached answer within your grace period and try again later. |

## Your own key server

With **My own key server** as the source, Shoprocket asks your server for a key once per unit sold: an order for three copies is three requests, with `unit` 1, 2 and 3 and a different `id` each. Set the **Key server URL** on the product's Licence keys card. It must be HTTPS (plain HTTP only for `localhost`) and cannot point at a private or internal address.

The request is made in the background once the order is paid, never during checkout, so a slow server cannot hold up a payment. It is a `POST` with a JSON body:

What your server receives

```
POST /your/key/server
Content-Type: application/json
User-Agent: Shoprocket-LicenseKeys/1.0
X-Shoprocket-Signature: t=1791196211,v1=5257a869e7...
X-Shoprocket-Delivery: lkr_034Yd3pQ7nWs1KfB9xTz4m
X-Shoprocket-Event: license_key.requested
Idempotency-Key: lkr_034Yd3pQ7nWs1KfB9xTz4m

{
  "id": "lkr_034Yd3pQ7nWs1KfB9xTz4m",
  "event": "license_key.requested",
  "test": false,
  "createdAt": "2026-10-05T08:10:02.000Z",
  "store": { "id": "str_033xotqIZ8haHilPe2fnCW" },
  "order": {
    "id": "ord_034Ya2kH9wRt6PnB4sMv8c",
    "number": "#1042",
    "email": "sam@example.com",
    "customerName": "Sam Rivera"
  },
  "product": { "id": "prod_034XaQ7m1vPz3TgK8rLw5d", "name": "Pixel Studio Pro" },
  "variant": null,
  "unit": 1
}
```

| Field | Type | Description |
| --- | --- | --- |
| `id` | string | This request (lkr\_...). The same on every retry of this unit; also sent as Idempotency-Key and X-Shoprocket-Delivery. |
| `event` | string | Always license_key.requested, also sent as X-Shoprocket-Event. |
| `test` | boolean | True for a test order and for the Send test request button. Issue a key you can tell apart. |
| `createdAt` | string | When the order was paid and the request queued (ISO 8601). Not the time of this attempt. |
| `store.id` | string | The store (str\_...). |
| `order.id, order.number` | string | The order (ord\_...) and its number as the buyer sees it (#1042). |
| `order.email, order.customerName` | string \| null | The buyer, so your system can record who holds the key. |
| `product.id, product.name` | string | The product (prod\_...) and the name it was sold under. |
| `variant` | object \| null | id (var\_...) and name of the variant, or null. |
| `unit` | integer | Which unit of the order line this is, from 1. |

**Verify the signature.** `X-Shoprocket-Signature` is signed exactly like a [webhook](https://v3.shoprocket.io/docs/webhooks.md#verify): `t=<unix time>,v1=<signature>`, where the signature is a hex HMAC-SHA256 of the timestamp, a full stop and the raw body, keyed with the product's key server secret (`whsec_...`, on the same card, rotatable there). It is the product's own secret, not a webhook endpoint's. Rotating it takes effect on the next request, retries included, so update your server first.

A complete server in Node, with no dependencies:

Key server (Node)

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

// whsec_... from the product's Licence keys card.
const SECRET = process.env.SHOPROCKET_KEY_SECRET;
// Idempotency-Key -> the key you issued. Use your database in production.
const issued = new Map();

// rawBody: the exact bytes received, before any JSON parsing.
function verify(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < toleranceSec;
  const given = Buffer.from(parts.v1 ?? "");
  const want = Buffer.from(expected);
  // timingSafeEqual throws on a length mismatch instead of returning false.
  return fresh && given.length === want.length && crypto.timingSafeEqual(want, given);
}

// Your own licensing system goes here.
function mintKey(request) {
  return "MYAPP-" + crypto.randomBytes(10).toString("hex").toUpperCase();
}

http.createServer((req, res) => {
  const chunks = [];
  req.on("data", (c) => chunks.push(c));
  req.on("end", () => {
    const raw = Buffer.concat(chunks).toString("utf8");
    if (!verify(raw, req.headers["x-shoprocket-signature"] ?? "", SECRET)) {
      res.writeHead(401).end("bad signature");
      return;
    }
    const request = JSON.parse(raw);
    const id = req.headers["idempotency-key"];
    // A retry of a unit you already answered gets the same key back.
    let key = issued.get(id);
    if (!key) {
      key = mintKey(request);
      issued.set(id, key);
    }
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ key }));
  });
}).listen(3000);
```

## What your server answers

Answer `2xx` within 10 seconds with the key, either as JSON `{"key": "..."}` or as the whole body in plain text. The key must be one line of at most 255 characters, and the body at most 8 KB; an HTML page is refused, so a misconfigured server answering 200 with its home page never hands a buyer `<!doctype html>`.

**Return the same key for the same id.** If your server answered but the reply was lost (a timeout on our side), the retry carries the same `Idempotency-Key`. Shoprocket stores exactly one key per request whatever happens, so a second, different key would simply never reach the buyer and would sit in your system unused.

Anything else is a failed attempt: another status code (redirects are not followed), no answer in time, or a body that is not a key. Every failure is retried, 4xx included. After a failure:

1. If the product has **fallback keys** pasted on the card, the next one is used at once and the unit is done. Your server is not asked again for it.
2. Otherwise the request is retried after 1 minute, 5 minutes, 15 minutes, 1 hour, 4 hours and 24 hours: 7 attempts over about 29 hours. Retries run on a 5-minute schedule, so the short waits round up to it.
3. The store's order notification addresses (or the owner) get one email per order on the first failure, with your server's answer, and the order page shows the request with **Retry** and a box to type a key in by hand. After the last attempt it waits there.

**What the buyer sees meanwhile.** Their order confirmation says the key is being prepared and will arrive in a separate email, and their order page shows "Being prepared, it will arrive by email shortly." When your server answers, a second email carries the key and it appears on the order page and in their account. Nothing is refunded or held up because of a failure: the order stays paid. If the order is refunded before a key arrives, your server is not asked again.

**Testing.** **Send test request** on the card posts a sample request (`test: true`, order number `#TEST`, buyer `test@example.com`) and shows what your server answered. Nothing is stored. A test order you place in your own store preview calls your server for real, with `test: true`. A test order a shopper places through the test gateway gets a placeholder key (`TEST-...`) without calling your server. See [Test mode](https://v3.shoprocket.io/docs/test-mode.md).

Testing your app's activation

The same split applies to every key source: a placeholder `TEST-...` key is not stored, so the Licence API answers 404 for it. To try activate and validate end to end, buy the product in your own store preview. That key is real and marked `test_mode: true`. Clearing out test orders deletes a key that was generated or sent by your server (it then answers 404), and puts a key from your pasted pool back up for sale (it stops validating too).

## Licence key webhooks

To keep your own records in step without polling, subscribe a [webhook endpoint](https://v3.shoprocket.io/docs/webhooks.md) to the licence key events. Each carries the key as the seller API returns it, plus the buyer's `customerEmail`, and is signed and retried like every other webhook.

| Field | Type | Description |
| --- | --- | --- |
| `license_key.assigned` | event | A licence key was handed to a buyer: generated by Shoprocket, from the pool, from the seller's own server, or typed in by hand. |
| `license_key.suspended` | event | A sold key stopped working because its subscription is past due or paused. It comes back on its own when that resolves. |
| `license_key.reinstated` | event | A suspended key works again: the subscription's payment recovered or it was resumed. |
| `license_key.expired` | event | A sold key reached its end date: its subscription ended, or a one-off sale's validity period ran out. Final. |
| `license_key.revoked` | event | A sold key was revoked by the seller, or disabled because its order was refunded in full. Final. |

---

- HTML version: https://v3.shoprocket.io/docs/licence-api
- Language: English (en)
