# License Server API v1

REST API for the **Apnar Business Licence Server**. Two API families live under `/api/v1`:

| Family | Base path | Auth | Used by |
|---|---|---|---|
| Client API | `/api/v1/license/*` | none (key-based) | The client software phoning home |
| Admin API | `/api/v1/admin/*` | Bearer token (Sanctum) | The platform / admins |

All requests and responses are JSON.

---

## Authentication (Admin API)

Issue a token for an existing admin user:

```bash
php artisan license:create-token admin@example.com --name=platform --days=365
```

Send it on every admin request:

```
Authorization: Bearer <token>
```

- Tokens expire; re-issue with the same command.
- Admin routes are rate-limited to **120 requests / minute / token** (`throttle:120,1`).
- Failed / missing auth → `401`.
- The token only appears once at creation — copy it immediately.

---

## Conventions

- **Success** responses use `200` / `201`.
- **Validation errors** → `422` with `{ message, errors: { field: [ ... ] } }`.
- **Not found** → `404` with `{ message }`.
- Collection endpoints are **paginated** (Laravel paginator):
  ```json
  {
    "current_page": 1,
    "data": [ ... ],
    "total": 42,
    "per_page": 20,
    "last_page": 3,
    ...
  }
  ```
  Pass `?per_page=25` (default `20`) to control page size.

---

# Client API

Used by installed client software. No auth token — the **license key** itself is the credential.

## POST `/api/v1/license/activate`

Register a domain against a license and receive a signed license document.

| Field | Type | Required | Notes |
|---|---|---|---|
| `license_key` | string | yes | The customer's license key |
| `domain` | string | yes | Lowercased automatically |
| `instance_id` | string | no | Unique install id (optional) |
| `app` | string | no | Product `app_key`; must match the license's product |
| `app_version` | string | no | Client version, echoed into the document |

