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

# SDK Register

> Create an end-user account linked to a license key via the Frost Auth SDK.

# SDK Register

```http theme={null}
POST /api/v1/sdk/register
Content-Type: application/json
```

Creates a new end-user account and links it to a license key in one call. The key identifies the product automatically — unlike login, this route needs no `owner`/`product` pair. After registration the user is signed in immediately: gate checks, device binding, and session issuance all happen in the same request.

> This is **not** the operator console registration. This creates customer accounts that sign into your application through your software.

## Request

```json theme={null}
{
  "key": "FROST-NMBS-AX92-1LM3",
  "username": "alex.dev",
  "password": "••••••••",
  "hwid": "b7d3f1a9c0e2...",
  "os": "Windows 11",
  "appVersion": "1.4.2",
  "hash": "9f2c...64 hex chars",
  "label": "Alex's desktop",
  "components": { "cpu": "...", "mb": "..." },
  "virtual": false
}
```

The JS SDK fills `hwid`, `os`, `appVersion`, `components`, and `virtual` for you:

```js theme={null}
await client.init();
const snap = await client.register("FROST-NMBS-AX92-1LM3", "alex.dev", "••••••••");
// snap.license, snap.product, snap.device, snap.account, snap.subscriptions
```

Optional third argument for the rest: `client.register(key, user, pass, { hash, label: "Alex's desktop" })`. There is no `twofa` option here — accounts are created without 2FA.

## Parameters

| Field | Required | Description |
| - | - | - |
| `key` | Yes | The license key to register with. Looked up by hashed value. Must be unclaimed. |
| `username` | Yes | 3–32 characters. Letters, digits, `.`, `-`, `_` only. Unique per product, not globally. |
| `password` | Yes | Minimum 8 characters. Hashed with argon2id. |
| `hwid` | Yes | Hardware fingerprint for device binding. The SDK derives this automatically. |
| `os` | No | Operating system string. Feeds the platform gate. |
| `appVersion` | No | Current build version (max 32 chars). Feeds the version gate. |
| `hash` | No | SHA-256 of the running binary (max 128 chars). Checked when **Check Hash** is enabled. |
| `label` | No | Human-readable device name (max 64 chars). |
| `components` | No | Structured hardware components object. Parsed into the composite HWID. |
| `virtual` | No | Boolean. When `true` and the product blocks VMs, registration is refused. |

`key`, `hwid`, and `os` are capped at 256 characters.

## Responses

```text 201 Created theme={null}
{
  "success": true,
  "response": {
    "data": {
      "session": {
        "token": "eyJhbG...MiIs...",
        "expiresIn": 900,
        "heartbeatSeconds": 180
      },
      "license": {
        "status": "ACTIVE",
        "tier": "",
        "expiresAt": "2026-12-31T00:00:00.000Z",
        "activatedAt": "2026-09-15T08:22:09.118Z",
        "deviceLimit": 3,
        "devicesUsed": 1,
        "unlimitedDevices": false
      },
      "product": {
        "name": "Example Product",
        "version": "1.4.2",
        "minVersion": "1.3.0",
        "platform": "Windows"
      },
      "device": {
        "id": "clx8s2k...",
        "os": "Windows 11"
      },
      "user": "alex.dev",
      "account": {
        "username": "alex.dev",
        "status": "Active",
        "twoFactor": false,
        "createdAt": "2026-09-15T08:22:09.118Z",
        "lastLoginAt": "2026-09-15T08:22:09.118Z",
        "lastIp": "203.0.113.9"
      },
      "subscriptions": [
        {
          "tier": "",
          "status": "ACTIVE",
          "expiresAt": "2026-12-31T00:00:00.000Z",
          "secondsLeft": 9244800
        }
      ]
    }
  }
}
```

```text 400 Bad Request theme={null}
{
  "error": true,
  "response": {
    "code": 400,
    "message": "Missing required fields",
    "errorCode": "MISSING_FIELDS"
  }
}
```

```text 401 Unauthorized theme={null}
{
  "error": true,
  "response": {
    "code": 401,
    "message": "License not found",
    "errorCode": "LICENSE_NOT_FOUND"
  }
}
```

```text 409 Conflict theme={null}
{
  "error": true,
  "response": {
    "code": 409,
    "message": "License is already in use",
    "errorCode": "LICENSE_IN_USE"
  }
}
```

```text 409 Conflict theme={null}
{
  "error": true,
  "response": {
    "code": 409,
    "message": "Username is taken or invalid",
    "errorCode": "USERNAME_TAKEN"
  }
}
```

```text 429 Rate Limited theme={null}
{
  "error": true,
  "response": {
    "code": 429,
    "message": "Too many registration attempts, slow down"
  }
}
```

```text 500 Server Error theme={null}
{
  "error": true,
  "response": {
    "code": 500,
    "message": "Registration failed"
  }
}
```

