> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pocketsflow.com/llms.txt
> Use this file to discover all available pages before exploring further.

# License keys

> Sell software with a license key per sale: turn keys on for a product, then validate, activate and deactivate them from your app with no secret key.

Selling an app, a plugin or a desktop tool? Turn on **License keys** for the
product and every sale gets its own key. The buyer sees it in their
purchase receipt and in **My purchases** (with a copy button), you see it on
the order, and your software checks it against three public endpoints — no
API key ships inside your app.

<Steps>
  <Step title="Turn it on">
    In the product editor, open **License keys**, switch on **Generate a
    license key for each sale** and, optionally, set an **activation limit**
    (leave it blank for unlimited).
  </Step>

  <Step title="Sell">
    Each new sale of the product gets one key, like
    `7KQ2M-X9D4R-T3VWB-8HNPC`. It is in the buyer's receipt email, in their
    purchases, on the order in your dashboard and in the `order.completed`
    webhook.
  </Step>

  <Step title="Check it from your app">
    Call `POST /licenses/activate` when the user enters the key, and
    `POST /licenses/validate` on launch.
  </Step>
</Steps>

<Note>
  License keys are for one-time products. Keys are issued for sales made after
  you turn the setting on. A refunded or disputed order's key stops
  validating automatically.
</Note>

## How keys work

* **Format.** Four groups of five characters (`XXXXX-XXXXX-XXXXX-XXXXX`) drawn
  from a cryptographically secure random source — 100 bits, so keys can't be
  guessed. Input is forgiving: case, spaces and dashes are ignored, and `O` is
  read as `0`, `I`/`L` as `1`.
* **One key per order.** Payment retries never create a second key.
* **Activations.** An activation is one install or device, identified by an
  `instanceId` you choose (or one we generate). The activation limit you set
  on the product is copied onto each key when it is issued, so changing the
  product later never takes seats away from buyers who already paid.
* **Product-bound.** Every call needs the product id **and** the key. A key of
  another product gets the same `404` as a key that doesn't exist.

## Public endpoints (called from your software)

Base URL `https://api.pocketsflow.com`. No `Authorization` header. JSON body:

| Field | Required | Notes |
| - | - | - |
| `productId` | yes | The product the key was sold with. |
| `licenseKey` | yes | What the user entered. |
| `instanceId` | activate: no, deactivate: yes, validate: no | Your id for this install (1–128 letters, digits, `.`, `_`, `:`, `-`). |
| `instanceName` | no | Activate only. A label you'll see on the order, e.g. the device name. |

`snake_case` spellings (`product_id`, `license_key`, `instance_id`,
`instance_name`) are accepted too. The same routes also answer under
`/v1/licenses/...`.

Every answer has the same shape — read `valid`, and `code` when it is `false`:

```json theme={"system"}
{
  "valid": true,
  "status": "active",
  "code": null,
  "message": "License key is valid.",
  "productId": "665f1c2a9b3e4a0012ab34cd",
  "activations": 1,
  "activationLimit": 3,
  "activationsRemaining": 2,
  "buyerEmail": "a***@example.com",
  "createdAt": "2026-10-15T09:12:44.000Z",
  "testMode": false,
  "instance": {
    "id": "macbook-pro-2f9c",
    "name": "Anna's MacBook Pro",
    "createdAt": "2026-10-15T09:20:02.000Z"
  }
}
```

| Endpoint | Success | Failures (`code`) |
| - | - | - |
| `POST /licenses/activate` | `201` activated, `200` already active on this `instanceId` | `403 license_disabled`, `409 activation_limit_reached`, `404 license_not_found` |
| `POST /licenses/validate` | `200` with `valid: true` | `200` with `valid: false` and `license_disabled` or `instance_not_found` (when you pass `instanceId`), `404 license_not_found` |
| `POST /licenses/deactivate` | `200`, the seat is free again | `404 instance_not_found` / `license_not_found` |

