# Licence contract, version 1

The contract every paid plugin in the family speaks to switch a paid tier on: NoticeSolved's three
Pro add-ons, AEOSearch for WordPress's paid plan, and any plugin on the hosted tier. Its home is
licencebook (`MeeshaBear1/licencebook`), which serves it at `https://licencebook.vercel.app` until
a custom host is chosen. The shape is `contract/licence-v1.schema.json`; the engine's tests hold
both files to the code. Written 13 October 2026.

## The idea in four sentences

A customer pays for a Stripe subscription. licencebook derives their key from that subscription
and never stores the key itself. The plugin sends the key and the site's hostname, and gets back
what the key pays for, with a signed copy of that answer. The plugin keeps the signed copy for 24
hours, so a licencebook outage never switches off a customer who has paid.

## The three calls

All three are `POST`, JSON in and JSON out, with the same request body:

```json
{ "key": "lb_<40 hex>", "site": "shop.example.com", "product": "noticesolved-ai-act-pro", "plugin_version": "1.4.0" }
```

- `site` is a bare hostname: no scheme, no path, no port, lower case. A plugin sends
  `wp_parse_url( home_url(), PHP_URL_HOST )` or the shop's myshopify domain.
- `product` is one of `noticesolved-ai-act-pro`, `noticesolved-cookie-consent-pro`,
  `noticesolved-accessibility-pro`, `aeowp-paid`. The hosted tier adds products by name.
- `plugin_version` is the plugin's own version string, kept for support and never used to refuse.

| Call | What it does |
|---|---|
| `/api/licence/v1/check` | Answers what the key pays for on this site. Moves nothing. |
| `/api/licence/v1/activate` | Takes a seat for this site, then answers as `check` does. Activating a site that already holds a seat takes no second one. |
| `/api/licence/v1/deactivate` | Frees this site's seat. Answers `{ "ok": true, "sites_used": n }`. Works on a lapsed key too, so a customer can always move a site. |

## The answer

Granted:

```json
{ "ok": true, "tier": "pro", "features": ["file", "vendors"], "seats": 1, "sites_used": 1,
  "expires_at": "2026-11-12T22:00:00Z", "token": "<payload>.<signature>", "checked_at": "2026-10-13T22:00:00Z" }
```

- `seats` is how many sites the key may be switched on for; `null` means no limit.
- `sites_used` is how many hold a seat now; `null` means the activation store is not connected,
  and the answer came from Stripe alone.
- `expires_at` is the end of the period the customer has paid for.

Refused, with exactly one reason from a closed list:

| `reason` | Meaning | What the plugin shows |
|---|---|---|
| `unknown_key` | No subscription carries this key. | The key as typed is wrong; ask for it again. |
| `revoked` | We switched this key off by hand (a refund, a chargeback, a key posted in public). | Point the customer at support. |
| `expired` | The subscription is cancelled, unpaid, or past the end of its period. | Offer the renewal link. |
| `seat_limit` | Every seat is taken and this site holds none. | List the sites, and let the customer free one. |
| `product_mismatch` | The key is for another product in the family. | Name the product the key belongs to. |

Status codes carry one meaning each. `200` is an answer, granted or refused, and the plugin acts on
it. `400` is a malformed request, with an `error` sentence naming the field. `429` is the rate
limit (60 requests a minute from one address). `503` means licencebook cannot answer, with an
`error` sentence naming what is not connected. On anything but `200`, and on a network failure,
the plugin keeps its last signed answer and changes nothing, for as long as that answer's
`expires_at` is in the future. A `200` refusal drops the cached answer at once.

## The token

The token is the answer, signed with Ed25519, so a plugin can check it with no network call.

```
token = base64url(bytes) + "." + base64url(signature)
bytes = canonical JSON of { "v": 1, "domain": "licencebook:licence:v1", "payload": {...}, "timestamp": checked_at }
```

Canonical JSON is the fleet's form (`fleet-attestation/docs/CONTRACT.md`): keys sorted, no
whitespace, integers only, UTF-8. The payload carries `key_id` (the first 16 hex characters of
`sha256(key)`), `product`, `site`, `tier`, `features`, `seats`, `expires_at` and `cache_until`
(24 hours after the check).

A plugin trusts a token only when six things hold: the signature verifies against the public key
compiled into the plugin; `domain` is `licencebook:licence:v1`; `payload.site` is its own host;
`payload.product` is its own product; `payload.key_id` matches its own key; and `cache_until` is
in the future. In PHP the signature is one call, `sodium_crypto_sign_verify_detached( $sig, $bytes, $public_key )`,
available in WordPress core since 5.2. `clients/wordpress/` and `clients/shopify/` in licencebook
are the reference clients.

The signing key's public half is published on licencebook's first screen and in its repo under
`keys/`. Rotating it is a new `domain` (`licencebook:licence:v2`) and a plugin release.

## Where the record lives

Stripe is the record of every subscription. A key is `lb_` and the first 40 hex characters of
`HMAC-SHA256(salt, subscription_id)`: the same subscription and salt always give the same key, and
the key cannot be turned back into the subscription. licencebook writes two values onto the
subscription's metadata when it issues a key, `licence_product` and `licence_key_sha256` (the hash
of the key, never the key), and a third, `licence_revoked`, when it revokes one. If every other
store is lost, every key still checks, from Stripe and the salt alone.

Site activations are the one thing Stripe cannot hold. They live in one Redis set per subscription
on a Vercel KV (Upstash) store. Losing it costs the seat counts and nothing else.

## What this contract leaves out, on purpose

A billing portal, dunning, per-feature flags beyond `features[]`, and any customer-facing screen.
The plugin owns its own settings screen; licencebook answers questions about keys.

## Changes

A field is added without a version change; a plugin ignores fields it does not know. A field
removed, a reason added to the closed list, or a change to the signed bytes is `v2`, served beside
`v1` for at least one year.