Same two extras as login can ride along in `data`: an `offline` assertion (cached by the SDK for `checkOffline()`) and a soft `product.updateAvailable` hint with `latest` and optional `downloadUrl` when **autoUpdate** is on and your build is behind. `session` is omitted when the operator's plan has no token system.

## Behavior

* The license key is looked up by its hash. An unknown key answers `LICENSE_NOT_FOUND` (401) and writes nothing to the audit log — an unauthenticated caller must not be able to fill another account's trail with noise. It also adds abuse weight against the caller's IP.
* A key already linked to another user answers `LICENSE_IN_USE` (**409**, not 403). Two registrations racing on one key cannot both attach: the claim check and the link happen inside the same guarded flow.
* Username uniqueness is per-product (`@@unique([productId, username])`), not global. The same username can exist on different products. A taken name and a malformed name give the same answer: 400 `USERNAME_TAKEN` (`Username is taken or invalid`) — there is no separate `INVALID_USERNAME` code.
* A missing/short password answers 400 `WEAK_PASSWORD`. A missing `key`/`hwid` answers 400 `MISSING_FIELDS`.
* After the account is created, the full activation flow runs: gates (kill switch, license state, blacklist, platform, version, build hash, VM block), device binding, and session token issuance. Gate failures get their own accurate code from `runGates`.
* There is no per-plan account cap on this route — no `USER_LIMIT_REACHED` code exists server-side.
* Session tokens are only returned when the operator's plan includes the token system. Without it, the response omits the `session` field.
* **Audit trail**: successful registrations write a `LicenseEvent` (`account <user> registered`) and emit an `activation.succeeded` webhook — the same event as a key activation, with `user` attached. No `user.registered` event exists.
* **SDK result**: `register()` stores the session/license/device/account/subscriptions and emits `registered`, then `activated`. `client.snapshot()` returns `{ app, license, product, device, user, account, subscriptions, valid }`.

## Signed Responses

Every response from this endpoint carries Ed25519 signature headers when a signing key is configured (`v2` scheme):

| Header | Value |
| - | - |
| `X-Frost-Signature` | Base64url Ed25519 signature of `timestamp\nnonce\nmethod\npath\nstatus\nbody` |
| `X-Frost-Timestamp` | Unix millisecond timestamp |
| `X-Frost-Nonce` | Echoed from the request's `X-Frost-Nonce` header |
| `X-Frost-Key-Id` | Identifier of the signing key used |

The SDK verifies all of this automatically and throws `FrostAuthError` (carrying the server's `errorCode` as `code`, plus `status` and `requestId`) on failure. Unsigned `429`s from the rate limiter are treated as retryable, not as forgeries.

When `requireSignedRequests` is enabled for the product, inbound freshness is enforced **before** the handler runs: timestamp within 60 seconds, nonce 16–128 chars and unique (Redis `SET NX`, 180s TTL). Stale requests get `SESSION_EXPIRED`, replays get `FORBIDDEN`.

## Rate Limits

| Layer | Limit | Keyed by |
| - | - | - |
| Register limiter | 10 per 1 min | Hashed key prefix + IP |
| IP floor | 60 per 1 min | IP |
| Abuse guard | Escalating block after repeated unknown keys | IP |

The register limiter's own answer (`Too many registration attempts, slow down`) carries no `errorCode`. The IP floor answers with `errorCode: "RATE_LIMITED"`; the abuse guard adds `retryAfterSeconds`.

## Errors

Operators can reword any message per product — match on `errorCode`, never on message text.

| HTTP | errorCode | Message (default) | Cause |
| - | - | - | - |
| `400` | `MISSING_FIELDS` | `Missing required fields` | `key` or `hwid` missing. |
| `400` | `USERNAME_TAKEN` | `Username is taken or invalid` | Bad format (`^[a-zA-Z0-9._-]{3,32}$`) or taken on this product. |
| `400` | `WEAK_PASSWORD` | `Password must be at least 8 characters` | Password too short (also covers missing). |
| `401` | `LICENSE_NOT_FOUND` | `License not found` | Unknown key. Also triggers abuse weight 2. |
| `403` | (gate code) | (configured message) | Any activation gate failure (expired, banned, platform mismatch, etc.). |
| `409` | `LICENSE_IN_USE` | `License is already in use` | Key already linked to another account, or claimed during a race. |
| `409` | `USERNAME_TAKEN` | `Username is taken or invalid` | Username exists on this product. |
| `429` | `RATE_LIMITED` | `Too many requests, please try again later` | Abuse guard block. Carries `retryAfterSeconds`. |
| `429` | — | `Too many registration attempts, slow down` / `Too many requests, slow down` | Route/IP limiters. |
| `500` | — | `Registration failed` | Unhandled exception. |


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