**Success `200`** — a signed document (see [Signed document format](#signed-document-format)):

```json
{
  "payload": "eyJsaWNlbnNlX2tleSI6...",
  "signature": "E3v1k9FZ..."
}
```

Decoded `payload` example:

```json
{
  "license_key": "EXCELLENTORS-XXXX-XXXX-XXXX-XXXX",
  "client": "Acme Ltd",
  "plan": "annual",
  "max_domains": 1,
  "instance_id": "inst-1",
  "app": "excellentors",
  "app_version": "1.0.0",
  "allowed_domains": ["app.example.com"],
  "issued_at": "2026-08-09T07:00:00+00:00",
  "valid_until": "2026-09-08T00:00:00+00:00",
  "status": "active"
}
```

**Errors**

| Status | `error_code` | Meaning |
|---|---|---|
| 404 | `invalid_key` | Key not found / product inactive / app mismatch |
| 422 | `invalid_domain` | Empty domain |

Domain rules: an existing active domain is reused; if the domain limit (`license.max_domains` or `product.max_domains`, min 1) is reached, the new domain is **silently not added** — the client sees its domain absent from `allowed_domains` and locks itself as `domain_mismatch`.

## POST `/api/v1/license/validate`

Phone-home check. Same request shape as `activate`; updates `last_seen_at` / `instance_id` for the registered domain and returns a fresh signed document. Does **not** register new domains.

## POST `/api/v1/license/ping`

Alias of `validate` — lightweight reachability + status check.

### Signed document format

```json
{
  "payload": "<base64(canonical JSON)>",
  "signature": "<base64(RSA-SHA256 over the base64 payload string)>"
}
```

The client decodes `payload`, then verifies `signature` with the **product's public key** (from `LICENSE_PUBLIC_KEY` in the client `.env`).

Decoded `payload` fields:

| Field | Description |
|---|---|
| `license_key` | The license key |
| `client` | Customer name |
| `plan` | e.g. `monthly`, `annual`, `lifetime` |
| `max_domains` | Effective domain limit |
| `instance_id` | Echoed from request |
| `app` | Product `app_key` |
| `app_version` | Echoed from request |
| `allowed_domains` | Currently active domains (`[]` until one registers) |
| `issued_at` | Server time of signing |
| `valid_until` | Licence expiry (client keeps running until `valid_until + grace_days`) |
| `status` | `active`, `grace`, or `revoked` (blocked/revoked licenses always report `revoked`) |

---

# Admin API

## Dashboard

### GET `/api/v1/admin/dashboard`

```json
{
  "success": true,
  "data": {
    "totals": { "licenses": 12, "customers": 8, "products": 2 },
    "by_status": { "active": 9, "grace": 1, "expired": 1, "blocked": 1, "revoked": 0 },
    "expiring_soon": 2,
    "overdue": 2,
    "pending_webhooks": 0,
    "failed_webhooks": 1
  }
}
```

### GET `/api/v1/admin/events`

License event audit trail. Paginated.

| Query | Description |
|---|---|
| `type` | Filter by event type (e.g. `activated`, `extended`, `payment_received`, `blocked`) |
| `license_id` | Filter by license |
| `per_page` | Page size |

### GET `/api/v1/admin/webhooks`

Outbox rows for webhook delivery to the platform. Paginated.

| Query | Description |
|---|---|
| `status` | `pending` \| `sent` \| `failed` |
| `per_page` | Page size |

---

## Products

`/api/v1/admin/products`

### List — GET `/api/v1/admin/products`

Query: `active_only=1` (only active products), `per_page`.

### Create — POST `/api/v1/admin/products`

| Field | Type | Required | Notes |
|---|---|---|---|
| `app_key` | string | yes | Unique; used to match client `app` |
| `name` | string | yes | |
| `support_email` | string | no | |
| `is_active` | boolean | no | default `true` |
| `grace_days` | integer | no | default `7` |
| `check_interval_hours` | integer | no | default `24` |
| `max_domains` | integer | no | default `1` |

An **RSA-2048 keypair is generated automatically** unless you supply `rsa_private_key` / `rsa_public_key`. The private key is encrypted at rest.

### Show — GET `/api/v1/admin/products/{product}`

### Update — PUT/PATCH `/api/v1/admin/products/{product}`

Same fields as create (all optional). **`app_key` and keypair cannot be changed here** — use regenerate.

### Regenerate keypair — POST `/api/v1/admin/products/{product}/regenerate-key`

Replaces the RSA keypair. ⚠️ **Destructive** — every existing client install that verifies with the old public key will stop working.

```json
{
  "success": true,
  "data": {
    "id": 3,
    "app_key": "excellentors",
    "public_key_base64": "LS0tLS1CRUdJTiBQVUJMSUM...",
    "warning": "Existing client installs are now invalid. Update LICENSE_PUBLIC_KEY in every client .env."
  }
}
```

Distribute `public_key_base64` to clients as their `LICENSE_PUBLIC_KEY`.

### Delete — DELETE `/api/v1/admin/products/{product}`

422 if the product still has licenses.

---

## Customers

`/api/v1/admin/customers`

### List — GET `/api/v1/admin/customers`

Query: `search` (name / email / `platform_user_id`), `per_page`.

### Create — POST `/api/v1/admin/customers`

| Field | Type | Required |
|---|---|---|
| `name` | string | yes |
| `email` | string | yes (unique) |
| `phone` | string | no |
| `platform_user_id` | string | no (unique) |
| `notes` | string | no |

### Show — GET `/api/v1/admin/customers/{customer}`

Includes the customer's licenses.

### Update — PUT/PATCH `/api/v1/admin/customers/{customer}`

All fields optional.

### Delete — DELETE `/api/v1/admin/customers/{customer}`

422 if the customer still has licenses.

---

## Licenses

`/api/v1/admin/licenses`

### List — GET `/api/v1/admin/licenses`

Each row includes `product`, `customer`, `domains_count`, `events_count`.

| Query | Description |
|---|---|
| `search` | License key, customer name, or customer email |
| `status` | `active` \| `blocked` \| `revoked` — **or effective status** `grace` \| `expired` (accounts for the product's grace window) |
| `product_id` | Filter by product |
| `expiring_soon` | `1` = active licenses expiring within 7 days |
| `per_page` | Page size |

### Create — POST `/api/v1/admin/licenses`

The license `key` is generated automatically (`APP_KEY-XXXX-XXXX-XXXX-XXXX`).

| Field | Type | Required | Notes |
|---|---|---|---|
| `product_id` | int | either | Product id |
| `app_key` | string | either | Product app key (alternative to `product_id`) |
| `customer_id` | int | either | Existing customer |
| `platform_user_id` | string | either | Creates/finds customer by platform user id |
| `email` | string | either | Creates/finds customer by email |
| `name` | string | no | Used when creating a new customer |
| `plan` | string | no | `monthly` \| `quarterly` \| `annual` \| `lifetime` \| `custom` |
| `status` | string | no | `active` \| `blocked` \| `revoked` (default `active`) |
| `max_domains` | int | no | Defaults to product's limit |
| `valid_until` | date | either | Exact expiry (ISO8601) |
| `days` | int | either | Expiry = now + days |
| `payment_reference` | string | no | Logs a `payment_received` event + webhook |
| `amount` | number | no | Stored on the payment event |
| `notes` | string | no | |

`valid_until`/`days` omitted = lifetime license.

### Show — GET `/api/v1/admin/licenses/{license}`

Includes `product`, `customer`, `domains`, latest 50 `events`, and a computed `effective_status`.

### Update — PUT/PATCH `/api/v1/admin/licenses/{license}`

Fields (all optional): `plan`, `valid_until`, `max_domains`, `status`, `notes`. Logs an `updated` event.

### Delete — DELETE `/api/v1/admin/licenses/{license}`

422 if the license has events or domains — **revoke instead**.

### Block — POST `/api/v1/admin/licenses/{license}/block`

Sets status `blocked` (clients see `revoked`), logs event, fires `license.blocked` notification.

### Unblock — POST `/api/v1/admin/licenses/{license}/unblock`

Sets status `active`, fires `license.unblocked`.

### Revoke — POST `/api/v1/admin/licenses/{license}/revoke`

Permanent; sets status `revoked`, fires `license.revoked`.

### Extend — POST `/api/v1/admin/licenses/{license}/extend`

| Field | Type | Notes |
|---|---|---|
| `valid_until` | date | Either this or `days` |
| `days` | int | `>= 1` |
| `payment_reference` | string | |
| `amount` | number | |
| `note` | string | |

Sets status to `active`, logs `extended` + `payment_received`, fires a `payment_received` notification.

### Add domain — POST `/api/v1/admin/licenses/{license}/domains`

```json
{ "domain": "shop.example.com", "instance_id": "inst-2" }
```

Reactivates an existing domain; otherwise enforces `max_domains` (422 when full). Logs `domain_added`.

### Remove domain — DELETE `/api/v1/admin/licenses/{license}/domains/{domain}`

`domain` is the literal domain string (lowercased). Logs `domain_removed`.

---

## Payments (platform → license server)

### POST `/api/v1/admin/payments`

Report a payment and extend the matching license. Preferred over calling `extend` directly — this is the endpoint the platform (or payment gateway) calls after a successful checkout.

| Field | Type | Required | Notes |
|---|---|---|---|
| `license_key` | string | either | Look up license by key |
| `license_id` | int | either | Look up license by id |
| `payment_reference` | string | yes | Invoice / payment id |
| `amount` | number | no | |
| `valid_until` | date | either | Exact new expiry |
| `days` | int | either | Expiry = now + days |
| `note` | string | no | |

Response `200` includes the refreshed license (with `product`, `customer`, `domains`). Unknown key → `404`.

---

# Outbound webhooks (license server → platform)

Events are delivered to `WEBHOOK_PLATFORM_URL` as `POST` with an **HMAC-SHA256 signature** header for verification:

```
X-Webhook-Signature: <hex sha256 of canonical body using WEBHOOK_PLATFORM_SECRET>
```

Body:

```json
{
  "event": "payment_received",
  "data": {
    "license_key": "EXCELLENTORS-XXXX-XXXX-XXXX-XXXX",
    "product": "excellentors",
    "customer": "customer@example.com",
    "status": "active",
    "valid_until": "2026-09-08T00:00:00+00:00",
    "event": "payment_received",
    "note": "INV-1234"
  },
  "sent_at": "2026-08-09T07:00:00Z"
}
```

Events: `payment_received`, `license.grace`, `license.expired`, `license.blocked`, `license.unblocked`, `license.revoked`.

Delivery is retried (5 attempts, exponential backoff) via `license:webhooks:send`. Verify the signature against `WEBHOOK_PLATFORM_SECRET`.

---

# Environment config

| Key | Purpose |
|---|---|
| `WEBHOOK_ENABLED` | Master switch for outbound webhooks |
| `WEBHOOK_PLATFORM_URL` | Platform webhook receiver URL (empty = no delivery) |
| `WEBHOOK_PLATFORM_SECRET` | HMAC signing secret |
| `LICENSE_EMAIL_ENABLED` | Master switch for direct customer emails |

---

# Quick reference

| Method | Endpoint |
|---|---|
| POST | `/api/v1/license/activate` |
| POST | `/api/v1/license/validate` |
| POST | `/api/v1/license/ping` |
| GET | `/api/v1/admin/dashboard` |
| GET | `/api/v1/admin/events` |
| GET | `/api/v1/admin/webhooks` |
| GET/POST/PUT/DELETE | `/api/v1/admin/products[/{id}]` |
| POST | `/api/v1/admin/products/{id}/regenerate-key` |
| GET/POST/PUT/DELETE | `/api/v1/admin/customers[/{id}]` |
| GET/POST | `/api/v1/admin/licenses` |
| GET/PUT/DELETE | `/api/v1/admin/licenses/{id}` |
| POST | `/api/v1/admin/licenses/{id}/block` \| `/unblock` \| `/revoke` \| `/extend` |
| POST/DELETE | `/api/v1/admin/licenses/{id}/domains[/{domain}]` |
| POST | `/api/v1/admin/payments` |