All three return `400 invalid_request` without `productId` or `licenseKey`,
and `429` (`code: rate_limited`) past **60 requests per minute per IP**. They
answer any origin, so a web app can call them from the browser.

<CodeGroup>
  ```bash cURL theme={"system"}
  curl -X POST https://api.pocketsflow.com/licenses/activate \
    -H "Content-Type: application/json" \
    -d '{
      "productId": "665f1c2a9b3e4a0012ab34cd",
      "licenseKey": "7KQ2M-X9D4R-T3VWB-8HNPC",
      "instanceId": "macbook-pro-2f9c",
      "instanceName": "Anna'\''s MacBook Pro"
    }'
  ```

  ```javascript Node.js theme={"system"}
  const PRODUCT_ID = "665f1c2a9b3e4a0012ab34cd";

  async function license(action, licenseKey, instanceId) {
    const res = await fetch(`https://api.pocketsflow.com/licenses/${action}`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ productId: PRODUCT_ID, licenseKey, instanceId }),
    });
    return res.json();
  }

  // When the user enters their key:
  const activation = await license("activate", userKey, deviceId);
  if (!activation.valid) showError(activation.message);

  // On every launch:
  const check = await license("validate", savedKey, deviceId);
  if (!check.valid) lockApp(check.code);
  ```

  ```python Python theme={"system"}
  import requests

  PRODUCT_ID = "665f1c2a9b3e4a0012ab34cd"

  def license(action, key, instance_id=None):
      res = requests.post(
          f"https://api.pocketsflow.com/licenses/{action}",
          json={"productId": PRODUCT_ID, "licenseKey": key, "instanceId": instance_id},
          timeout=10,
      )
      return res.json()

  check = license("validate", saved_key, device_id)
  if not check["valid"]:
      lock_app(check["code"])
  ```
</CodeGroup>

<Tip>
  Cache the last successful validation and allow a grace period offline — a
  network error on launch shouldn't lock a paying customer out.
</Tip>

## Managing keys (your API key)

These need your API key (`Authorization: Bearer pk_live_…` / `pk_test_…`)
and only ever touch your own keys in that key's mode.

| Endpoint | What it does |
| - | - |
| `GET /licenses` | List keys, newest first. Filters: `productId`, `orderId`, `status` (`active` / `disabled`), `email`, `page`, `pageSize` (max 100). |
| `GET /licenses/{id}` | One key with its activations. |
| `POST /licenses/{id}/disable` | Switch the key off. |
| `POST /licenses/{id}/enable` | Switch it back on. Re-enabling the key of a refunded or disputed order is an explicit override — the order won't switch it off again. |
| `POST /licenses/{id}/reset-activations` | Remove every activation, e.g. after the buyer replaced their computers. |

The same actions are on the order page in your dashboard (**Orders → an
order → License key**), and as MCP tools (`list_license_keys`,
`get_license_key`, `disable_license_key`, `enable_license_key`,
`reset_license_key_activations`).

## Refunds and disputes

When an order is refunded or disputed, its key is disabled with the reason
(`refunded` / `disputed`), so `validate` answers `valid: false` with
`code: license_disabled` and `activate` answers `403`. The buyer's purchases
no longer show the key. You can still re-enable it from the order page.

Per-seat subscriptions can issue one key per subscriber (see
[usage-based and per-seat pricing](/selling/usage-based-pricing)). That key is
disabled with the reason `subscription_ended` while the subscription is
canceled or expired, and switched back on automatically if it resumes.

## Webhooks

`order.completed` for a product with license keys carries the key:

```json theme={"system"}
{
  "licenseKey": {
    "id": "6710b2c49e3f1a0012cd56ef",
    "key": "7KQ2M-X9D4R-T3VWB-8HNPC",
    "status": "active",
    "activationLimit": 3
  }
}
```

See [Webhook events](/api-webhooks/events#order-completed).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.