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

# Verify a license signature offline

> Confirm an Omnichar license was signed by Omnichar using the published Ed25519 public keys and the canonical license fields. Node and Python examples.

Each license is signed with Ed25519 when it is issued. With the published public key you can check a license you hold without calling Omnichar, for example inside a pipeline with no network access. The signature proves who issued the license and what it says. Whether it has since been revoked is answered only by [verify](https://docs.omnichar.org/api/verify.md).

## 1. Get the public keys

```bash theme={null}
curl -s https://api.omnichar.org/v1/.well-known/license-keys
```

```json theme={null}
{
  "keys": [
    { "kid": "live-2026-11-09", "env": "production", "alg": "Ed25519", "public_key": "Wb2y..." },
    { "kid": "test-2026-11-09", "env": "sandbox", "alg": "Ed25519", "public_key": "y1Qp..." }
  ]
}
```

`public_key` is the raw 32-byte key, base64url-encoded. Pick the key whose `kid` matches the license's `kid`. The list can be cached; it changes only when a key is rotated.

## 2. Rebuild the signed fields

The signature covers exactly these fields, taken from the license or from the verify answer, plus `"v": 1`:

`v`, `key`, `env`, `kid`, `char_id`, `provider_team_id`, `licensee_team_id`, `scope`, `scope_version`, `starts_at`, `ends_at`

They are serialised as JSON with keys sorted at every level and no whitespace. `status` is not signed.

## 3. Check the signature

### Node

```js theme={null}
import { createPublicKey, verify } from 'node:crypto'

function canonical(value) {
  if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
  if (value && typeof value === 'object') {
    return `{${Object.keys(value)
      .filter((key) => value[key] !== undefined)
      .sort()
      .map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`)
      .join(',')}}`
  }
  return JSON.stringify(value)
}

export function verifyLicenseSignature(license, publicKey) {
  const fields = {
    v: 1,
    key: license.key,
    env: license.env,
    kid: license.kid,
    char_id: license.char_id,
    provider_team_id: license.provider_team_id ?? license.provider.team_id,
    licensee_team_id: license.licensee_team_id,
    scope: license.scope,
    scope_version: license.scope_version,
    starts_at: license.starts_at,
    ends_at: license.ends_at,
  }
  const key = createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: publicKey }, format: 'jwk' })
  return verify(null, Buffer.from(canonical(fields)), key, Buffer.from(license.signature, 'base64url'))
}
```

### Python

Uses the [`cryptography`](https://pypi.org/project/cryptography/) package.

```python theme={null}
import base64
import json

from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey


def _b64url(text: str) -> bytes:
    return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))


def verify_license_signature(license: dict, public_key: str) -> bool:
    provider = license.get("provider_team_id") or license["provider"]["team_id"]
    fields = {
        "v": 1,
        "key": license["key"],
        "env": license["env"],
        "kid": license["kid"],
        "char_id": license["char_id"],
        "provider_team_id": provider,
        "licensee_team_id": license["licensee_team_id"],
        "scope": license["scope"],
        "scope_version": license["scope_version"],
        "starts_at": license["starts_at"],
        "ends_at": license["ends_at"],
    }
    payload = json.dumps(fields, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
    try:
        Ed25519PublicKey.from_public_bytes(_b64url(public_key)).verify(_b64url(license["signature"]), payload)
        return True
    except InvalidSignature:
        return False
```

Use the timestamps exactly as the API returns them (for example `2026-12-01T00:00:00.000Z`); reformatting a date changes the bytes and the check fails.

The Python and Node versions produce the same bytes for strings, whole numbers, booleans and lists of them, which is everything the known scope fields hold. If you add your own scope fields, keep them to those types: a decimal such as `1.0` is written differently by the two languages.


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