openapi: 3.1.0 info: title: Keygate API description: | License management API for software products. ## Authentication There are three kinds of caller, each with its own credential. **SDK and updaters (no API key).** `/license/*` and the release feeds (`/releases/{product_slug}/*`) take no API key. The `license_key` in the request body is the credential; embedding an additional API key in client binaries would add nothing, since anyone can extract it. A product's feeds are public unless the product sets `feed_license_required`; then they answer `401 LICENSE_KEY_REQUIRED` unless the updater sends the signed license token or the license key (see the `license_token` and `X-License-Key` parameters). Trust in the artifacts comes from the per-product Ed25519 signature on each one, not from URL secrecy. **Members' keys.** Each accepted member of a license has a license key of their own, shown to them in the portal. It works wherever the license's key does and opens the same license, but keeps to the member's own devices and floating sessions: each records whose key made it, and a member's key verifies, releases or renews only its own. A device or session stays whoever's key made it; the license's key manages all of them. The one exception is a device activated with the license's key before members had keys of their own, when members shared that key: it moves, once, to the member whose own key activates it. Removing the member clears the key and removes their devices and sessions. When a license holds more people than its seat limit (the plan's `max_seats`, or the seats bought; the owner counts), the members who joined last are paused: their keys answer activation with `403 SEAT_PAUSED`, and verification, checkout and usage as keys that may not be used (releasing their own devices still works), until the owner removes a member or adds seats, which lets them back at once. An offline token issued before the pause runs to its own expiry. The owner is never paused. A license gets there only when its seat limit drops under it: a plan's `max_seats` lowered, or the seats bought lowered in Stripe itself; a plan change from the portal or the admin API refuses to go below the people on it, and an invitation cannot be accepted while no seat is free. A member's key works only while the product has seats (a SaaS or hybrid product): should its type change to one without, member keys answer as unknown keys until it changes back. The owner keeps the license's own key, which does not change once issued; only an admin reissues it (`POST /admin/licenses/{id}/rotate-key`). **Customers (portal session).** `/portal/*` and `POST /auth/logout` need a signed-in customer. A sign-in (`POST /auth/otp/verify`, `POST /signup/{checkout_id}/verify`, `POST /invites/accept`) sets two HttpOnly, SameSite=Lax cookies: `session`, a JWT valid for 24 hours, and `refresh_token`, valid for 30 days and sent only to `POST /auth/refresh`, which issues a new pair. The session JWT is also accepted as `Authorization: Bearer `. Without a valid session these routes answer `401 UNAUTHORIZED`. **Servers and the dashboard (`/admin/*`).** An admin dashboard session (the same `session` cookie, held by a user with an admin role) or an API key, `Authorization: Bearer kg_live_…`. Mint keys in the dashboard under **API Keys**. A key carries scopes: | Scope | Reaches | |---|---| | `admin` | every `/admin/*` route | | `licenses:write` | the license routes tagged **Admin Licenses** | | `releases:write` | the release routes tagged **Admin Releases** | A key with no scope reaches nothing. Each admin operation lists the scopes that reach it in `x-required-scopes`. A `licenses:write` or `releases:write` key may be bound to one product. A bound key is held to that product on every route it can reach: lists and the export narrow to it, and naming another product's resource or `product_id` answers `403 PRODUCT_SCOPE_MISMATCH`. An `admin` key cannot be bound (it reaches every product's catalogue and keys anyway), so creating one with a `product_id` is refused with 400. Admin authentication errors: an unknown `kg_live_` key answers `401 INVALID_API_KEY`; no credential, or an invalid or expired session, `401 UNAUTHORIZED`; a key without a scope the route accepts, `403 INSUFFICIENT_SCOPE`; a signed-in user who is not an admin, `403 FORBIDDEN`. ## Idempotency `POST /license/activate`, `POST /license/usage` and `POST /license/floating/checkout` accept an optional `Idempotency-Key` header (1 to 256 characters, no NUL, CR or LF). The middleware answers before the handler runs: - A retry with the same key and the same body within 24 hours replays the first response, with the header `Idempotent-Replayed: true`. 2xx and 4xx responses are stored and replayed; 5xx responses are not, so a retry after a 5xx runs again. A replayed 4xx includes `429 LOCKED_OUT` when that request was the one that tripped the brute force lockout: send a new key once the lockout has passed. - Same key, different body: `422 IDEMPOTENCY_KEY_CONFLICT`. - The first request with the key is still running: `409 IDEMPOTENCY_IN_FLIGHT` with `Retry-After: 1`. - A malformed key: `400 INVALID_IDEMPOTENCY_KEY`. A body over 256 KiB sent with a key: `413 BODY_TOO_LARGE`. Keys are stored per endpoint, not per caller or per license: two clients that happen to send the same key to the same endpoint collide (one of them receives 422 or a replay of the other's response). Use random keys, such as UUIDv4. ## Response Format Every response under `/api/v1` uses one envelope, apart from the raw-body routes listed in `docs/api-contract.md` (the feeds, the license export, the PEM download and the Stripe webhook): ```json // Success {"success": true, "data": {...}} // Error {"success": false, "error": {"code": "ERROR_CODE", "message": "...", "details": {...}}} ``` A given `error.code` always comes with the same HTTP status. `details` is present only for the codes that document it. `204 No Content` is the only success without a body. Every response carries the header `X-Powered-By: Keygate`. This attribution notice, like the "Powered by Keygate" notice on the UI and the `attribution_text` / `attribution_url` fields of `GET /config`, is required by the AGPL v3 terms under Section 7(b) (see NOTICE). ## Rate limits and lockout Requests are counted per client IP: the connection's address, or, when it comes from a proxy listed in `TRUSTED_PROXIES` (private networks when unset), the address that proxy reports: the rightmost untrusted entry of `X-Forwarded-For`, else `X-Real-Ip`. The counts are kept in separate buckets per route family: `/license/*` (at least 120 per minute), the feeds (at least 240 per minute), `/auth/*` and sign-up (`RATE_LIMIT_AUTH`, default 60 per minute), `POST /auth/otp/send` and `POST /signup/{checkout_id}/send` (`RATE_LIMIT_OTP_SEND`, default 30 per hour), `/admin/*` (`RATE_LIMIT_ADMIN`, default 120 per minute), and a few others noted per route. Over the limit a route answers `429 RATE_LIMITED` with `Retry-After` set to the bucket's window in seconds: 60 for the per minute buckets, 3600 for the hourly code-sending bucket. Failed `/license/activate`, `/license/verify` and `/license/deactivate` calls (an unknown key, and for verify an unusable license) are counted per IP. After `BF_MAX_FAILS` failures (default 5) within 5 minutes the IP is locked out of all of `/license/*` for `BF_LOCKOUT_SECONDS` (default 30), doubling on each further lockout up to 30 minutes. The doubling is remembered until 30 minutes after the last lockout ends; a successful call from the IP clears it. A locked out request answers `429 LOCKED_OUT` with `Retry-After` and `details.retry_after` in seconds. ## Existence-oracle hardening The SDK endpoints try not to reveal whether a `license_key` exists: - `/license/verify` answers the same `404 LICENSE_NOT_FOUND` for a key that does not exist, a license that is suspended, revoked, expired or canceled past its paid period, and an identifier that is not activated. - `/license/deactivate` answers `404 LICENSE_NOT_FOUND` for an unknown key or an identifier that is not activated. It does not check the license's status: a suspended, revoked or expired license can still be deactivated. - `/license/entitlements`, `/license/usage*`, `/license/floating/*`, `/license/download` and the portal license guards collapse every unusable or foreign license into `404 LICENSE_NOT_FOUND`. Exceptions a client should know about: activate, verify and deactivate answer `404 FEATURE_NOT_AVAILABLE` for a key of a SaaS product, and activate reports the specific `403` status of an unusable license (activation is how a returning customer learns their license has lapsed). Paid users otherwise discover lifecycle state through email and the portal. ## Other routes The admin dashboard also uses routes not documented here: products, plans, entitlements, add-ons, API keys, settings, team, analytics, audit logs, users, system tasks, signing keys and email templates, all under `/admin/*` and all requiring the `admin` scope. Their request and response shapes follow the dashboard and may change between releases. Outside `/api/v1`, `GET /health` (status, database check as `ok` or `error` with the cause only in the server log, version) and `GET /metrics` (Prometheus) answer without authentication; restrict them at your reverse proxy if they should not be public. `POST /api/v1/webhook/stripe` receives Stripe's events and is not for integrators. `/api/v1/setup/*` serves the first-run setup wizard. version: 1.3.0 contact: name: Keygate url: https://keygate.app license: name: AGPL v3 url: https://www.gnu.org/licenses/agpl-3.0.html servers: - url: "{server}/api/v1" variables: server: default: http://localhost:9000 description: Your Keygate server URL # Public SDK endpoints have no auth at the route layer. Portal and # admin operations declare their own security. security: [] tags: - name: License description: | Activate, verify, and deactivate licenses. With multi-tenancy on, a request to a workspace's own host (`.`) serves only keys that belong to that workspace: another workspace's key answers the same `404 LICENSE_NOT_FOUND` as one that does not exist. Requests to the bare root domain resolve the workspace from the key alone, so a client that only knows `BASE_URL` keeps working. - name: Entitlements description: Check feature access for a license - name: Usage description: Record and query usage against quotas - name: Seats description: Manage the members of a license - name: Floating description: Concurrent session checkout for floating licenses - name: Releases description: License-gated download + auto-update feeds (Sparkle / Velopack / Tauri) - name: Site description: Public site configuration, version and pricing data - name: Checkout description: Hosted checkout links and the checkout success page - name: Signup description: Self sign-up for free plans - name: Auth description: Customer sign-in with an emailed code, session renewal and sign-out - name: Portal description: The signed-in customer's licenses, profile, subscription and devices - name: Admin Licenses description: | License administration for billing backends. Reachable with the `admin` or `licenses:write` scope, or an admin session. - name: Admin Releases description: | Release management for CI/CD. Reachable with the `admin` or `releases:write` scope, or an admin session. - name: Admin Webhooks description: | Webhook endpoints and their deliveries. Requires the `admin` scope or an admin session. - name: Webhooks description: Events Keygate posts to your webhook endpoints paths: /config: get: tags: [Site] summary: Public site configuration description: | Settings the login page, customer portal and dashboard read before anyone signs in, plus the attribution notice the AGPL v3 terms (Section 7(b), see NOTICE) require on every UI surface. `site_name`, `timezone`, `brand_color`, `language` and `logo_url` appear only when an admin has set them. `base_url`, `attribution_text` and `attribution_url` are always present. responses: "200": description: Public settings content: application/json: schema: type: object properties: success: { type: boolean } data: type: object required: [base_url, attribution_text, attribution_url] properties: site_name: { type: string } timezone: { type: string } brand_color: type: string description: Hex colour, `#rgb` or `#rrggbb`. language: { type: string } logo_url: type: string description: An http(s) URL or a path starting with `/`. base_url: type: string description: The public origin serving this workspace — the tenant subdomain when multi-tenancy is on, otherwise BASE_URL. The dashboard builds the feed URLs it shows from it. tenant: type: object description: The workspace the request host resolved to. properties: slug: { type: string } name: { type: string } attribution_text: type: string description: Attribution notice, "Powered by Keygate". Required by the license. attribution_url: type: string description: Link for the attribution notice. Required by the license. /version: get: tags: [Site] summary: Server version responses: "200": description: Build information content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: version: { type: string } commit: { type: string } build_date: { type: string } project: { type: string, example: Keygate } project_url: { type: string, example: "https://keygate.app" } /license/pubkey: get: tags: [License] summary: Get the Ed25519 verification key description: | Returns the Ed25519 public key paired with the server's signing key. SDKs fetch this once (or hard-code it after first run) to verify the offline token returned by /license/activate and /license/verify. Served with `Cache-Control: public, max-age=3600`. responses: "200": description: Verification key content: application/json: schema: type: object properties: success: { type: boolean } data: type: object properties: algorithm: type: string enum: [ed25519] format: type: string enum: [hex] public_key: type: string description: 32-byte Ed25519 public key, hex-encoded (64 chars) /license/activate: post: tags: [License] summary: Activate a license description: | Registers a device or user identifier against a license. Returns a signed verification token. - Re-activating the same identifier returns `already_activated` without consuming an additional slot. A device stays whoever's key activated it: a member's key on a device of another key on the license (another member's, or the license's own) is refused with `409 DEVICE_HELD`, while the license's key finds any device activated. The one move is a device activated with the license's key before members had keys of their own: it moves, once, to the member whose key activates it. - Atomic enforcement of the plan's `max_activations`, times the seats bought on a plan sold per seat (concurrent retries can never exceed the cap) - A license on a floating plan (`license_model: floating`) is refused with `409 FLOATING_LICENSE`: check out a session with `/license/floating/checkout` instead. That includes an identifier activated earlier (before the rule, or before the plan became floating): it no longer gets a token. - `identifier` and `label` are trimmed first. A blank identifier, one longer than 256 bytes, or a label longer than 256 characters is refused with 400. - An unknown key counts as a failure for the brute force lockout (see the top-level docs) - Supports the `Idempotency-Key` header for safe retries parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: type: object required: [license_key, identifier] properties: license_key: type: string example: "KG-ABCD2345-EFGH6789-JKLM2345-NPQR6789" identifier: type: string maxLength: 256 description: Unique device ID or user email. Trimmed; at most 256 bytes. example: "device-a1b2c3d4" identifier_type: type: string enum: [device, user] default: device description: Anything other than `device`, `user` or omitted is refused with 400. label: type: string maxLength: 256 description: Human-readable name for the activation. Trimmed; at most 256 characters. example: "MacBook Pro" responses: "200": description: Activation successful content: application/json: schema: $ref: "#/components/schemas/ActivateResponse" "400": description: | `BAD_REQUEST`: missing fields, blank or too long identifier, invalid `identifier_type`, label too long. Also `INVALID_IDEMPOTENCY_KEY`. content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "403": description: | License not usable now: `LICENSE_EXPIRED`, `LICENSE_CANCELED`, `LICENSE_SUSPENDED`, `LICENSE_REVOKED` or `LICENSE_INVALID`. `SEAT_PAUSED`: a member's key while the license holds more people than seats and this member joined beyond the limit. content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "404": description: | `LICENSE_NOT_FOUND`; or `FEATURE_NOT_AVAILABLE`, the license belongs to a SaaS product, which has no device activation. content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "409": description: | - `ACTIVATION_LIMIT`: slots exhausted, `details: {"max": 3, "current": 3}` - `FLOATING_LICENSE`: the plan is floating; use floating checkout - `DEVICE_HELD`: a member's key on the device of another key on the license; the license owner can remove that device in the portal, after which the member activates it as their own - `IDEMPOTENCY_IN_FLIGHT`: concurrent retry, try again (`Retry-After: 1`) content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "413": $ref: "#/components/responses/IdempotencyBodyTooLarge" "422": $ref: "#/components/responses/IdempotencyConflict" "429": $ref: "#/components/responses/LicenseThrottled" /license/verify: post: tags: [License] summary: Verify a license activation description: | Checks that a license is usable and the identifier is activated. Returns the license status, plan features, and a signed token. A member's key verifies only that member's devices; another device, including one on the license's key, answers as an unknown key would. A device on the license's key from before members had keys of their own moves to the member when they activate it with their key; any other answers that activation with `409 DEVICE_HELD`. The signed token can be verified offline using the server's Ed25519 public key (`GET /license/pubkey`). Token format is `base64url(payload).base64url(ed25519_signature)`, where the signed bytes are the base64url-encoded payload string. `identifier` is trimmed first; blank or longer than 256 bytes is refused with 400. requestBody: required: true content: application/json: schema: type: object required: [license_key, identifier] properties: license_key: type: string identifier: type: string maxLength: 256 responses: "200": description: Verification result content: application/json: schema: $ref: "#/components/schemas/VerifyResponse" "400": description: "`BAD_REQUEST`: missing fields, blank or too long identifier" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "404": description: | `LICENSE_NOT_FOUND`: one answer for a license that doesn't exist, is not usable now (suspended, revoked, expired, or canceled and past its paid period), or has no activation for this identifier. Intentional oracle hardening (see the top-level docs). `FEATURE_NOT_AVAILABLE` for a key of a SaaS product. content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "409": description: | `FLOATING_LICENSE`: the plan is floating. A floating license is used through floating checkout; an identifier activated on it earlier no longer verifies. Deactivate still works. content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/LicenseThrottled" /license/deactivate: post: tags: [License] summary: Deactivate a device or user description: | Removes an activation, freeing a slot for a new device, and sends the `license.deactivated` webhook. The license's status is not checked: a suspended, revoked or expired license can still free its slots. A member's key removes only that member's devices; another device answers as an unknown key would. `identifier` is trimmed first; blank or longer than 256 bytes is refused with 400. requestBody: required: true content: application/json: schema: type: object required: [license_key, identifier] properties: license_key: type: string identifier: type: string maxLength: 256 responses: "200": description: Deactivated content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: status: { type: string, enum: [deactivated] } "400": description: "`BAD_REQUEST`: missing fields, blank or too long identifier" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "404": description: | `LICENSE_NOT_FOUND`: one answer for a missing license or an identifier with no activation (oracle hardening). `FEATURE_NOT_AVAILABLE` for a key of a SaaS product. content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/LicenseThrottled" /license/entitlements: post: tags: [Entitlements] summary: Check feature entitlements description: | Returns all features available for the license's plan, including quota usage for metered features. Includes add-on features if any are attached; an enabled add-on replaces the plan's entitlement for the same feature. With `feature`, only that feature is returned; a feature the license does not have gives an empty `features` object, not an error. requestBody: required: true content: application/json: schema: type: object required: [license_key] properties: license_key: type: string feature: type: string description: Optional. Check a single feature only. responses: "200": description: Entitlements check result content: application/json: schema: $ref: "#/components/schemas/EntitlementResponse" "400": description: "`BAD_REQUEST`: `license_key` missing" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "404": description: "`LICENSE_NOT_FOUND`: unknown key, or a license that is not usable now" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/LicenseThrottled" /license/usage: post: tags: [Usage] summary: Record usage description: | Increments a usage counter for a metered feature (an entitlement of value type `quota`). The increment is **atomic**: concurrent requests will never exceed the quota. Returns the updated counter with remaining balance. A quota with `limit: 0` is unlimited and reports `remaining: -1`. A refused increment sends the `quota.exceeded` webhook, on every refused call. Crossing the warning threshold (`QUOTA_WARNING_THRESHOLD`) sends `quota.warning` once. Also reachable from a portal session as `POST /portal/usage`. parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: type: object required: [license_key, feature] properties: license_key: type: string feature: type: string example: "api_calls" quantity: type: integer format: int64 minimum: 0 default: 1 description: Units to add. 0 or omitted counts as 1; a negative number is refused with 400. metadata: type: object description: Arbitrary metadata stored with the event responses: "200": description: Usage recorded content: application/json: schema: $ref: "#/components/schemas/UsageResponse" "400": description: | - `BAD_REQUEST`: missing fields, negative `quantity`, or `INVALID_IDEMPOTENCY_KEY` - `NO_QUOTA`: the license has no quota entitlement for this feature - `INVALID_INPUT`: `quantity` so large the counter would overflow content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "404": description: "`LICENSE_NOT_FOUND`: unknown key, or a license that is not usable now" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "409": description: "`IDEMPOTENCY_IN_FLIGHT` (`Retry-After: 1`)" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "413": $ref: "#/components/responses/IdempotencyBodyTooLarge" "422": $ref: "#/components/responses/IdempotencyConflict" "429": description: | `QUOTA_EXCEEDED`: the increment would pass the limit; nothing was counted. `details: {"used": 1000, "limit": 1000, "period": "monthly"}`. Waiting does not help until the period resets. Also `RATE_LIMITED` and `LOCKED_OUT` as on every `/license/*` route. content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /license/usage/status: post: tags: [Usage] summary: Get quota status description: | Returns current usage and remaining quota for a feature. A quota with `limit: 0` is unlimited and reports `remaining: -1`. Usage past a limit that was lowered afterwards reports `remaining: 0`. Note that a feature without a quota entitlement answers `404 QUOTA_NOT_FOUND` here, while `/license/usage` answers `400 NO_QUOTA` for the same condition. Also reachable from a portal session as `POST /portal/usage/status`. requestBody: required: true content: application/json: schema: type: object required: [license_key, feature] properties: license_key: type: string feature: type: string responses: "200": description: Quota status content: application/json: schema: $ref: "#/components/schemas/QuotaStatusResponse" "400": description: "`BAD_REQUEST`: missing fields" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "404": description: | `LICENSE_NOT_FOUND`: unknown key, or a license that is not usable now. `QUOTA_NOT_FOUND`: no quota entitlement for this feature. content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/LicenseThrottled" /license/floating/checkout: post: tags: [Floating] summary: Checkout a floating session description: | Acquires a concurrent session slot on a license whose plan is floating (`license_model: floating`). The session expires after the plan's `floating_timeout` minutes (default 30) unless refreshed via heartbeat. - Atomic: concurrent checkouts will never exceed the plan's `max_activations` (times the seats bought, on a plan sold per seat) - Re-checkout with the same identifier refreshes the existing session. A running session stays whoever's key checked it out: the license's key renews any, a member's key renews its own, and any other running session is refused with `409 SESSION_HELD`. Sessions never move; once one lapses, the machine goes to whoever checks it out next. - `identifier` and `label` are trimmed first. A blank identifier, one longer than 256 bytes, or a label longer than 256 characters is refused with 400. - Supports the `Idempotency-Key` header parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: type: object required: [license_key, identifier] properties: license_key: type: string identifier: type: string maxLength: 256 example: "workstation-001" label: type: string maxLength: 256 responses: "200": description: Session checked out content: application/json: schema: $ref: "#/components/schemas/FloatingCheckoutResponse" "400": description: | `BAD_REQUEST`: missing fields, blank or too long identifier, label too long. Also `INVALID_IDEMPOTENCY_KEY`. content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "404": description: | `LICENSE_NOT_FOUND`: unknown key, a license that is not usable now, a SaaS product, or a plan that is not floating. content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "409": description: | - `FLOATING_LIMIT`: all sessions in use, `details: {"active": 5, "max": 5}` - `SESSION_HELD`: this machine's running session was checked out with another key on the license - `IDEMPOTENCY_IN_FLIGHT` (`Retry-After: 1`) content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "413": $ref: "#/components/responses/IdempotencyBodyTooLarge" "422": $ref: "#/components/responses/IdempotencyConflict" "429": $ref: "#/components/responses/LicenseThrottled" /license/floating/checkin: post: tags: [Floating] summary: Release a floating session description: | Releases the identifier's session. A member's key releases only that member's session. Answers 200 whether or not a session was held (or released). `identifier` is trimmed first; blank or longer than 256 bytes is refused with 400. requestBody: required: true content: application/json: schema: type: object required: [license_key, identifier] properties: license_key: type: string identifier: type: string maxLength: 256 responses: "200": description: Session released content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: status: { type: string, enum: [checked_in] } "400": description: "`BAD_REQUEST`: missing fields, blank or too long identifier" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "404": description: "`LICENSE_NOT_FOUND`: unknown key, a license that is not usable now, or a SaaS product" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/LicenseThrottled" /license/floating/heartbeat: post: tags: [Floating] summary: Extend a floating session description: | Refreshes the session expiry by the plan's `floating_timeout`. Call periodically, well inside that timeout, to keep the session alive. When the identifier holds no live session (it never checked out, or its session lapsed and the slot may have gone to another machine), the answer is `404 SESSION_NOT_FOUND`: check out again before continuing. A member's key renews only that member's session; another's answers the same. `identifier` is trimmed first; blank or longer than 256 bytes is refused with 400. requestBody: required: true content: application/json: schema: type: object required: [license_key, identifier] properties: license_key: type: string identifier: type: string maxLength: 256 responses: "200": description: Session extended content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: status: { type: string, enum: [ok] } expires_at: { type: string, format: date-time } remaining_seconds: type: integer description: Seconds until `expires_at` active_sessions: { type: integer } max_sessions: { type: integer } "400": description: "`BAD_REQUEST`: missing fields, blank or too long identifier" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "404": description: | `SESSION_NOT_FOUND`: no live session for this identifier; check out again. `LICENSE_NOT_FOUND`: unknown key, a license that is not usable now, or a SaaS product. content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/LicenseThrottled" /license/download: post: tags: [Releases] summary: Get a license-gated download URL description: | Returns a short-lived presigned URL for the artifact matching `(product, channel, platform, version?)`. Used for manual and gated downloads; the auto-update path uses the feed endpoints below. If `version` is omitted, returns the latest published release in the channel that has an artifact for `platform`. Channel fallback chain applies (beta sees beta + stable, etc.). Perpetual licenses with a maintenance period (`updates_until` in the verify response) may only install releases published at or before that instant. Pinning a later version answers 403 `UPDATES_EXPIRED`; without a pin, "latest" is the newest release published inside the period. The feed endpoints apply the same limit when called with the license token or key. `platform` also accepts the aliases updaters produce (`darwin-aarch64`, `darwin-x86_64`, `windows-x86_64`, `windows-aarch64`, `linux-x86_64`, `linux-aarch64`, `linux-armv7`). requestBody: required: true content: application/json: schema: type: object required: [license_key, platform] properties: license_key: type: string platform: type: string enum: [ darwin-arm64, darwin-x64, windows-arm64, windows-x64, linux-arm64, linux-x64, linux-armhf, ] version: type: string description: "Optional. Empty = latest in channel." example: "1.2.3" channel: type: string enum: [stable, beta, alpha, dev] default: stable responses: "200": description: Signed download URL content: application/json: schema: type: object properties: success: type: boolean data: type: object properties: url: type: string format: uri expires_at: type: string format: date-time version: type: string platform: type: string sha256: type: string file_size: type: integer format: int64 "400": description: "`BAD_REQUEST` (missing fields), `INVALID_PLATFORM`, `INVALID_CHANNEL`" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "403": description: | `UPDATES_EXPIRED`: the requested release was published after the license's maintenance period ended. content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "404": description: | - `LICENSE_NOT_FOUND`: unknown key, a license that is not usable now, or a product without releases - `RELEASE_NOT_FOUND`: no release matches - `PLATFORM_NOT_AVAILABLE`: the release has no uploaded artifact for this platform content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "410": description: "`RELEASE_YANKED`: the pinned version was yanked" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/LicenseThrottled" "503": description: "`STORAGE_DISABLED`: storage not configured on this server" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /invites/accept: post: tags: [Seats] summary: Accept a seat invite (public, token-only) description: | Consumes the plain invite token shipped in the seat-invite email. The token is the proof of email ownership, so no prior session is required. On success the server: - Creates a user record keyed off the seat's email if none exists (lookup is case-insensitive against `users.email`) - Binds `seats.user_id` and stamps `accepted_at` - Clears `invite_token_hash` so the same token can't be replayed - Issues a portal session cookie so the invitee lands in the portal in one click All failure modes (unknown / expired / removed / already accepted token, or a license not usable now: suspended, revoked or expired, past its end plus its grace days, or canceled and past its end; the rule an invite is held to) collapse to one opaque `404 SEAT_INVITE_INVALID` so an attacker can't probe token validity by response shape. When a policy check fails (e.g. license suspended at accept time), the seat row is rolled back to its pre-accept state and the token is consumed anyway; the owner must re-invite to mint a fresh token. With every seat taken (the limit lowered since the invite was sent), it answers `409 SEAT_LIMIT` before anything else is checked or changed: the invitation stands, and is accepted, or refused as above, once a seat is free. IP rate-limited to 60 requests/minute. No authentication header or cookie is required. requestBody: required: true content: application/json: schema: type: object required: [token] properties: token: type: string description: 64-char hex token from the invite email responses: "200": description: "Invite accepted; `Set-Cookie: session=…` issued" content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: user_id: { type: string, format: uuid } email: { type: string, format: email } license_id: { type: string, format: uuid } product_name: { type: string } role: { type: string, enum: [member] } "404": description: Invalid, expired, already-used, or removed token, or a license not usable now (`SEAT_INVITE_INVALID`) content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "409": description: "`SEAT_LIMIT`: no seat is free on the license; the invitation still works once one is" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" /releases/{product_slug}/feed.xml: get: tags: [Releases] summary: Sparkle appcast (per-platform) description: | Sparkle-compatible XML appcast. All channels are served the same way. The feed is public unless the product sets `feed_license_required`, in which case it needs the license token or key (see the parameters) and answers 401 without one. When the product has a minimum_supported_version, every item at or above it carries with that version: Sparkle treats the update as critical for apps whose CFBundleVersion is lower. Feeds limited by an update period lower the version to the newest release the license may install. Errors come back as the JSON envelope; only a 200 is XML. parameters: - $ref: "#/components/parameters/ProductSlug" - $ref: "#/components/parameters/Platform" - $ref: "#/components/parameters/Channel" - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/LicenseKey" - $ref: "#/components/parameters/LicenseToken" responses: "200": description: Sparkle XML appcast content: application/xml: {} "401": description: "`LICENSE_KEY_REQUIRED`: the product requires the license token (or key) on its feeds" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "400": description: Invalid platform / channel / limit, or a `license_key` query parameter content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "404": description: Unknown product_slug, a product without releases, or an unknown or unusable license key or token content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" /releases/{product_slug}/feed.json: get: tags: [Releases] summary: Velopack feed (per-platform) description: | The same feed as /releases/{product_slug}/velopack/releases.{channel}.json, addressed by platform and channel parameters. Velopack's client cannot build this URL; give it the velopack base URL instead. parameters: - $ref: "#/components/parameters/ProductSlug" - $ref: "#/components/parameters/Platform" - $ref: "#/components/parameters/Channel" - $ref: "#/components/parameters/Limit" - name: id in: query required: false description: Velopack package id written as `PackageId`. Defaults to the product slug. schema: { type: string } - $ref: "#/components/parameters/LicenseKey" - $ref: "#/components/parameters/LicenseToken" responses: "200": description: Velopack asset feed content: application/json: schema: $ref: "#/components/schemas/VelopackFeed" "401": description: "`LICENSE_KEY_REQUIRED`: the product requires the license token (or key) on its feeds" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "400": description: Invalid platform / channel / limit, or a `license_key` query parameter content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "404": description: Unknown product_slug, a product without releases, or an unknown or unusable license key or token content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" /releases/{product_slug}/velopack/{platform}/{file}: get: tags: [Releases] summary: Velopack feed and package downloads, at the URLs Velopack builds description: | Give Velopack's UpdateManager the base URL /api/v1/releases/{product_slug}/velopack/{platform}. It requests releases.{channel}.json under it, then downloads each package as {base}/{FileName}, which this route answers with a 302 to storage. The platform segment may be left out (/releases/{product_slug}/velopack/{file}). The platform then comes from a channel named after a runtime (win-x64, win-x64-beta), or from the rid the C# client sends, which is the machine's runtime rather than the app's. Velopack's Rust core, used by its Rust, Node.js, Python and C++ SDKs, sends no rid. Channels: Velopack's default channels win, osx and linux read stable; stable, beta, alpha and dev read that channel; a runtime name with a trailing channel (osx-arm64-beta) reads that channel. Gated feeds: the C# client keeps the base URL's query on both requests, so license_token can go there. The Rust core replaces the query; send X-License-Token or X-License-Key instead. parameters: - $ref: "#/components/parameters/ProductSlug" - name: platform in: path required: true description: Keygate platform, such as windows-x64 or darwin-arm64 schema: type: string - name: file in: path required: true description: releases.{channel}.json, or a package FileName from the feed schema: type: string - name: rid in: query description: .NET runtime identifier sent by the C# client, used only without a platform schema: type: string - $ref: "#/components/parameters/LicenseKey" - $ref: "#/components/parameters/LicenseToken" responses: "200": description: Velopack asset feed (for releases.{channel}.json) content: application/json: schema: $ref: "#/components/schemas/VelopackFeed" "302": description: Redirect to the package in storage (for a package FileName) "401": description: "`LICENSE_KEY_REQUIRED`: the product requires the license token (or key) on its feeds" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "400": description: Unknown platform, or no platform from the path, the channel name or rid content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "404": description: Unknown product_slug, no such release, or an unknown or unusable license key or token content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" /releases/{product_slug}/upgrade.json: get: tags: [Releases] summary: Tauri updater manifest (latest only) description: | Returns the single latest release. The signature is what a Tauri `.sig` file holds: a base64 encoded minisign signature with a trusted comment (timestamp, file and version) and a global signature, verifiable with the product's Tauri public key from GET /admin/products/{id}/signing-key/tauri-pubkey. Embeds `minimum_supported_version` + `minimum_supported_message` when the product has them configured. The server does not see the client's version: Tauri compares versions itself. 204 means there is no release to offer at all. parameters: - $ref: "#/components/parameters/ProductSlug" - $ref: "#/components/parameters/Platform" - $ref: "#/components/parameters/Channel" - $ref: "#/components/parameters/LicenseKey" - $ref: "#/components/parameters/LicenseToken" responses: "200": description: Tauri manifest content: application/json: schema: type: object properties: version: type: string pub_date: type: string format: date-time url: type: string format: uri signature: type: string description: base64 encoded minisign signature file, as in a Tauri .sig file. Empty when the release is unsigned. notes: type: string minimum_supported_version: type: string minimum_supported_message: type: string "204": description: No published release for this channel and platform, or none inside the license's update period "401": description: "`LICENSE_KEY_REQUIRED`: the product requires the license token (or key) on its feeds" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "400": description: Invalid platform / channel, or a `license_key` query parameter content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "404": description: Unknown product_slug, a product without releases, or an unknown or unusable license key or token content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" /products/{product_slug}/plans: get: tags: [Site] summary: Public plan catalogue for a pricing page description: | The product's active plans, for a pricing page shown to visitors who have no account yet. Prices are read from Stripe and cached for 5 minutes; when Stripe cannot be reached in time, `price` and `currency` are null. A bounded list with no paging. A plan's checkout link is `GET /pay/{checkout_id}`. Rate limited per IP (`RATE_LIMIT_API` per minute, own bucket). parameters: - $ref: "#/components/parameters/ProductSlug" responses: "200": description: Active plans content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: plans: type: array items: type: object properties: id: { type: string, format: uuid } name: { type: string } slug: { type: string } license_type: { type: string, enum: [subscription, perpetual, trial] } billing_interval: { type: string } checkout_id: { type: string } price: type: [integer, "null"] format: int64 description: Stripe unit amount in the currency's smallest unit (cents). Null without a Stripe price, or when Stripe could not be read. currency: type: [string, "null"] description: ISO currency code in lower case, as Stripe returns it self_signup: type: boolean description: True for a free plan open to self sign-up (no Stripe price) per_seat: type: boolean description: Sold per seat; `price` is per seat min_quantity: type: integer description: Fewest seats a checkout sells; present on a per-seat plan max_quantity: type: integer description: Most seats a checkout sells; present on a per-seat plan "404": description: Unknown product slug (`NOT_FOUND`) content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" /pay/{checkout_id}: servers: - url: "{server}" description: Served at the root, outside /api/v1 variables: server: default: http://localhost:9000 description: Your Keygate server URL get: tags: [Checkout] summary: Hosted checkout link for a plan (root path) description: | The link to put on a "Buy" button: `{BASE_URL}/pay/{checkout_id}`, outside `/api/v1`. It does not use the JSON envelope: it redirects, and errors are `text/plain`. - A priced plan: a Stripe Checkout session is created (payment mode for a one-time Stripe price, subscription mode otherwise) and the browser is sent there with 307. Stripe returns the buyer to `{BASE_URL}/checkout/success?session_id=…`, or to `{BASE_URL}/pricing` on cancel. - A free plan with self sign-up on: 302 to the web form at `{BASE_URL}/signup/{checkout_id}`. A subscription plan sold per seat (`per_seat`) sells a quantity: the checkout starts at `quantity` (the plan's `min_quantity` when left out) and the buyer can change it on Stripe's page between `min_quantity` and `max_quantity`. The license holds what the subscription bills. parameters: - $ref: "#/components/parameters/CheckoutID" - name: quantity in: query required: false description: Seats to start a per-seat plan's checkout with. Refused (400) out of the plan's range, or above 1 on a plan not sold per seat. schema: { type: integer, minimum: 1 } responses: "302": description: Free self sign-up plan; redirect to the sign-up form "307": description: Redirect to Stripe Checkout "400": description: "`invalid checkout id` (not 8 characters), or a `quantity` the plan does not sell; plain text" content: { text/plain: { schema: { type: string } } } "404": description: "`plan not found`, plain text" content: { text/plain: { schema: { type: string } } } "410": description: The plan is inactive, plain text content: { text/plain: { schema: { type: string } } } "500": description: Stripe refused to create the session, plain text content: { text/plain: { schema: { type: string } } } "503": description: | Plain text. The plan sells an update period while maintenance features are switched off, has no Stripe price and no self sign-up, or its Stripe price could not be read (including when Stripe is not configured). content: { text/plain: { schema: { type: string } } } /webhook/casdoor: post: tags: [Auth] summary: Receive Casdoor identity events description: | Casdoor's deliveries for `new-user`, `add-user`, `signup`, `login`, `update-user` and `delete-user`. Casdoor does not sign its webhooks, so the static `X-Keygate-Webhook-Secret` header must equal `CASDOOR_WEBHOOK_SECRET`; the route answers 404 when either the provider or the secret is unset. A delivery is deduplicated by a hash of its body, so a retry is a no-op. A deletion is audited only: Keygate never deletes an account or a license because Casdoor did. parameters: - name: X-Keygate-Webhook-Secret in: header required: true schema: { type: string } responses: "200": description: Processed, skipped, ignored or already processed content: application/json: schema: type: object properties: status: { type: string, enum: [processed, skipped, ignored, already_processed] } "400": description: "`BAD_REQUEST`: malformed payload or missing action" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": description: "`UNAUTHORIZED`: the shared secret header is missing or wrong" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "404": description: Casdoor webhooks are not configured "429": $ref: "#/components/responses/RateLimited" "500": description: Transient failure; the claim is released so Casdoor's retry runs again /checkout/verify: get: tags: [Checkout] summary: Confirm a Stripe Checkout session on the success page description: | Called by the checkout success page with the `session_id` Stripe appended to the success URL. If the session is paid and the license (or renewal, or upgrade) has not been delivered yet, it is delivered now, so the buyer does not depend on the Stripe webhook arriving first. Each call reads the session from Stripe. Rate limited to 60 requests per minute per IP. parameters: - name: session_id in: query required: true description: The Stripe Checkout session id (`cs_…`) schema: { type: string } responses: "200": description: | - `pending`: not paid yet, or paid but the license is not delivered yet (a background sync keeps trying); poll again - `ok`: delivered - `not_applied`: an upgrade checkout that closed without applying (refunded, or the subscription had already ended) content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object required: [status] properties: status: { type: string, enum: [pending, ok, not_applied] } email: type: string description: The buyer's email. Absent when the session is not paid yet. kind: type: string enum: ["", renewal, upgrade] description: Empty for a new purchase. Absent when the session is not paid yet. "400": description: "`BAD_REQUEST`: `session_id` missing, or Stripe does not know the session" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" "503": description: "`FULFILLMENT_RETRY`: paid, but delivery failed transiently; it is retried, and so may the page" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /auth/providers: get: tags: [Auth] summary: Sign-in methods this server offers responses: "200": description: Available methods content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: otp: type: boolean description: Always true; sign-in with an emailed code dev_login: type: boolean description: True only on a development install, where `POST /auth/dev-login` signs in without a code casdoor: type: object description: Present only when the Casdoor provider is configured properties: enabled: { type: boolean } display_name: { type: string } login_url: { type: string } "429": $ref: "#/components/responses/RateLimited" /auth/casdoor/login: get: tags: [Auth] summary: Start Casdoor sign-in (OIDC authorization code) description: | Redirects to Casdoor with PKCE S256, a nonce and a single-use signed state cookie. `next` must be a site-relative path and defaults to `/portal`. Answers 404 when Casdoor is not configured. parameters: - name: next in: query schema: { type: string } responses: "302": description: Redirect to Casdoor, or to `/login?casdoor_error=...` when the flow cannot start "429": $ref: "#/components/responses/RateLimited" /auth/casdoor/callback: get: tags: [Auth] summary: Finish Casdoor sign-in description: | Verifies state, nonce and the ID token, requires userinfo's `sub` to match the token's, then links the Casdoor subject to a Keygate account and binds that account's unowned licenses and seats. `email_verified` is required before an address is matched or an account created. On success sets the session cookies and redirects to `next`; failures redirect to `/login?casdoor_error=` with one fixed code (`email_unverified`, `not_allowed`, `identity_conflict`, `unavailable`). parameters: - { name: code, in: query, schema: { type: string } } - { name: state, in: query, schema: { type: string } } responses: "302": description: Redirect to the portal or the login page "429": $ref: "#/components/responses/RateLimited" /auth/casdoor/exchange: post: tags: [Auth] summary: Trade a Casdoor token for a Keygate session (desktop) description: | A desktop client that obtained a Casdoor token directly (device flow or loopback PKCE) presents it here and receives a Keygate JWT (24 hours) and refresh token (30 days) in JSON instead of cookies. Supply `access_token` (verified through Casdoor's userinfo endpoint) or `id_token` (signature, issuer and expiry verified; its audience must be one of `CASDOOR_DESKTOP_CLIENT_IDS`). `email_verified` is required unless the subject is already linked. 10 requests per minute. Use the JWT as `Authorization: Bearer` on customer endpoints. Renew by posting the refresh token in the body of `POST /auth/refresh`. requestBody: required: true content: application/json: schema: type: object properties: access_token: { type: string } id_token: { type: string } responses: "200": description: Session issued content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: access_token: { type: string } token_type: { type: string, enum: [Bearer] } expires_in: { type: integer, example: 86400 } refresh_token: { type: string } user: type: object properties: id: { type: string, format: uuid } email: { type: string, format: email } name: { type: string } avatar_url: { type: string } is_admin: { type: boolean } role: { type: string, enum: [owner, admin, user] } "400": description: "`BAD_REQUEST`: neither token supplied, or `id_token` login is not configured" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": description: "`UNAUTHORIZED`: the token is invalid or its audience is not accepted" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "403": description: "`FORBIDDEN`: email not verified, or the account may not sign in" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "409": description: "`IDENTITY_CONFLICT`: this email is linked to a different Casdoor account" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" "503": description: "`PROVIDER_UNAVAILABLE`: Casdoor could not be reached; retry later" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /auth/otp/send: post: tags: [Auth] summary: Email a six-digit sign-in code description: | Mails a code valid for 10 minutes. The answer is the same `{"status": "sent"}` whether or not the address may sign in: with `signup_mode=licensed_only`, an address with no account and no license gets no mail, but the reply does not say so. Without SMTP configured the code is written to the server log instead. Limits: 3 codes per address per 10 minutes (then `429 RATE_LIMITED` with `Retry-After: 600`), and per IP the auth bucket plus `RATE_LIMIT_OTP_SEND` per hour. requestBody: required: true content: application/json: schema: type: object required: [email] properties: email: { type: string, format: email } responses: "200": description: Code sent (or silently not sent, see above) content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: status: { type: string, enum: [sent] } "400": description: "`BAD_REQUEST`: missing or invalid email" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": description: | `RATE_LIMITED`: 3 codes already sent to this address in the last 10 minutes (`Retry-After: 600`), or too many requests from this IP (`Retry-After` is the bucket's window: 3600 for the hourly code-sending bucket, 60 for the per minute auth bucket) content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /auth/otp/verify: post: tags: [Auth] summary: Sign in with the emailed code description: | Checks the code against the newest valid one mailed to the address and uses it up. Each code allows 5 attempts. On success the account is created if it is new, and the `session` and `refresh_token` cookies are set. requestBody: required: true content: application/json: schema: type: object required: [email, code] properties: email: { type: string, format: email } code: { type: string, example: "123456" } responses: "200": description: Signed in; `Set-Cookie` for `session` and `refresh_token` content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: status: { type: string, enum: [ok] } email: { type: string } name: { type: string } is_admin: { type: boolean } role: { type: string, enum: [owner, admin, user] } "400": description: "`BAD_REQUEST`: email or code missing, or email invalid" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": description: | `UNAUTHORIZED`: wrong, expired or already used code, too many attempts on this code (request a new one), or sign-ups are restricted and the address holds no account or license content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" /auth/refresh: post: tags: [Auth] summary: Renew the session description: | Exchanges the `refresh_token` cookie (sent only to this path) for a new `session` cookie and a new refresh token; the presented one is rotated out. A client without cookies (a desktop app) may instead post `{"refresh_token": "..."}` and receives the new JWT and refresh token in JSON, no `Set-Cookie`. Presenting a refresh token that was already rotated is treated as theft: every refresh token of the user is revoked. Own rate limit bucket (`RATE_LIMIT_AUTH` per minute). security: - RefreshCookie: [] requestBody: required: false description: Desktop clients only; omit when the cookie is present content: application/json: schema: type: object properties: refresh_token: { type: string } responses: "200": description: Renewed; `Set-Cookie` for `session` and `refresh_token`, or JSON tokens for the body path content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: status: { type: string, enum: [refreshed] } access_token: { type: string, description: Body path only } token_type: { type: string, enum: [Bearer] } expires_in: { type: integer, example: 86400 } refresh_token: { type: string, description: Body path only } user: { type: object, description: Body path only } "401": description: "`UNAUTHORIZED`: no, unknown, expired or reused refresh token, or the user is gone" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" /auth/logout: post: tags: [Auth] summary: Sign out description: | Deletes the user's refresh tokens (all of them, so every device is signed out at its next renewal) and clears both cookies. The session JWT itself stays valid until it expires. security: - CookieAuth: [] responses: "200": description: Signed out content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: status: { type: string, enum: [logged_out] } "401": $ref: "#/components/responses/SessionRequired" "429": $ref: "#/components/responses/RateLimited" /signup/{checkout_id}: get: tags: [Signup] summary: What a self sign-up form shows description: | A plan with `self_signup` on, no Stripe price and no update period lets a visitor claim a licence by confirming an email address. `GET /pay/{checkout_id}` redirects such a plan to the web form at `/signup/{checkout_id}`, which drives the three calls here. Every other plan (unknown, inactive, priced, not opened) answers the same `404 SIGNUP_UNAVAILABLE`, on all three calls. parameters: - $ref: "#/components/parameters/CheckoutID" responses: "200": description: The plan takes self sign-ups content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: product_name: { type: string } plan_name: { type: string } license_type: { type: string, enum: [subscription, perpetual, trial] } trial_days: { type: integer } consent_text: type: string description: Label of the optional marketing-consent checkbox; empty = no checkbox. "404": description: "`SIGNUP_UNAVAILABLE`" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" /signup/{checkout_id}/send: post: tags: [Signup] summary: Mail a confirmation code description: | Mails the same six-digit code a portal login uses, under the same limits (3 codes per address per 10 minutes, and the login's per-IP buckets). The `signup_mode` setting does not apply: the operator opening the plan is the decision to take sign-ups. The reply is the same whether or not the address already holds a licence on the plan. No code is mailed once the plan's `signup_daily_cap` is used up for the day, whoever asks: an existing customer signs in through the normal login instead. parameters: - $ref: "#/components/parameters/CheckoutID" requestBody: required: true content: application/json: schema: type: object required: [email] properties: email: { type: string, format: email } responses: "200": description: Code sent content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: status: { type: string, enum: [sent] } "400": description: Missing or malformed email (a bare address of at most 254 characters) content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "404": description: "`SIGNUP_UNAVAILABLE`" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": description: | `RATE_LIMITED`, too many codes for this address (`Retry-After: 600`) or from this IP (`Retry-After` 3600 or 60, the window of the bucket that refused it); or `SIGNUP_LIMIT_REACHED`, the plan's daily cap is used up (`Retry-After` = seconds until the next midnight UTC) content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /signup/{checkout_id}/verify: post: tags: [Signup] summary: Confirm the code and claim the licence description: | Checks the code, signs the visitor in to the portal (`Set-Cookie: session=…`) and creates their licence on the plan. The key is emailed with the product's licence template; the response does not carry it. One self sign-up licence per address per product: an address that already holds one (on this plan, or on a plan it has moved to since) is signed in to it (`200`, `status: existing`). The plan's `signup_daily_cap`, when set, limits new licences per UTC day, counting claims made on the plan even if they have moved to another plan since. The portal account is created only once the address holds a licence: a refused claim leaves none behind, so it cannot be used to get past `signup_mode=licensed_only`. The new licence has `signup_source: self_signup`. When the plan has `signup_consent_text`, the visitor's `marketing_consent` answer is stored on the licence with a timestamp. Both are sent in the `license.created` webhook. parameters: - $ref: "#/components/parameters/CheckoutID" requestBody: required: true content: application/json: schema: type: object required: [email, code] properties: email: { type: string, format: email } code: { type: string, example: "123456" } marketing_consent: type: boolean description: Ignored unless the plan has `signup_consent_text`. consent_text: type: string description: | The consent wording the form showed. A `marketing_consent` of true is recorded only when this matches the plan's `signup_consent_text`; if the operator edited the wording since the form loaded, the licence is created without consent. responses: "201": description: Licence created and emailed; session issued content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: status: { type: string, enum: [created] } license_id: { type: string, format: uuid } "200": description: The address already holds a self sign-up licence for the product; session issued content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: status: { type: string, enum: [existing] } license_id: { type: string, format: uuid } license_status: type: string description: The existing licence's status, which may be revoked or expired license_usable: type: boolean description: Whether the existing licence still verifies, judged as `/license/verify` judges it "400": description: Missing code, or missing or malformed email content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": description: Wrong, expired or already-used code, or too many attempts content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "404": description: "`SIGNUP_UNAVAILABLE`" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "409": description: "`PLAN_CHANGED`, the plan was edited mid-claim; try again" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": description: | `SIGNUP_LIMIT_REACHED`, the plan's daily cap is used up (`Retry-After` = seconds until the next midnight UTC); or `RATE_LIMITED`, too many requests from this IP (`Retry-After: 60`) content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "500": description: | Includes the rare case where the licence was created and emailed but signing in failed; a retry signs in to that licence content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /portal/me: get: tags: [Portal] summary: The signed-in user security: - CookieAuth: [] responses: "200": description: Current user content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: id: { type: string, format: uuid } email: { type: string } name: { type: string } avatar_url: { type: string } is_admin: { type: boolean } role: { type: string, enum: [owner, admin, user] } "401": $ref: "#/components/responses/SessionRequired" "404": description: "`NOT_FOUND`: the session's user no longer exists" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /portal/licenses: get: tags: [Portal] summary: The signed-in user's licenses description: | Licenses whose email is the user's (case-insensitive), plus licenses on which the user holds an accepted seat that was not removed; an invite not yet accepted lists nothing. A bounded list with no paging, newest first, served with `Cache-Control: no-store`. Unlike admin payloads, each item carries the plaintext `license_key`: the caller's own credential, shown in the portal and used to address the activation and seat endpoints. That is the license's key for its owner, and for a member their own key, made the first time they get here. A member gets only their own devices in `activations`; `activation_count` is the whole license's. A member's `seats` holds the members who have joined; pending invitations are listed to the owner alone. A member is not listed a license while its product has no seats (its type changed), as their key opens nothing then. Each license leaves out what is the operator's: the owner's copy has no `notes`, `user_id`, `stripe_customer_id`, `stripe_payment_intent_id`, `stripe_checkout_session_id`, `external_customer_id`, `external_workspace_id`, `signup_source` or `signup_plan_id`, and no `user_id` on its seats; a member's copy also leaves out the subscription and the owner's details. security: - CookieAuth: [] responses: "200": description: Licenses content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: licenses: type: array items: allOf: - $ref: "#/components/schemas/License" - type: object properties: license_key: { type: string } upgradable: type: boolean description: The user owns this license and can upgrade it to a paid plan now (`POST /portal/licenses/upgrade`) key_paused: type: boolean description: Only for a member; their seat is paused (the license holds more people than seats), so `license_key` may not be used until a seat is free cancel_scheduled: type: boolean description: Only for the owner; the license's subscription is set to end at its period end (`valid_until`) rather than renew features: type: array description: What the license grants, its add-ons included, as verify and the signed token read them items: type: object properties: feature: { type: string } value_type: { type: string } value: { type: string } quota_period: { type: string } quota_unit: { type: string } renewals_enabled: type: boolean description: Whether maintenance renewals are on sale (`POST /portal/updates/renew`) "401": $ref: "#/components/responses/SessionRequired" /portal/profile: put: tags: [Portal] summary: Update the signed-in user's display name description: | `name` is trimmed and stripped of HTML tags; at most 100 characters. security: - CookieAuth: [] requestBody: required: true content: application/json: schema: type: object properties: name: { type: string, maxLength: 100 } responses: "200": description: Updated user content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: id: { type: string, format: uuid } email: { type: string } name: { type: string } avatar_url: { type: string } role: { type: string, enum: [owner, admin, user] } "400": description: "`BAD_REQUEST`: invalid body, or name longer than 100 characters" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/SessionRequired" /portal/plans: get: tags: [Portal] summary: Active plans of a product description: | For the portal's plan change and upgrade choices. A bounded list with no paging. security: - CookieAuth: [] parameters: - name: product_id in: query required: true schema: { type: string, format: uuid } responses: "200": description: Active plans content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: plans: type: array items: type: object properties: id: { type: string, format: uuid } name: { type: string } slug: { type: string } license_type: { type: string, enum: [subscription, perpetual, trial] } billing_interval: { type: string } stripe_price_id: type: string description: Pass as `new_price_id` to `POST /portal/subscription/change-plan` checkout_id: { type: string } updates_days: type: integer description: Length of the update period the plan sells; 0 = none per_seat: { type: boolean } min_quantity: { type: integer } max_quantity: { type: integer } max_activations: type: integer description: Devices (or floating sessions) a license holds; per seat on a per-seat plan max_seats: type: integer description: People a license holds, the owner included (0 = no limit). A plan sold per seat holds the seats bought instead and keeps 1 here (0 on a product without people). license_model: { type: string, enum: [standard, floating] } "400": description: "`BAD_REQUEST`: `product_id` missing" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/SessionRequired" /portal/usage: post: tags: [Portal, Usage] summary: Record usage from a portal session description: | The same operation as `POST /license/usage` (same body, response and errors, except that it has no idempotency layer), for a license the signed-in user owns or holds an accepted seat on; an invitation not yet accepted gives no access. A license that exists but is not the user's answers the same `404 LICENSE_NOT_FOUND` as one that does not exist. The body is capped at 64 KiB. security: - CookieAuth: [] requestBody: required: true content: application/json: schema: type: object required: [license_key, feature] properties: license_key: { type: string } feature: { type: string } quantity: type: integer format: int64 minimum: 0 default: 1 description: 0 or omitted counts as 1; negative is refused with 400. metadata: { type: object } responses: "200": description: Usage recorded content: application/json: schema: $ref: "#/components/schemas/UsageResponse" "400": description: "`BAD_REQUEST`, `NO_QUOTA` or `INVALID_INPUT`, as on `/license/usage`" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/SessionRequired" "404": description: "`LICENSE_NOT_FOUND`: unknown, not the user's, or not usable now" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "413": $ref: "#/components/responses/PortalBodyTooLarge" "429": description: "`QUOTA_EXCEEDED` with `details: {used, limit, period}`" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /portal/usage/status: post: tags: [Portal, Usage] summary: Get quota status from a portal session description: | The same operation as `POST /license/usage/status`, for a license the signed-in user owns or holds an accepted seat on. A license that exists but is not the user's answers the same `404 LICENSE_NOT_FOUND` as one that does not exist. The body is capped at 64 KiB. security: - CookieAuth: [] requestBody: required: true content: application/json: schema: type: object required: [license_key, feature] properties: license_key: { type: string } feature: { type: string } responses: "200": description: Quota status content: application/json: schema: $ref: "#/components/schemas/QuotaStatusResponse" "400": description: "`BAD_REQUEST`: missing fields" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/SessionRequired" "404": description: "`LICENSE_NOT_FOUND` (unknown, not the user's, or not usable now) or `QUOTA_NOT_FOUND`" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "413": $ref: "#/components/responses/PortalBodyTooLarge" # Seat management moved off the SDK namespace (license_key is the # device credential, not a seat-management credential). All three # endpoints live under /portal and are gated by the portal session # cookie; the license_key in the body only NAMES the target license, # the cookie is what authenticates. /portal/seats: post: tags: [Seats, Portal] summary: List seats on a license (portal) description: | Returns the seats (not removed) of a license the signed-in user owns or holds an accepted seat on, whatever the license's state, so an owner sees whom to remove while it is suspended or lapsed. The owner gets pending invitations too; a member gets only the members who have joined, not the addresses invitations went to. A license that exists but is not the user's answers the same `404 LICENSE_NOT_FOUND`, code included, as one that does not exist. The body is capped at 64 KiB. security: - CookieAuth: [] requestBody: required: true content: application/json: schema: type: object required: [license_key] properties: license_key: type: string responses: "200": description: Seat list content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: seats: type: array items: { $ref: "#/components/schemas/Seat" } "400": description: "`BAD_REQUEST`: `license_key` missing" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/SessionRequired" "404": description: "`LICENSE_NOT_FOUND`: unknown, not the user's, or a product without seats" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "413": $ref: "#/components/responses/PortalBodyTooLarge" /portal/seats/add: post: tags: [Seats, Portal] summary: Invite a member (portal) description: | Adds a seat to the license. A license has its owner, who manages its members, and members, who use it: only the license owner (matched by `license.email`, and never a seat row of their own) may call this. Anyone else, members included, gets the same `404 LICENSE_NOT_FOUND` as for an unknown license. Idempotent: adding the same email twice with the seat still pending sends the invitation again: the invite token is rotated (the earlier link stops working), its expiry and `invited_at` renewed, and the email re-sent, even at the seat limit, since the seat is already counted. No seat is added, so `seat.added` does not fire. This is how the portal resends an invitation. If the seat is already accepted, returns the existing seat with no email re-send. Cannot invite the license owner's own email (400). Enforces the license's seat limit (`seat_limit` on the license: the plan's `max_seats`, or the seats bought on a plan sold per seat), which counts the owner: with 5 the owner invites four members, and with 1 none (409 SEAT_LIMIT, where `current` counts the owner too), and nobody new is invited while a checkout upgrading the license to a plan sold per seat is open, which lapses within an hour (409 UPGRADE_IN_PROGRESS). When a seat is added, an invite email goes to the recipient and the `seat.added` webhook fires; they accept via `/invites/accept`. The body is capped at 64 KiB. security: - CookieAuth: [] requestBody: required: true content: application/json: schema: type: object required: [license_key, email] properties: license_key: type: string email: type: string format: email role: type: string enum: [member] default: member description: | Always `member`, and may be left out. Seats no longer have an admin role; any other value is refused with 400. responses: "200": description: | The seat: added, restored, sent again (still pending), or already a member. Which it was is not in the response; it is the `outcome` of the `seat.added` webhook (created or restored) and of the audit log entry. content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: { $ref: "#/components/schemas/Seat" } "400": description: Missing fields, invalid email, self-invite or invalid role (`BAD_REQUEST`) content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/SessionRequired" "404": description: "`LICENSE_NOT_FOUND`: unknown or unusable license, a product without seats, or the caller is not the license owner" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "409": description: "`SEAT_LIMIT`: every seat is taken (`details: {max, current}`, both counting the owner). `UPGRADE_IN_PROGRESS`: a checkout upgrading the license to a plan sold per seat is open (it lapses within an hour)" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "413": $ref: "#/components/responses/PortalBodyTooLarge" /portal/seats/remove: post: tags: [Seats, Portal] summary: Remove a member (portal) description: | Soft-removes a seat (sets `removed_at`) and sends the `seat.removed` webhook. The member's own key stops working at once, and their devices and floating sessions are removed in the same transaction, each device with a `license.deactivated` webhook. Same auth rules as `/portal/seats/add`: the license owner only. It works whatever the license's state, so an owner takes access away from a suspended or lapsed license and a renewal does not hand it back. Removing a seat already removed changes nothing and sends no webhook. The body is capped at 64 KiB. security: - CookieAuth: [] requestBody: required: true content: application/json: schema: type: object required: [license_key, seat_id] properties: license_key: type: string seat_id: type: string responses: "200": description: Seat removed content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: status: { type: string, enum: [removed] } "400": description: "`BAD_REQUEST`: missing fields" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/SessionRequired" "404": description: | `LICENSE_NOT_FOUND` (unknown license, a product without seats, or the caller is not its owner) or `SEAT_NOT_FOUND` (no such seat on this license) content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "413": $ref: "#/components/responses/PortalBodyTooLarge" /portal/licenses/{license_key}/activations: get: tags: [Portal] summary: List a license's activations description: | For the license owner, or a user holding an accepted seat on it (a pending invitation does not count here). Anyone else gets the same 404 as for an unknown key. The owner gets every device and whose key activated each; a member gets the devices their own key activated. The license key (the license's, or a member's own) is in the path: the server redacts that segment before logging, and a client's own logging should too. security: - CookieAuth: [] parameters: - $ref: "#/components/parameters/PathLicenseKey" responses: "200": description: Activations, newest first content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: activations: type: array items: type: object properties: id: { type: string, format: uuid } identifier: { type: string } identifier_type: { type: string, enum: [device, user] } label: { type: string } ip_address: { type: string } created_at: { type: string, format: date-time } last_verified: { type: string, format: date-time } seat_id: type: string description: The member whose own key activated the device; absent for the license's key member_email: type: string description: That member's address max: type: integer description: The license's device limit (the plan's `max_activations`, times the seats bought on a plan sold per seat); 0 when there is none "401": $ref: "#/components/responses/SessionRequired" "404": description: "`NOT_FOUND`: unknown key or not the user's license" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /portal/licenses/{license_key}/activations/{activation_id}: delete: tags: [Portal] summary: Remove an activation (free a device slot) description: | Same access rule as the list: a member removes only the devices their own key activated. Sends the `license.deactivated` webhook. security: - CookieAuth: [] parameters: - $ref: "#/components/parameters/PathLicenseKey" - name: activation_id in: path required: true schema: { type: string, format: uuid } responses: "200": description: Removed (also when it was removed concurrently) content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: status: { type: string, enum: [deleted] } "401": $ref: "#/components/responses/SessionRequired" "404": description: "`NOT_FOUND`: unknown key, not the user's license, or no such activation on it the user may remove" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /portal/subscription/change-plan: post: tags: [Portal] summary: Move a subscription to another plan description: | For the license owner (email match; a seat is not enough). The API form of `POST /portal/subscription/change` with no preview: the subscription moves to the plan with `new_price_id` and keeps its seats. A plan not sold per seat takes 1 coming from a plan sold per seat, and otherwise keeps the quantity the subscription has. It is charged the same way: a plan that lowers none of the license's limits is invoiced at once and held until that invoice is paid, so a dearer plan applies only once the difference is paid (`pending_payment`) and a cheaper one applies at once with the difference credited to the customer's Stripe balance; a plan that lowers a limit is a downgrade, applied at once without proration and billed from the next invoice. The license follows the subscription Stripe returns, and `plan.changed` fires when its plan moves. Asking for the plan the subscription already has answers `plan_changed` (a retry finds the change made), and drops a change waiting for payment. The target must be an active subscription plan of the same product, and the license active or trialing. Checks run license and ownership first, so a wrong owner cannot learn whether a price exists. security: - CookieAuth: [] requestBody: required: true content: application/json: schema: type: object required: [license_id, new_price_id] properties: license_id: { type: string, format: uuid } new_price_id: type: string description: Stripe price id of the target plan (`stripe_price_id` from `GET /portal/plans`) responses: "200": description: Plan changed, or waiting for payment content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: status: { type: string, enum: [plan_changed, pending_payment] } new_plan_id: { type: string, format: uuid } new_plan_name: { type: string } plan_id: { type: string, format: uuid, description: Same as new_plan_id } plan_name: { type: string, description: Same as new_plan_name } quantity: { type: integer } proration: type: string enum: [always_invoice, none] description: "`none` for a downgrade, billed from the next invoice" downgrade: { type: boolean, description: The change lowered a limit } hosted_invoice_url: type: string description: With `pending_payment`, Stripe's page to pay the invoice on expires_at: type: string format: date-time description: With `pending_payment`, when Stripe drops the change if still unpaid "202": description: | Stripe gave no answer, even when asked again. The license follows Stripe's next event; a downgrade has already moved it to the lower terms, which it keeps against reads that still show the old ones for up to 10 minutes, or until a read shows the change. content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: status: { type: string, enum: [processing] } new_plan_id: { type: string, format: uuid } new_plan_name: { type: string } plan_id: { type: string, format: uuid } plan_name: { type: string } quantity: { type: integer } proration: { type: string, enum: [always_invoice, none] } downgrade: { type: boolean } "400": description: | - `BAD_REQUEST`: missing fields, unknown `new_price_id`, no Stripe subscription on the license, or a plan of another product. - `PLAN_INACTIVE`: the target plan is no longer available - `NOT_SUBSCRIPTION_PLAN`: the target is not a subscription plan content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/SessionRequired" "403": description: "`FORBIDDEN`: not the user's license" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "404": description: "`NOT_FOUND`: no such license" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "409": $ref: "#/components/responses/SubscriptionChangeRefused" "429": description: "`RATE_LIMITED`: too many requests from this IP, Stripe was busy reading the subscription, or too many changes are under way (`Retry-After` 60 for the first, 5 for the others)" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "500": description: Stripe could not be read content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "503": description: "`PAYMENTS_UNAVAILABLE`: Stripe is not configured" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /portal/subscription/preview: post: tags: [Portal] summary: Preview a change to a subscription's plan or seats description: | For the license owner. Asks Stripe what the change would cost, changing nothing in Stripe (a license found out of line with its subscription is brought into line first, with the webhooks that go with that). `price_id` empty keeps the plan; `quantity` 0 keeps the seats. A plan not sold per seat takes no quantity: it keeps the one the subscription has, or 1 coming from a plan sold per seat. A change that lowers a limit the product uses (fewer seats, fewer devices) is a downgrade (`downgrade: true`): it applies at once and is not prorated, so `total` and `due_now` are 0, nothing is credited for the rest of the period, and `next_invoice_total` is what the next invoice, due at `next_invoice_at`, bills on the new terms. Any other change is prorated: `total` is the change itself, for the rest of the billing period, charged at once when positive, credited to the customer's Stripe balance when negative. Stripe's preview leaves the balance out, so it is read from the customer: `credit_balance` is the credit held, and `due_now` is `total` less that credit (or plus what the customer owes). `total` includes tax and discounts. Asking for what the subscription has now, while a change waits for payment, previews dropping that change: it charges nothing, so any payment method and collection setting can do it, whatever range the plan sells now. To make the change, pass `preview_token` to `POST /portal/subscription/change`: it carries the target, the terms the preview started from and its proration date, signed by the server. Rate limited per IP (30 per minute, shared with change, pending and change-plan). security: - CookieAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/SubscriptionChangeRequest" responses: "200": description: What the change would cost content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: current: { $ref: "#/components/schemas/SubscriptionTerms" } target: { $ref: "#/components/schemas/SubscriptionTerms" } total: { type: integer, format: int64, description: "Smallest currency unit; negative is a credit" } credit_balance: { type: integer, format: int64 } due_now: { type: integer, format: int64 } currency: { type: string } proration_date: { type: integer, format: int64, description: Unix seconds } seats_in_use: { type: integer, description: People on the license, the owner included } devices_in_use: { type: integer } replaces_pending: type: boolean description: A change waits for payment, and this one would replace it downgrade: type: boolean description: The change lowers a limit; it applies at once and is billed from the next invoice next_invoice_total: type: integer format: int64 description: Downgrade only. What the next invoice bills on the new terms, smallest currency unit next_invoice_at: type: integer format: int64 description: Downgrade only, and absent for a subscription set to end. When the next invoice falls due, Unix seconds preview_token: type: string description: Makes this change through `POST /portal/subscription/change`, for an hour "400": description: "`BAD_REQUEST`, `PLAN_INACTIVE` or `NOT_SUBSCRIPTION_PLAN`, as for change-plan" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/SessionRequired" "403": description: "`FORBIDDEN`: not the user's license" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "404": description: "`NOT_FOUND`: no such license" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "409": $ref: "#/components/responses/SubscriptionChangeRefused" "429": description: "`RATE_LIMITED`: too many requests from this IP (`Retry-After` the limiter's window), or Stripe was busy (`Retry-After: 5`); try again shortly" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "500": description: Stripe could not be read content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "503": description: "`PAYMENTS_UNAVAILABLE`: Stripe is not configured" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /portal/subscription/change: post: tags: [Portal] summary: Change a subscription's plan or seats, as previewed description: | For the license owner. Makes the change a preview showed, from its `preview_token`: the same target, charged from the preview's proration date, so Stripe charges what was shown (a customer who waits pays for the time waited). Refused when the token is over an hour old, the subscription renewed since, or the change is no longer the kind the preview showed (`PREVIEW_EXPIRED`), altered (400), or the subscription no longer has the price and quantity the preview started from (`SUBSCRIPTION_CHANGED`). A downgrade applies at once and is not prorated (`proration_behavior=none`); any other change is invoiced at once and held until paid (`payment_behavior=pending_if_incomplete`): - `applied`: Stripe made it (a downgrade, paid, covered by the customer's balance, or a credit). The license already shows it. - `pending_payment`: the invoice was not paid (a declined card, or the bank wants the customer to confirm). Nothing changes until it is paid at `hosted_invoice_url`; at `expires_at` Stripe drops it. A new change replaces it. - `processing` (202): Stripe gave no answer, even when asked again. The license follows Stripe's next event; a downgrade has already moved it to the lower terms, which it keeps against reads that still show the old ones for up to 10 minutes, or until a read shows the change (after that, looking at the current terms again, `NO_CHANGE`, brings it back in line if Stripe did not make it). Sending the same token again is safe: Stripe makes the change once, and a change already made answers `applied`. Fewer seats than the people on the license (`SEATS_IN_USE`), or fewer devices than are in use (`DEVICES_IN_USE`), is refused: nobody and nothing is removed by a change. A downgrade moves the license to its lower terms just before Stripe is asked (put back if Stripe does not make it), so nothing is taken meanwhile on the limits it removes; a subscription event that still shows the old terms during that time is answered with an error, so Stripe sends it again later. One change at a time is with Stripe for a license: a second waits up to 3 seconds, then answers `CHANGE_IN_PROGRESS`, and is checked again against the subscription and the license once its turn comes. A `429 RATE_LIMITED` may also mean too many changes are under way (`Retry-After: 5`); try again shortly. A Stripe that stays busy answers `processing`. `license.quantity_changed` and `plan.changed` fire when the license's seats or plan move. security: - CookieAuth: [] requestBody: required: true content: application/json: schema: type: object required: [license_id, preview_token] properties: license_id: { type: string, format: uuid } preview_token: type: string description: From `POST /portal/subscription/preview` responses: "200": description: Applied, or waiting for payment content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: { $ref: "#/components/schemas/SubscriptionChangeResult" } "202": description: "`processing`: Stripe's answer did not come back" content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: { $ref: "#/components/schemas/SubscriptionChangeResult" } "400": description: "`BAD_REQUEST`, `PLAN_INACTIVE` or `NOT_SUBSCRIPTION_PLAN`, as for change-plan" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/SessionRequired" "403": description: "`FORBIDDEN`: not the user's license" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "404": description: "`NOT_FOUND`: no such license" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "409": $ref: "#/components/responses/SubscriptionChangeRefused" "429": description: "`RATE_LIMITED`: too many requests from this IP, Stripe was busy reading the subscription, or too many changes are under way (`Retry-After` 60 for the first, 5 for the others)" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "500": description: Stripe could not be read content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "503": description: "`PAYMENTS_UNAVAILABLE`: Stripe is not configured" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /portal/subscription/pending: post: tags: [Portal] summary: A subscription change waiting for payment description: | For the license owner. Reads the subscription from Stripe (and brings the license up to date with it) and says whether a change waits for payment, where to pay for it, and what it is. security: - CookieAuth: [] requestBody: required: true content: application/json: schema: type: object required: [license_id] properties: license_id: { type: string, format: uuid } responses: "200": description: Pending change, if any content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: status: { type: string, enum: [none, pending_payment] } hosted_invoice_url: { type: string } expires_at: { type: string, format: date-time } quantity: { type: integer } plan_name: { type: string } "400": description: "`BAD_REQUEST`: license_id missing" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/SessionRequired" "403": description: "`FORBIDDEN`: not the user's license" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "404": description: "`NOT_FOUND`: no such license" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": description: "`RATE_LIMITED`: too many requests from this IP, or Stripe was busy (`Retry-After` 60 for the first, 5 for the other)" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "500": description: Stripe could not be read content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /portal/subscription/cancel: post: tags: [Portal] summary: Cancel a subscription description: | For the license owner (email match). By default the subscription is set to cancel at the end of the current period, and the license's `valid_until` moves to that date. With `immediate: true` the subscription is canceled in Stripe now and the license becomes `canceled` with `valid_until` set to now. A cancellation email is sent either way. security: - CookieAuth: [] requestBody: required: true content: application/json: schema: type: object required: [license_id] properties: license_id: { type: string, format: uuid } immediate: type: boolean default: false description: Cancel now instead of at period end responses: "200": description: Cancellation requested content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: status: { type: string, enum: [canceled] } immediate: { type: boolean } "400": description: "`BAD_REQUEST`: `license_id` missing, or the license has no subscription" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/SessionRequired" "403": description: "`FORBIDDEN`: not the user's license" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "404": description: "`NOT_FOUND`: no such license" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "500": description: Stripe refused or could not be reached content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /portal/subscription/billing-portal: post: tags: [Portal] summary: Open the Stripe customer portal description: | For the license owner (email match). Returns a Stripe billing portal URL for the license's Stripe customer, where the customer manages payment methods and invoices. `return_url` must be a page under `BASE_URL`; anything else, or none, becomes `{BASE_URL}/portal`. security: - CookieAuth: [] requestBody: required: true content: application/json: schema: type: object required: [license_id] properties: license_id: { type: string, format: uuid } return_url: { type: string, format: uri } responses: "200": description: Portal session created content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: url: { type: string, format: uri } "400": description: "`BAD_REQUEST`: `license_id` missing, or no Stripe customer on the license" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/SessionRequired" "403": description: "`FORBIDDEN`: not the user's license" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "404": description: "`NOT_FOUND`: no such license" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "500": description: Stripe refused or could not be reached content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /portal/subscription/invoices: get: tags: [Portal] summary: Invoices of a license's Stripe customer description: | For the license owner (email match). Read from Stripe on each call: the 20 newest invoices. A license with no Stripe customer answers an empty list. Amounts are in the currency's smallest unit; times are unix seconds. security: - CookieAuth: [] parameters: - name: license_id in: query required: true schema: { type: string, format: uuid } responses: "200": description: Invoices content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: invoices: type: array items: type: object properties: id: { type: string } number: { type: string } status: { type: string } amount_due: { type: integer, format: int64 } amount_paid: { type: integer, format: int64 } currency: { type: string } created: { type: integer, format: int64 } period_start: { type: integer, format: int64 } period_end: { type: integer, format: int64 } invoice_pdf: { type: string } hosted_url: { type: string } "400": description: "`BAD_REQUEST`: `license_id` missing" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/SessionRequired" "403": description: "`FORBIDDEN`: not the user's license" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "404": description: "`NOT_FOUND`: no such license" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "500": description: Stripe refused or could not be reached content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /portal/updates/renew: post: tags: [Portal] summary: Buy a maintenance renewal for a perpetual license description: | For the license owner (email match). Returns a Stripe Checkout URL (payment mode) for the plan's renewal price. When it is paid, the license's `updates_until` is extended by the plan's `renewal_days` and `license.updates_changed` fires with reason `renewal`. A renewal refunded in full takes the period back (`license.updates_changed`, reason `renewal_refunded`). security: - CookieAuth: [] requestBody: required: true content: application/json: schema: type: object required: [license_id] properties: license_id: { type: string, format: uuid } responses: "200": description: Checkout session created content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: url: { type: string, format: uri } "400": description: | - `BAD_REQUEST`: `license_id` missing - `RENEWAL_NOT_AVAILABLE`: the plan offers no renewal, or the license has updates for life - `LICENSE_NOT_ACTIVE`: only an `active` license can renew content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/SessionRequired" "403": description: "`FORBIDDEN`: not the user's license" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "404": description: "`NOT_FOUND`: no such license" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "409": description: "`MAINTENANCE_FEATURES_DISABLED`: renewals are not on sale right now" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "503": description: "`RENEWAL_UNAVAILABLE`: the plan's renewal price cannot be sold (unreadable, recurring or archived in Stripe); retrying will not help until an admin fixes it" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /portal/licenses/claim: post: tags: [Portal, License] summary: Attach a license to the signed-in account by entering its key description: | The key proves ownership: an unowned license is bound to the caller, and a member key binds the caller to its seat. A license another account already holds is refused; claiming one the caller already holds is idempotent. Unknown keys answer the same 404 as keys the caller cannot claim, so the endpoint does not reveal which licenses exist, and the audit records the license but never the key. Needs a signed-in account; 10 requests per minute. security: - BearerAuth: [] - CookieAuth: [] requestBody: required: true content: application/json: schema: type: object required: [license_key] properties: license_key: { type: string } responses: "200": description: Claimed, or the caller already held it content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: status: { type: string, enum: [claimed, already_owned, already_member] } license_id: { type: string, format: uuid } "400": description: "`BAD_REQUEST`: missing key or malformed body" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": description: "`UNAUTHORIZED`: not signed in" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "404": description: "`LICENSE_NOT_FOUND`: no such key, or it belongs to another account and is not a member key of the caller" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "409": description: "`LICENSE_ALREADY_CLAIMED`: another account holds this license; an admin can reassign it" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" /portal/licenses/upgrade: post: tags: [Portal, License] summary: Upgrade a license on a free plan to a paid plan (portal session) description: | For the license holder's own license on a free plan (no Stripe price, update period or renewals) that Stripe doesn't bill yet, issued by an admin or through self sign-up. Returns a Stripe Checkout URL for the target plan's price. When the session is paid, the same license moves to that plan and is linked to the Stripe subscription or payment: its key and activations stay, and a `plan.changed` webhook fires. The upgrade can be undone, and the license goes back to its free plan (same key, `plan.changed` with a `reason`) rather than being revoked or canceled: | Trigger | `reason` | |---|---| | a full refund in Stripe | `refund` | | the subscription is canceled in Stripe | `subscription_ended` | | the subscription becomes unpaid | `subscription_unpaid` | | the license stays past due for 30 days | `past_due_timeout` | | the license's grace period ends without payment | `grace_period_ended` | | an admin uses "cancel and revoke" (`POST /admin/licenses/{id}/refund`) | `admin_canceled` | For `subscription_unpaid`, `past_due_timeout`, `grace_period_ended`, `admin_canceled` and a refunded subscription, the subscription is canceled in Stripe first. Opening a new checkout expires one opened earlier for the same license. Targets are active plans of the same product with a Stripe price; trial plans and plans selling an update period are not offered. Suspended and revoked licenses cannot upgrade; expired and canceled ones can. The caller's email must match the license's (a seat is not enough). `GET /portal/licenses` marks each license the caller can upgrade with `upgradable: true`. For a plan sold per seat, checkout starts at the people already on the license (or the plan's minimum, if higher) and cannot go below that; more people than the plan's maximum is refused with `SEATS_IN_USE`. While that checkout is open nobody new can be invited (`UPGRADE_IN_PROGRESS` on `POST /portal/seats/add`); should the license hold more people than the seats bought all the same, the paid upgrade still applies, everyone stays, and nobody new joins until seats are added or freed. When the upgrade, or undoing it, moves the seats, `license.quantity_changed` fires with `reason` `upgrade` or `upgrade_reverted`. security: - CookieAuth: [] requestBody: required: true content: application/json: schema: type: object required: [license_id, plan_id] properties: license_id: { type: string, format: uuid } plan_id: { type: string, format: uuid } responses: "200": description: Checkout session created content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: url: { type: string, format: uri } "400": description: "`UPGRADE_NOT_AVAILABLE`, the license or plan doesn't qualify; `BAD_REQUEST`, missing fields" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/SessionRequired" "403": description: "`FORBIDDEN`: not the caller's license" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "404": description: "`NOT_FOUND`: license or plan not found" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "409": description: | `UPGRADE_IN_PROGRESS`, a checkout opened earlier for this license has been paid and is still being applied. `SEATS_IN_USE`, more people are on the license than the plan sells seats, or than its `max_seats` if not sold per seat (`details.in_use`, `details.seat_limit`) content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "503": description: "`UPGRADE_UNAVAILABLE`, the plan's Stripe price can't be sold right now" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /admin/licenses: get: tags: [Admin Licenses] summary: List licenses description: | Requires the `admin` or `licenses:write` scope. A key bound to a product sees only that product's licenses; asking for another `product_id` answers `403 PRODUCT_SCOPE_MISMATCH`. Paginated (default 50, max 200). Sortable by `created_at` (default, newest first), `valid_until` (soonest first), `email`, `status`, `product` and `plan`. Items carry no license key; `license_key_hints` maps each license id to the last four characters of its key, and `license_key_prefixes` to the key's prefix (left out for a key imported from another system). `GET /admin/licenses/{id}/key` reveals one key. x-required-scopes: [admin, "licenses:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - { name: product_id, in: query, schema: { type: string, format: uuid } } - { name: status, in: query, schema: { $ref: "#/components/schemas/LicenseStatus" } } - name: search in: query description: | Free-text search over the list: part of the owner's address, or of the address of a member or of an invitation not yet accepted (matched literally: `%` and `_` are not wildcards), or a whole key, the license's or a member's own, which finds the license they are on. A key is never matched in part. Removed seats are not searched. schema: { type: string } - { name: external_customer_id, in: query, schema: { type: string } } - { name: external_workspace_id, in: query, schema: { type: string } } - name: sort in: query schema: { type: string, enum: [created_at, valid_until, email, status, product, plan], default: created_at } - { name: order, in: query, schema: { type: string, enum: [asc, desc] } } - $ref: "#/components/parameters/PageLimit" - $ref: "#/components/parameters/PageOffset" responses: "200": description: A page of licenses content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: allOf: - $ref: "#/components/schemas/Page" - type: object properties: licenses: type: array items: { $ref: "#/components/schemas/License" } license_key_hints: type: object additionalProperties: { type: string } description: License id to the last four characters of its key license_key_prefixes: type: object additionalProperties: { type: string } description: License id to the prefix its key was issued with, which stays when the product's prefix changes later "400": description: "`INVALID_SORT` or `INVALID_ORDER`" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "429": $ref: "#/components/responses/RateLimited" post: tags: [Admin Licenses] summary: Create a license description: | Requires the `admin` or `licenses:write` scope. A key bound to a product can only create licenses for that product (`403 PRODUCT_SCOPE_MISMATCH`). The license gets a new key, status `trialing` on a trial plan and `active` otherwise. Without `valid_until`, a trial plan sets now plus `trial_days` and other plans issue a license with no expiry. A plan that sells an update period sets `updates_until` from it, which needs maintenance features switched on and the product's feeds gated (409 otherwise). The key is emailed to the customer (best effort; a mail failure does not fail the call) and returned in this response only, with `Cache-Control: no-store`. `license.created` fires. x-required-scopes: [admin, "licenses:write"] security: - BearerAuth: [] - CookieAuth: [] requestBody: required: true content: application/json: schema: type: object required: [product_id, plan_id, email] properties: product_id: { type: string, format: uuid } plan_id: { type: string, format: uuid } email: { type: string, format: email, maxLength: 254 } notes: { type: string } external_customer_id: type: string maxLength: 256 description: Your own customer id, echoed by `/license/verify` and filterable on the list external_workspace_id: { type: string, maxLength: 256 } valid_until: type: string format: date-time description: RFC 3339, in the future. Overrides the plan's default. quantity: type: integer minimum: 1 maximum: 10000 description: | Seats, on a plan sold per seat: between the plan's `min_quantity` and `max_quantity`, its `min_quantity` when left out. A plan not sold per seat takes only 1. example: product_id: 6f1c2e8a-1b2c-4d5e-8f90-1a2b3c4d5e6f plan_id: 0a9b8c7d-6e5f-4a3b-2c1d-0e9f8a7b6c5d email: buyer@example.com external_customer_id: cus_123 responses: "201": description: Created; the only response that carries the key besides the reveal endpoint content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: allOf: - $ref: "#/components/schemas/License" - type: object properties: license_key: { type: string } "400": description: | - `BAD_REQUEST`: missing fields, invalid email, an external id over 256 characters, `valid_until` not RFC 3339 or not in the future, `quantity` below 1 or above 10000 - `PLAN_PRODUCT_MISMATCH`: the plan belongs to another product content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": description: "`NOT_FOUND`: plan not found" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "409": description: | - `MAINTENANCE_FEATURES_DISABLED`, `FEED_NOT_GATED`, `FEED_CACHE_DRAINING` (`details.retry_after_seconds`): the plan sells an update period that cannot be issued now - `PLAN_CHANGED`: the plan's license type changed meanwhile; retry - `QUANTITY_OUT_OF_RANGE` (`details.min`, `details.max`): the plan sells another number of seats - `NOT_PER_SEAT`: `quantity` above 1 on a plan not sold per seat content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" /admin/licenses/export: get: tags: [Admin Licenses] summary: Export licenses with their keys description: | Requires the `admin` or `licenses:write` scope. A key bound to a product exports only that product's licenses; another `product_id` answers `403 PRODUCT_SCOPE_MISMATCH`. A bulk export **including every plaintext license key**, for migrations and reports. Each export is written to the audit log. Not paginated and not wrapped in the envelope: the body is a file download (`Content-Disposition: attachment; filename=licenses-YYYY-MM-DD.csv` or `.json`). CSV has a header row; cells starting with `=`, `+`, `-`, `@`, tab or CR are prefixed with `'`. JSON is a bare array. Errors still use the envelope. x-required-scopes: [admin, "licenses:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - name: format in: query schema: { type: string, enum: [csv, json], default: csv } - { name: product_id, in: query, schema: { type: string, format: uuid } } - { name: status, in: query, schema: { $ref: "#/components/schemas/LicenseStatus" } } responses: "200": description: The export file content: text/csv: schema: type: string description: "Columns: id, email, product, plan, status, license_key, valid_from, valid_until, updates_until, created_at" application/json: schema: type: array items: type: object properties: id: { type: string, format: uuid } email: { type: string } product: { type: string, description: Product name } plan: { type: string, description: Plan name } status: { type: string } license_key: { type: string } valid_from: { type: string, format: date-time } valid_until: { type: string, description: "RFC 3339, empty when none" } updates_until: { type: string, description: "RFC 3339, empty when there is no separate limit" } created_at: { type: string, format: date-time } "400": description: "`BAD_REQUEST`: format must be csv or json" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "429": $ref: "#/components/responses/RateLimited" /admin/licenses/{id}: get: tags: [Admin Licenses] summary: Get a license description: | Requires the `admin` or `licenses:write` scope; a bound key only for its product's licenses. The key itself is not included; see `license_key_hint` and `GET /admin/licenses/{id}/key`. x-required-scopes: [admin, "licenses:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/LicenseID" responses: "200": description: The license content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: allOf: - $ref: "#/components/schemas/License" - type: object properties: license_key_hint: { type: string, description: Last four characters of the key } license_key_prefix: type: string description: The prefix the key was issued with, such as `KG`; empty for a key imported from another system key_usable: type: boolean description: Whether the key works right now, by the activation rule key_unusable_reason: type: string description: The error code activation would give (such as `LICENSE_SUSPENDED`), when `key_usable` is false "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": $ref: "#/components/responses/AdminLicenseNotFound" "429": $ref: "#/components/responses/RateLimited" /admin/licenses/{id}/key: get: tags: [Admin Licenses] summary: Reveal a license key description: | Requires the `admin` or `licenses:write` scope; a bound key only for its product's licenses. Returns the plaintext key with `Cache-Control: no-store`. Each reveal is written to the audit log. x-required-scopes: [admin, "licenses:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/LicenseID" responses: "200": description: The key content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: license_key: { type: string } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": $ref: "#/components/responses/AdminLicenseNotFound" "429": $ref: "#/components/responses/RateLimited" "503": description: "`LICENSE_KEY_UNAVAILABLE`: the stored key could not be decrypted" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /admin/licenses/{id}/rotate-key: post: tags: [Admin Licenses] summary: Replace a license key description: | Requires the `admin` or `licenses:write` scope; a bound key only for its product's licenses. A license's key does not change once issued; this is how the vendor reissues one, as when it got out, and customers have no endpoint for it. A new key with the product's current prefix: the old one stops at once and the devices and floating sessions on it are removed (each device with a `license.deactivated` webhook), and members keep their own keys, devices and sessions. The owner is not told; `POST /admin/licenses/{id}/resend-email` sends them the new key. Returns the new key with `Cache-Control: no-store`; the change, never the key, is written to the audit log. No request body. x-required-scopes: [admin, "licenses:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/LicenseID" responses: "200": description: The new key content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: $ref: "#/components/schemas/RotatedLicenseKey" "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": $ref: "#/components/responses/AdminLicenseNotFound" "429": $ref: "#/components/responses/RateLimited" /admin/licenses/{id}/resend-email: post: tags: [Admin Licenses] summary: Email the customer their key again description: | Requires the `admin` or `licenses:write` scope; a bound key only for its product's licenses. Queues the license email to the address on the license (never one from the request); queued mail is retried with backoff. Only a key that works now is sent. Each send is written to the audit log. No request body. x-required-scopes: [admin, "licenses:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/LicenseID" responses: "200": description: Queued content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: queued: { type: boolean, enum: [true] } email: { type: string } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": $ref: "#/components/responses/AdminLicenseNotFound" "409": description: | - `NO_EMAIL`: the license has no email address - `LICENSE_NOT_USABLE`: the key does not work now (the message names the reason) - `SMTP_NOT_CONFIGURED`: no mail can be sent from this server content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" "500": description: "`EMAIL_QUEUE_FAILED`: the mail could not be queued" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "503": description: "`LICENSE_KEY_UNAVAILABLE`: the stored key could not be decrypted" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /admin/licenses/{id}/refund: post: tags: [Admin Licenses] summary: Cancel and revoke (path kept as /refund) description: | Requires the `admin` or `licenses:write` scope; a bound key only for its product's licenses. **Moves no money.** Despite the path, this is the dashboard's "Cancel and revoke": it cancels the license's Stripe subscription first, and only once Stripe confirms does the license change. If Stripe cannot confirm, the answer is `502 STRIPE_UNAVAILABLE` and nothing changes. A license without a subscription (for example a one-time purchase) answers 400. - A license an upgrade moved onto that subscription goes back to its free plan instead of being revoked: `{"status": "reverted_to_free_plan"}`, and `plan.changed` fires with `reason: admin_canceled`. - Any other license is revoked: `{"status": "refunded", "provider_result": "stripe_subscription_canceled"}`, and `license.revoked` fires with `reason: refund`. The revoke applies only while the license still belongs to the cancelled subscription: if it went back to its free plan or was unlinked meanwhile (a Stripe event handled at the same moment), the answer is `409 LICENSE_CHANGED` and the license is left as it is. To refund money, refund the charge in Stripe. Its `charge.refunded` event then revokes a purchase (`license.revoked`, reason `refund`), or reverts an upgrade (`plan.changed`, reason `refund`). x-required-scopes: [admin, "licenses:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/LicenseID" responses: "200": description: Subscription canceled; license revoked or reverted content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: status: { type: string, enum: [refunded, reverted_to_free_plan] } provider_result: type: string enum: [stripe_subscription_canceled] description: "Present with `status: refunded`" "400": description: "`BAD_REQUEST`: the license has no subscription to cancel" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": $ref: "#/components/responses/AdminLicenseNotFound" "409": description: "`LICENSE_CHANGED`: the subscription is cancelled, but the license no longer belongs to it (back on its free plan, or unlinked), so it was not revoked" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" "502": description: "`STRIPE_UNAVAILABLE`: Stripe could not confirm the cancellation; nothing was changed, retry" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "503": description: "`STRIPE_NOT_CONFIGURED`: Stripe is not configured on this install" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /admin/licenses/{id}/revoke: post: tags: [Admin Licenses] summary: Revoke a license description: | Requires the `admin` or `licenses:write` scope; a bound key only for its product's licenses. Sets the status to `revoked` (the key stops working) and sends `license.revoked`. Nothing is changed in Stripe: cancel a subscription first, or use `/refund` (cancel and revoke). No request body. x-required-scopes: [admin, "licenses:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/LicenseID" responses: "200": description: Revoked content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: status: { type: string, enum: [revoked] } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": $ref: "#/components/responses/AdminLicenseNotFound" "429": $ref: "#/components/responses/RateLimited" /admin/licenses/{id}/suspend: post: tags: [Admin Licenses] summary: Suspend a license description: | Requires the `admin` or `licenses:write` scope; a bound key only for its product's licenses. Only an `active`, `trialing` or `past_due` license can be suspended. Sends `license.suspended` and a suspension email. Undo with `/reinstate`. No request body. x-required-scopes: [admin, "licenses:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/LicenseID" responses: "200": description: Suspended content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: status: { type: string, enum: [suspended] } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": $ref: "#/components/responses/AdminLicenseNotFound" "409": description: "`LICENSE_NOT_SUSPENDABLE`: the license is not active, trialing or past due" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" /admin/licenses/{id}/reinstate: post: tags: [Admin Licenses] summary: Reinstate a license description: | Requires the `admin` or `licenses:write` scope; a bound key only for its product's licenses. Gives a `suspended` license, or an `expired` or `canceled` one whose `valid_until` is unset or still in the future, back the status it should have, and sends `license.reinstated` with that status. An expired or canceled license whose expiry has passed needs a new expiry instead (`POST /admin/licenses/{id}/valid-until`). No request body. A license on a Stripe subscription goes back to the subscription as Stripe has it now: Stripe's changes are not applied to a suspended license, so a renewal paid, a pause or the end of the subscription meanwhile is applied when it is reinstated. Stripe is read first; when it cannot be read, the license stays suspended. x-required-scopes: [admin, "licenses:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/LicenseID" responses: "200": description: Reinstated content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: status: type: string description: | The status the license now has: for a license on a Stripe subscription, as the subscription leaves it (`suspended` for one Stripe paused, `canceled` for one that ended) usable: type: boolean description: | Whether the license works now. A suspended license whose expiry and grace ran out while it was suspended is reinstated but still does not work: the next expiry check expires it (or returns an upgraded one to its free plan). One on a Stripe subscription works as the subscription allows. Absent when the license could not be read back. notice: type: string description: When `usable` is false, what brings the license back "400": description: "`BAD_REQUEST`: the license cannot be reinstated from its current status" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": $ref: "#/components/responses/AdminLicenseNotFound" "409": description: | `EXPIRY_PASSED`: set a new expiry date first, which reactivates an expired license (a canceled one can then be reinstated); for a license on a Stripe subscription, the subscription has ended (unlink it to manage its expiry here). `STRIPE_NOT_VISIBLE`: the install's Stripe API key cannot see the license's subscription (another account, or the other test/live mode); the license stays suspended content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" "502": description: "`STRIPE_UNAVAILABLE`: Stripe could not be reached to read the license's subscription; the license stays suspended" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /admin/licenses/{id}/quantity: post: tags: [Admin Licenses] summary: Set the seats of a license on a per-seat plan description: | Requires the `admin` or `licenses:write` scope; a bound key only for its product's licenses. For a license nothing bills, such as a sale made outside Stripe: a Stripe subscription's seats are its quantity in Stripe and are refused here (`STRIPE_MANAGED`). The quantity must be one the plan sells, its `min_quantity` to `max_quantity` (`QUANTITY_OUT_OF_RANGE`), and lowering it is refused below what the license uses, as the owner's change in the portal is: its people, the owner and invitations included (`SEATS_IN_USE`), and its devices, or floating sessions on a floating plan, against the devices the new seats hold (`DEVICES_IN_USE`). Nobody and nothing is removed by a change of seats; the room is freed first. Sends `license.quantity_changed` when the seats changed. Answers with the license, its limits included. x-required-scopes: [admin, "licenses:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/LicenseID" requestBody: required: true content: application/json: schema: type: object required: [quantity] properties: quantity: { type: integer, minimum: 1, maximum: 10000 } responses: "200": description: Seats set content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: { $ref: "#/components/schemas/License" } "400": description: "`BAD_REQUEST`: quantity missing or outside 1 to 10000" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": description: "`NOT_FOUND`: no such license" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "409": description: | - `STRIPE_MANAGED`: a Stripe subscription bills the license - `NOT_PER_SEAT`: the license's plan is not sold per seat - `QUANTITY_OUT_OF_RANGE`: outside the plan's `min_quantity` to `max_quantity` (`details`: `min`, `max`, as the portal's) - `SEATS_IN_USE`: more people than the seats asked for (`details`: `in_use`, `seat_limit`) - `DEVICES_IN_USE`: more devices, or sessions, than the seats asked for hold (`details`: `in_use`, `activation_limit`, `kind`) content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" /admin/licenses/{id}/valid-until: post: tags: [Admin Licenses] summary: Set or clear a license's expiry description: | Requires the `admin` or `licenses:write` scope; a bound key only for its product's licenses. An empty `valid_until` removes the expiry. An `expired` license given a future (or no) expiry becomes usable again with its plan's status (`trialing` on a trial plan, `active` otherwise); suspended, revoked and canceled licenses keep their status. Sends `license.expiry_changed`, and `license.reinstated` when the status changed. Not for Stripe-billed licenses, whose expiry follows the subscription. x-required-scopes: [admin, "licenses:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/LicenseID" requestBody: required: true content: application/json: schema: type: object properties: valid_until: type: string description: RFC 3339 timestamp in the future, or empty for no expiry example: "2027-01-31T00:00:00Z" responses: "200": description: The updated license content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: { $ref: "#/components/schemas/License" } "400": description: "`BAD_REQUEST`: invalid body, not RFC 3339, or not in the future" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": $ref: "#/components/responses/AdminLicenseNotFound" "409": description: "`STRIPE_MANAGED`: the license is billed by a Stripe subscription" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" /admin/licenses/{id}/updates-until: post: tags: [Admin Licenses] summary: Set or clear a perpetual license's update cutoff description: | Requires the `admin` or `licenses:write` scope; a bound key only for its product's licenses. Only for licenses on a perpetual plan. A timestamp sets the end of the maintenance period: the license keeps working, but releases published after it cannot be installed. An empty `updates_until` removes the cutoff (updates for life). Setting a cutoff needs maintenance features switched on and the product's update feeds gated (`feed_license_required`), and is refused while links handed out by the public feed may still be valid. Sends `license.updates_changed` with `reason: admin`. x-required-scopes: [admin, "licenses:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/LicenseID" requestBody: required: true content: application/json: schema: type: object properties: updates_until: type: string description: RFC 3339 timestamp, or empty to remove the cutoff responses: "200": description: The updated license content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: { $ref: "#/components/schemas/License" } "400": description: | - `BAD_REQUEST`: invalid body, or not RFC 3339 - `NOT_PERPETUAL`: only perpetual licenses have an update period content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": $ref: "#/components/responses/AdminLicenseNotFound" "409": description: | - `MAINTENANCE_FEATURES_DISABLED`: maintenance features are switched off - `FEED_NOT_GATED`: the product's feeds do not require the license - `FEED_CACHE_DRAINING`: the feeds were gated too recently, `details: {product_id, retry_after_seconds}` - `LICENSE_CHANGED`: the license moved plan or its period changed meanwhile; reload and retry content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" /admin/licenses/{id}/owner: post: tags: [Admin Licenses] summary: Bind a license to the account that should see it in the portal description: | Resolves the account by `user_id` or `email` without creating one, then sets the license's owner. Only an unowned license or the account already holding it is accepted; reassigning one held by another account needs the override action. The billing address (`licenses.email`) is not changed. Audited as `bind_owner`. x-required-scopes: [admin, "licenses:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/LicenseID" requestBody: required: true content: application/json: schema: type: object properties: user_id: { type: string, format: uuid } email: { type: string, format: email } responses: "200": description: Bound (or already bound to that account) content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: status: { type: string, enum: [bound, already_bound] } license_id: { type: string, format: uuid } user_id: { type: string, format: uuid } "400": description: "`BAD_REQUEST`: neither `user_id` nor `email` supplied" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "404": description: "`NOT_FOUND`: no such license or account" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "409": description: "`LICENSE_ALREADY_CLAIMED`: another account holds this license; use the override action" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /admin/licenses/{id}/owner/override: post: tags: [Admin Licenses] summary: Reassign a license another account already holds description: | Administrator only. Moves portal access to the resolved account, so the previous account loses it immediately; the billing address is unchanged. Audited as `reassign_owner` with both sides. x-required-scopes: [admin] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/LicenseID" requestBody: required: true content: application/json: schema: type: object properties: user_id: { type: string, format: uuid } email: { type: string, format: email } responses: "200": description: Reassigned content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: status: { type: string, enum: [reassigned] } license_id: { type: string, format: uuid } user_id: { type: string, format: uuid } "400": description: "`BAD_REQUEST`: neither `user_id` nor `email` supplied" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "404": description: "`NOT_FOUND`: no such license or account" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /admin/licenses/{id}/change-plan: post: tags: [Admin Licenses] summary: Move a license to another plan of its product description: | Requires the `admin` or `licenses:write` scope; a bound key only for its product's licenses. Not for a license billed by a Stripe subscription (change the plan in the customer portal, which moves the subscription too, or cancel it in Stripe and unlink first). The license takes the shape the new plan would have issued it in: moving out of a trial plan ends the trial status, moving into one starts a trial, and the update period follows the license type (leaving perpetual clears it, entering perpetual starts the new plan's period, which needs maintenance features and gated feeds). Suspended and revoked licenses keep their status. Nobody is removed: a plan seating fewer people than are on or invited to the license is refused until some are removed. Moving to a plan sold per seat takes the seats the license holds (its people, coming from a plan not sold per seat), within the new plan's minimum and maximum; `plan.changed` and the audit entry then carry `old_quantity` and `new_quantity`. Sends `plan.changed` and a plan change email. x-required-scopes: [admin, "licenses:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/LicenseID" requestBody: required: true content: application/json: schema: type: object required: [plan_id] properties: plan_id: { type: string, format: uuid } responses: "200": description: Plan changed content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: status: { type: string, enum: [plan_changed] } plan_id: { type: string, format: uuid } "400": description: "`BAD_REQUEST`: `plan_id` missing, or the plan belongs to another product" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": description: "`NOT_FOUND`: license or plan not found" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "409": description: | - `STRIPE_MANAGED`: billed by a Stripe subscription - `SEATS_IN_USE`: more people on or invited to the license than the new plan seats, `details: {"in_use": 4, "seat_limit": 2}` - `LICENSE_CHANGED`: the license moved meanwhile; reload and retry - `MAINTENANCE_FEATURES_DISABLED`, `FEED_NOT_GATED`, `FEED_CACHE_DRAINING`: the new plan's update period cannot be issued now content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" /admin/licenses/{id}/stripe/unlink: post: tags: [Admin Licenses] summary: Detach an ended Stripe subscription from a license description: | Requires the `admin` or `licenses:write` scope; a bound key only for its product's licenses. Clears the license's Stripe subscription id once Stripe confirms the subscription has ended, so the license can be managed here again (for example with `change-plan` or `valid-until`). A license with no subscription answers `{"status": "not_linked"}`. No request body. x-required-scopes: [admin, "licenses:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/LicenseID" responses: "200": description: Unlinked, or there was nothing to unlink content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: status: { type: string, enum: [unlinked, not_linked] } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": $ref: "#/components/responses/AdminLicenseNotFound" "409": description: | - `SUBSCRIPTION_LIVE`: Stripe still has the subscription; cancel it first - `SUBSCRIPTION_CHANGED`: the license moved to another subscription meanwhile content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" "502": description: "`STRIPE_UNAVAILABLE`: Stripe could not be asked; retry" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "503": description: "`STRIPE_NOT_CONFIGURED`" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /admin/licenses/{id}/usage: get: tags: [Admin Licenses] summary: Usage events and counters of a license description: | Requires the `admin` or `licenses:write` scope; a bound key only for its product's licenses. A page of recorded usage events, optionally for one feature, plus every counter of the license. x-required-scopes: [admin, "licenses:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/LicenseID" - { name: feature, in: query, schema: { type: string } } - $ref: "#/components/parameters/PageLimit" - $ref: "#/components/parameters/PageOffset" responses: "200": description: Events and counters content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: allOf: - $ref: "#/components/schemas/Page" - type: object properties: events: type: array items: { $ref: "#/components/schemas/UsageEvent" } counters: type: array items: { $ref: "#/components/schemas/UsageCounter" } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": $ref: "#/components/responses/AdminLicenseNotFound" "429": $ref: "#/components/responses/RateLimited" /admin/licenses/{id}/usage/reset: post: tags: [Admin Licenses] summary: Reset a usage counter description: | Requires the `admin` or `licenses:write` scope; a bound key only for its product's licenses. Resets the counter of `feature` for one period (default: the current `monthly` period). x-required-scopes: [admin, "licenses:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/LicenseID" requestBody: required: true content: application/json: schema: type: object required: [feature] properties: feature: { type: string } period: type: string enum: [hourly, daily, monthly, yearly] default: monthly period_key: type: string description: "The period to reset, such as `2026-03` for monthly. Default: the current one." responses: "200": description: Reset content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: status: { type: string, enum: [reset] } "400": description: "`BAD_REQUEST`: `feature` missing, or `period` not one of the four" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": $ref: "#/components/responses/AdminLicenseNotFound" "429": $ref: "#/components/responses/RateLimited" /admin/licenses/{id}/seats: get: tags: [Admin Licenses] summary: Seats of a license description: | Requires the `admin` or `licenses:write` scope; a bound key only for its product's licenses. Seats that were not removed, oldest first. `active_count` is the number of members: it leaves out the owner, who holds a seat without a row, and a seat at the owner's own address. The people counted against the license's seat limit (`seat_limit`) are `active_count + 1`. Each seat carries its member's own key by its prefix and last four characters (absent until they have a key; listing never makes one) and how many devices that key activated. x-required-scopes: [admin, "licenses:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/LicenseID" - $ref: "#/components/parameters/PageLimit" - $ref: "#/components/parameters/PageOffset" responses: "200": description: A page of seats content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: allOf: - $ref: "#/components/schemas/Page" - type: object properties: seats: type: array items: allOf: - $ref: "#/components/schemas/Seat" - type: object properties: key_prefix: { type: string } key_hint: { type: string, description: Last four characters of the member's own key } device_count: { type: integer } active_count: { type: integer } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": $ref: "#/components/responses/AdminLicenseNotFound" "429": $ref: "#/components/responses/RateLimited" /admin/licenses/{id}/seats/{seat_id}: delete: tags: [Admin Licenses] summary: Remove a member or cancel an invitation description: | Requires the `admin` or `licenses:write` scope; a bound key only for its product's licenses. What the owner's `POST /portal/seats/remove` does, for support: the member's own key stops working, the devices and floating sessions it took are removed (`seat.removed`, and `license.deactivated` for each device), and the removal is audited as the admin's. Works whatever the license's state. Removing a seat already removed answers the same, and fires nothing again. x-required-scopes: [admin, "licenses:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/LicenseID" - { name: seat_id, in: path, required: true, schema: { type: string, format: uuid } } responses: "200": description: Removed content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: status: { type: string, example: removed } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": description: "`NOT_FOUND`: unknown license; `SEAT_NOT_FOUND`: no such seat on it" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" /admin/licenses/{id}/seats/{seat_id}/key: get: tags: [Admin Licenses] summary: Reveal a member's key description: | Requires the `admin` or `licenses:write` scope; a bound key only for its product's licenses. The member's own key in full, as `GET /admin/licenses/{id}/key` gives the license's: with `Cache-Control: no-store`, and each reveal written to the audit log without the key. A member who has not opened the portal yet has their key made here, as the portal would make it. x-required-scopes: [admin, "licenses:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/LicenseID" - { name: seat_id, in: path, required: true, schema: { type: string, format: uuid } } responses: "200": description: The member's key content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: license_key: { type: string } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": description: "`NOT_FOUND`: unknown license; `SEAT_NOT_FOUND`: no such seat on it" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "409": description: "`SEAT_NOT_JOINED`: an invitation not yet accepted, or a removed seat; it has no key" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" /admin/licenses/{id}/seats/{seat_id}/rotate-key: post: tags: [Admin Licenses] summary: Reissue a member's key description: | Requires the `admin` or `licenses:write` scope; a bound key only for its product's licenses. Gives the member a new key of their own, as `POST /admin/licenses/{id}/rotate-key` does the license's: the old one stops at once, the devices and floating sessions it took are removed (each device with a `license.deactivated` webhook), and the owner's and other members' keys and devices stay. The member sees the new key in the portal; the response carries it too, with `Cache-Control: no-store`. Audited without the key. No request body. x-required-scopes: [admin, "licenses:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/LicenseID" - { name: seat_id, in: path, required: true, schema: { type: string, format: uuid } } responses: "200": description: The member's new key content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: $ref: "#/components/schemas/RotatedLicenseKey" "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": description: "`NOT_FOUND`: unknown license; `SEAT_NOT_FOUND`: no such seat on it" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "409": description: "`SEAT_NOT_JOINED`: an invitation not yet accepted, or a removed seat; it has no key" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" /admin/licenses/{id}/addons: get: tags: [Admin Licenses] summary: Add-ons attached to a license description: | Requires the `admin` or `licenses:write` scope; a bound key only for its product's licenses. x-required-scopes: [admin, "licenses:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/LicenseID" - $ref: "#/components/parameters/PageLimit" - $ref: "#/components/parameters/PageOffset" responses: "200": description: A page of attachments content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: allOf: - $ref: "#/components/schemas/Page" - type: object properties: addons: type: array items: { $ref: "#/components/schemas/LicenseAddon" } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": $ref: "#/components/responses/AdminLicenseNotFound" "429": $ref: "#/components/responses/RateLimited" post: tags: [Admin Licenses] summary: Attach an add-on to a license description: | Requires the `admin` or `licenses:write` scope; a bound key only for its product's licenses. The add-on must belong to the license's product. Attaching one that is already attached returns the existing attachment with 200; a new attachment returns 201. An enabled add-on's feature replaces the plan's entitlement for the same feature. An add-on with `active: false` is taken off sale: it is attached to no further license (409 `ADDON_INACTIVE`), nor enabled again where its attachment was switched off, while the licenses that already have it keep it. To take its feature from those, detach it from each. x-required-scopes: [admin, "licenses:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/LicenseID" requestBody: required: true content: application/json: schema: type: object required: [addon_id] properties: addon_id: { type: string, format: uuid } responses: "200": description: Already attached; the existing attachment content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: { $ref: "#/components/schemas/LicenseAddon" } "201": description: Attached content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: { $ref: "#/components/schemas/LicenseAddon" } "400": description: | - `BAD_REQUEST`: `addon_id` missing - `ADDON_PRODUCT_MISMATCH`: the add-on belongs to another product content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": description: "`NOT_FOUND`: license or add-on not found" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "409": description: "`ADDON_INACTIVE`: the add-on is no longer offered (`active: false`) and the license does not have it" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" /admin/licenses/{id}/addons/{addon_id}: delete: tags: [Admin Licenses] summary: Detach an add-on from a license description: | Requires the `admin` or `licenses:write` scope; a bound key only for its product's licenses. x-required-scopes: [admin, "licenses:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/LicenseID" - name: addon_id in: path required: true description: The add-on's id (not the attachment's) schema: { type: string, format: uuid } responses: "204": description: Detached "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": description: "`NOT_FOUND`: license not found, or the add-on is not attached to it" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" /admin/licenses/{id}/floating: get: tags: [Admin Licenses] summary: Floating sessions of a license description: | Requires the `admin` or `licenses:write` scope; a bound key only for its product's licenses. x-required-scopes: [admin, "licenses:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/LicenseID" - $ref: "#/components/parameters/PageLimit" - $ref: "#/components/parameters/PageOffset" responses: "200": description: A page of sessions content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: allOf: - $ref: "#/components/schemas/Page" - type: object properties: sessions: type: array items: { $ref: "#/components/schemas/FloatingSession" } active: type: integer description: Sessions not yet expired "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": $ref: "#/components/responses/AdminLicenseNotFound" "429": $ref: "#/components/responses/RateLimited" /admin/activations/{id}: delete: tags: [Admin Licenses] summary: Remove an activation description: | Requires the `admin` or `licenses:write` scope; a bound key only for activations of its product's licenses. Frees the slot and sends `license.deactivated`. x-required-scopes: [admin, "licenses:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - name: id in: path required: true description: Activation id schema: { type: string, format: uuid } responses: "204": description: Removed "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": description: "`NOT_FOUND`: activation not found" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" /admin/releases: get: tags: [Admin Releases] summary: List releases description: | Requires the `admin` or `releases:write` scope. A key bound to a product sees only that product's releases; another `product_id` answers `403 PRODUCT_SCOPE_MISMATCH`. Paginated (default 50, max 200). Each release embeds its product and artifacts. x-required-scopes: [admin, "releases:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - { name: product_id, in: query, schema: { type: string, format: uuid } } - { name: channel, in: query, schema: { type: string, enum: [stable, beta, alpha, dev] } } - { name: status, in: query, schema: { type: string, enum: [draft, published, yanked] } } - $ref: "#/components/parameters/PageLimit" - $ref: "#/components/parameters/PageOffset" responses: "200": description: A page of releases content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: allOf: - $ref: "#/components/schemas/Page" - type: object properties: releases: type: array items: { $ref: "#/components/schemas/Release" } latest_published: type: array description: | The highest published version (by semver) of each product and channel, whatever the channel, status and page asked for; only the product_id filter applies. items: type: object properties: product_id: { type: string, format: uuid } channel: { type: string } version: { type: string } "400": description: "`BAD_REQUEST`: invalid channel or status" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "429": $ref: "#/components/responses/RateLimited" post: tags: [Admin Releases] summary: Create a draft release description: | Requires the `admin` or `releases:write` scope; a bound key only for its product. The release starts as a `draft`: add artifacts, upload and finalize each one, then publish. `version` is a semantic version of at most 64 characters; a prerelease version (such as `1.2.0-beta.1`) cannot go on the stable channel. One release per product and version. x-required-scopes: [admin, "releases:write"] security: - BearerAuth: [] - CookieAuth: [] requestBody: required: true content: application/json: schema: type: object required: [product_id, version] properties: product_id: { type: string, format: uuid } version: { type: string, maxLength: 64, example: "1.2.3" } channel: type: string enum: [stable, beta, alpha, dev] default: stable name: { type: string, maxLength: 256 } release_notes: { type: string, maxLength: 65536, description: Markdown } responses: "201": description: Draft created content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: { $ref: "#/components/schemas/Release" } "400": description: | - `BAD_REQUEST`: missing fields, name or notes too long - `INVALID_VERSION`: not a valid version, or a prerelease on stable - `INVALID_CHANNEL` - `INCOMPATIBLE_PRODUCT_TYPE`: the product (SaaS) has no releases content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": description: "`NOT_FOUND`: product not found" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "409": description: "`RELEASE_EXISTS`: a release for this product and version already exists" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" /admin/releases/{id}: get: tags: [Admin Releases] summary: Get a release description: | Requires the `admin` or `releases:write` scope; a bound key only for its product's releases. Embeds the product and artifacts. x-required-scopes: [admin, "releases:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/ReleaseID" responses: "200": description: The release content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: { $ref: "#/components/schemas/Release" } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": $ref: "#/components/responses/AdminReleaseNotFound" "429": $ref: "#/components/responses/RateLimited" patch: tags: [Admin Releases] summary: Update a release's name and notes description: | Requires the `admin` or `releases:write` scope; a bound key only for its product's releases. Only the display fields change, in any status. Version, channel and status are fixed (status moves through the actions). At least one field is required; `name` is trimmed. x-required-scopes: [admin, "releases:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/ReleaseID" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ReleaseUpdate" responses: "200": description: The updated release content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: { $ref: "#/components/schemas/Release" } "400": description: "`BAD_REQUEST`: invalid body, no field given, name or notes too long" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": $ref: "#/components/responses/AdminReleaseNotFound" "429": $ref: "#/components/responses/RateLimited" delete: tags: [Admin Releases] summary: Delete a draft release description: | Requires the `admin` or `releases:write` scope; a bound key only for its product's releases. Only drafts can be deleted, with their artifacts; yank a published release instead. x-required-scopes: [admin, "releases:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/ReleaseID" responses: "200": description: Deleted content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: status: { type: string, enum: [deleted] } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": $ref: "#/components/responses/AdminReleaseNotFound" "409": description: "`NOT_DELETABLE`: only draft releases can be deleted" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" /admin/releases/{id}/notes: patch: tags: [Admin Releases] summary: Update a release's name and notes (deprecated alias) deprecated: true description: | Deprecated alias of `PATCH /admin/releases/{id}`, with the same body, scopes and responses. Use that route. x-required-scopes: [admin, "releases:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/ReleaseID" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ReleaseUpdate" responses: "200": description: The updated release content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: { $ref: "#/components/schemas/Release" } "400": description: "`BAD_REQUEST`" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": $ref: "#/components/responses/AdminReleaseNotFound" /admin/releases/{id}/artifacts: post: tags: [Admin Releases] summary: Add an artifact and get an upload URL description: | Requires the `admin` or `releases:write` scope; a bound key only for its product's releases. Only on a draft, one artifact per platform. Returns a presigned `upload_url`. Upload the file with an HTTP `PUT` to it before `expires_at`, sending the same `Content-Type` as `content_type` (default `application/octet-stream`), which is signed into the URL; when `expected_size` is given, the `Content-Length` must match it. Then call finalize. x-required-scopes: [admin, "releases:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/ReleaseID" requestBody: required: true content: application/json: schema: type: object required: [platform] properties: platform: type: string enum: [darwin-arm64, darwin-x64, windows-arm64, windows-x64, linux-arm64, linux-x64, linux-armhf] content_type: { type: string, maxLength: 128 } expected_size: type: integer format: int64 minimum: 0 maximum: 10737418240 description: Size in bytes, at most 10 GiB. 0 or omitted means unknown. filename: type: string maxLength: 256 description: The original file name, shown in the dashboard responses: "201": description: Artifact created; upload to `upload_url` content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: artifact: { $ref: "#/components/schemas/ReleaseArtifact" } upload_url: { type: string, format: uri } expires_at: { type: string, format: date-time } "400": description: | `BAD_REQUEST` (platform missing, `expected_size` out of range, filename or content type too long) or `INVALID_PLATFORM` content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": $ref: "#/components/responses/AdminReleaseNotFound" "409": description: | - `RELEASE_NOT_DRAFT`: artifacts can only be added to drafts - `ARTIFACT_EXISTS`: the release already has an artifact for this platform content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" "503": description: "`STORAGE_DISABLED`: release storage is not configured" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /admin/releases/{id}/artifacts/{aid}/finalize: post: tags: [Admin Releases] summary: Finalize an uploaded artifact description: | Requires the `admin` or `releases:write` scope; a bound key only for its product's releases. The server reads the uploaded bytes back from storage and computes the SHA-256 itself; that value is the one published. `expected_sha256`, when given, must match it. The body is optional. `sha256` is accepted as a legacy alias of `expected_sha256`. x-required-scopes: [admin, "releases:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/ReleaseID" - $ref: "#/components/parameters/ArtifactID" requestBody: required: false content: application/json: schema: type: object properties: expected_sha256: type: string pattern: "^[0-9a-f]{64}$" description: 64 lower case hex characters sha256: type: string deprecated: true description: Legacy alias of `expected_sha256` responses: "200": description: The finalized artifact content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: { $ref: "#/components/schemas/ReleaseArtifact" } "400": description: "`INVALID_SHA256`: not 64 lower case hex characters" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": description: "`NOT_FOUND` or `ARTIFACT_NOT_FOUND`: no such release, or no such artifact on it" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "409": description: | - `UPLOAD_INCOMPLETE`: nothing was uploaded yet - `SHA256_MISMATCH`: the uploaded bytes do not hash to `expected_sha256` - `RELEASE_NOT_DRAFT`: the release is no longer a draft content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "413": description: "`ARTIFACT_TOO_LARGE`: the uploaded file exceeds the finalize size limit" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" /admin/releases/{id}/artifacts/{aid}: delete: tags: [Admin Releases] summary: Delete an artifact of a draft description: | Requires the `admin` or `releases:write` scope; a bound key only for its product's releases. Only while the release is a draft. x-required-scopes: [admin, "releases:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/ReleaseID" - $ref: "#/components/parameters/ArtifactID" responses: "200": description: Deleted content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: status: { type: string, enum: [deleted] } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": description: "`NOT_FOUND` or `ARTIFACT_NOT_FOUND`: no such release, or no such artifact on it" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "409": description: "`RELEASE_NOT_DRAFT`: artifacts of published or yanked releases cannot be deleted" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" /admin/releases/{id}/actions/publish: post: tags: [Admin Releases] summary: Publish a draft release description: | Requires the `admin` or `releases:write` scope; a bound key only for its product's releases. Every artifact must be uploaded and finalized. With an active signing key, every artifact is signed (Ed25519, plus the Tauri minisign signature); without one, publishing is refused unless the product has `require_signing=false`, in which case the release ships unsigned. Sends `release.published`. No request body. x-required-scopes: [admin, "releases:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/ReleaseID" responses: "200": description: The published release content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: { $ref: "#/components/schemas/Release" } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": $ref: "#/components/responses/AdminReleaseNotFound" "409": description: | - `NOT_PUBLISHABLE`: the release is not a draft - `NO_ARTIFACTS`, `ARTIFACTS_NOT_READY`: add, upload and finalize artifacts first - `ARTIFACT_MISSING`: an artifact's file is gone from storage - `ARTIFACT_MODIFIED`: a file no longer matches its finalized SHA-256 - `SIGNING_NOT_CONFIGURED`: no signing key and the product requires signing - `SIGNING_KEY_MISSING`, `ARTIFACTS_NOT_SIGNED`: a concurrent change; retry content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "413": description: "`ARTIFACT_TOO_LARGE`: an artifact exceeds the signing size limit (`MAX_RELEASE_SIGN_SIZE_MB`)" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" /admin/releases/{id}/actions/yank: post: tags: [Admin Releases] summary: Yank a published release description: | Requires the `admin` or `releases:write` scope; a bound key only for its product's releases. Pulls every artifact of the release from the feeds; a download pinned to it answers `410 RELEASE_YANKED`. Sends `release.yanked`. x-required-scopes: [admin, "releases:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/ReleaseID" requestBody: required: true content: application/json: schema: type: object required: [reason] properties: reason: { type: string, maxLength: 1024, description: Trimmed; must not be blank } responses: "200": description: The yanked release content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: { $ref: "#/components/schemas/Release" } "400": description: "`BAD_REQUEST`: reason missing, blank or too long" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": $ref: "#/components/responses/AdminReleaseNotFound" "409": description: "`NOT_YANKABLE`: only published releases can be yanked" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" /admin/releases/{id}/actions/unyank: post: tags: [Admin Releases] summary: Restore a yanked release description: | Requires the `admin` or `releases:write` scope; a bound key only for its product's releases. Sends `release.unyanked`. No request body. x-required-scopes: [admin, "releases:write"] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/ReleaseID" responses: "200": description: The restored release content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: { $ref: "#/components/schemas/Release" } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": $ref: "#/components/responses/AdminReleaseNotFound" "409": description: "`NOT_UNYANKABLE`: only yanked releases can be restored, and only while the product's type has releases" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" /admin/webhooks: get: tags: [Admin Webhooks] summary: List webhook endpoints description: | Requires the `admin` scope. Paginated (default 50, max 200). Each endpoint belongs to one product and receives that product's events. Secrets are never returned after creation. x-required-scopes: [admin] security: - BearerAuth: [] - CookieAuth: [] parameters: - { name: product_id, in: query, schema: { type: string, format: uuid } } - { name: search, in: query, schema: { type: string } } - $ref: "#/components/parameters/PageLimit" - $ref: "#/components/parameters/PageOffset" responses: "200": description: A page of webhook endpoints content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: allOf: - $ref: "#/components/schemas/Page" - type: object properties: webhooks: type: array items: { $ref: "#/components/schemas/WebhookEndpoint" } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "429": $ref: "#/components/responses/RateLimited" post: tags: [Admin Webhooks] summary: Create a webhook endpoint description: | Requires the `admin` scope. Subscribes `url` to one or more events of one product (see the Webhooks section for the list; any other name is refused). The URL must be http(s), at most 2048 characters, and, unless the server runs with `WEBHOOK_ALLOW_PRIVATE`, must not name localhost, a private or loopback IP, or a numeric IPv4 shorthand such as `2130706433`, `0x7f000001` or `127.1`. Host names are resolved and checked again at every delivery. The signing secret is returned in this response only. Omit `secret` to have one generated (64 hex characters). x-required-scopes: [admin] security: - BearerAuth: [] - CookieAuth: [] requestBody: required: true content: application/json: schema: type: object required: [product_id, url, events] properties: product_id: { type: string, format: uuid } url: { type: string, format: uri, maxLength: 2048 } events: type: array minItems: 1 items: { $ref: "#/components/schemas/WebhookEventName" } secret: type: string description: Your own signing secret. Generated when omitted. responses: "201": description: Created; carries the secret content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: id: { type: string, format: uuid } product_id: { type: string, format: uuid } url: { type: string } secret: { type: string } events: type: array items: { type: string } active: { type: boolean } created_at: { type: string, format: date-time } "400": description: | `BAD_REQUEST` (missing fields, no events, an unknown event name, a private target) or `INVALID_INPUT` (URL empty, too long, or not http(s)) content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": description: "`NOT_FOUND`: product not found" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" /admin/webhooks/{id}: put: tags: [Admin Webhooks] summary: Update a webhook endpoint description: | Requires the `admin` scope. Every field is optional; only those sent change. The same URL and event rules as on create apply. The secret and product cannot be changed. x-required-scopes: [admin] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/WebhookID" requestBody: required: true content: application/json: schema: type: object properties: url: { type: string, format: uri, maxLength: 2048 } events: type: array minItems: 1 items: { $ref: "#/components/schemas/WebhookEventName" } active: type: boolean description: An inactive endpoint receives nothing, and its pending retries are given up responses: "200": description: The updated endpoint content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: { $ref: "#/components/schemas/WebhookEndpoint" } "400": description: "`BAD_REQUEST` or `INVALID_INPUT`, as on create" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": $ref: "#/components/responses/AdminWebhookNotFound" "429": $ref: "#/components/responses/RateLimited" delete: tags: [Admin Webhooks] summary: Delete a webhook endpoint description: Requires the `admin` scope. x-required-scopes: [admin] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/WebhookID" responses: "204": description: Deleted "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": $ref: "#/components/responses/AdminWebhookNotFound" "429": $ref: "#/components/responses/RateLimited" /admin/webhooks/{id}/deliveries: get: tags: [Admin Webhooks] summary: List an endpoint's deliveries description: Requires the `admin` scope. Paginated (default 50, max 200). x-required-scopes: [admin] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/WebhookID" - { name: status, in: query, schema: { type: string, enum: [pending, delivered, failed] } } - { name: event, in: query, schema: { type: string } } - $ref: "#/components/parameters/PageLimit" - $ref: "#/components/parameters/PageOffset" responses: "200": description: A page of deliveries content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: allOf: - $ref: "#/components/schemas/Page" - type: object properties: deliveries: type: array items: { $ref: "#/components/schemas/WebhookDelivery" } "400": description: "`BAD_REQUEST`: invalid status" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": $ref: "#/components/responses/AdminWebhookNotFound" "429": $ref: "#/components/responses/RateLimited" /admin/webhooks/{id}/deliveries/{delivery_id}: get: tags: [Admin Webhooks] summary: Get one delivery description: | Requires the `admin` scope. The full payload and the receiver's response (code and the first 4 KiB of the body). x-required-scopes: [admin] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/WebhookID" - $ref: "#/components/parameters/DeliveryID" responses: "200": description: The delivery content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: { $ref: "#/components/schemas/WebhookDelivery" } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": description: "`NOT_FOUND`: no such endpoint, or no such delivery on it" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" /admin/webhooks/{id}/deliveries/{delivery_id}/resend: post: tags: [Admin Webhooks] summary: Send a delivery again description: | Requires the `admin` scope. Creates a new delivery with the same event and payload (including the original `timestamp`) and a new `X-Keygate-Delivery` id, and sends it in the background with the usual retries. No request body. x-required-scopes: [admin] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/WebhookID" - $ref: "#/components/parameters/DeliveryID" responses: "201": description: The new delivery, status `pending` content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: { $ref: "#/components/schemas/WebhookDelivery" } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": description: "`NOT_FOUND`: no such endpoint, or no such delivery on it" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "409": description: "`NOT_RESENDABLE`: the endpoint is inactive" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" /admin/webhooks/{id}/test: post: tags: [Admin Webhooks] summary: Send a test event description: | Requires the `admin` scope. Sends a `webhook.test` event to this endpoint only, whatever it subscribes to, and waits for the answer. A failed test is retried like any delivery. No request body. x-required-scopes: [admin] security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/WebhookID" responses: "200": description: The outcome of the first attempt content: application/json: schema: type: object properties: success: { type: boolean, example: true } data: type: object properties: status: type: string enum: [delivered, pending, failed] description: "`pending` means it failed and a retry is scheduled" delivery_id: { type: string, format: uuid } response_code: type: integer description: The receiver's HTTP status; 0 when it could not be reached response_body: { type: string } "401": $ref: "#/components/responses/AdminUnauthorized" "403": $ref: "#/components/responses/AdminForbidden" "404": $ref: "#/components/responses/AdminWebhookNotFound" "409": description: "`WEBHOOK_INACTIVE`: enable the endpoint before testing it" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } "429": $ref: "#/components/responses/RateLimited" /me: get: tags: [Session] summary: The signed-in account in this context description: | Who the request is, on a tenant host and on the platform console alike. On a tenant host the payload reports this account's place in the workspace; on PLATFORM_HOST there is no tenant and `is_platform` is true. `is_admin` is true for a platform admin or a tenant owner/admin, so the dashboard's guard follows the same rule the API enforces. security: - BearerAuth: [] - CookieAuth: [] responses: "200": description: The account content: application/json: schema: type: object properties: success: { type: boolean } data: type: object properties: id: { type: string, format: uuid } email: { type: string, format: email } name: { type: string } avatar_url: { type: string } is_admin: { type: boolean, description: Platform admin or tenant owner/admin } role: { type: string, enum: [owner, admin, user], description: Platform role } is_platform: { type: boolean } is_platform_admin: { type: boolean } is_member: { type: boolean } is_tenant_admin: { type: boolean } tenant_role: { type: string, enum: [owner, admin, member, ""] } tenant: type: object properties: id: { type: string, format: uuid } slug: { type: string } name: { type: string } "401": $ref: "#/components/responses/Unauthorized" /me/tenants: get: tags: [Session] summary: Workspaces this account belongs to description: The workspace switcher on the platform host. security: - BearerAuth: [] - CookieAuth: [] responses: "200": description: The memberships content: application/json: schema: type: object properties: success: { type: boolean } data: type: object properties: tenants: type: array items: type: object properties: id: { type: string, format: uuid } slug: { type: string } name: { type: string } status: { type: string, enum: [active, suspended] } role: { type: string, enum: [owner, admin, member] } base_url: { type: string } total: { type: integer } "401": $ref: "#/components/responses/Unauthorized" /platform/tenants: get: tags: [Platform] summary: List workspaces description: Reachable only on PLATFORM_HOST by a platform admin. security: - BearerAuth: [] - CookieAuth: [] responses: "200": description: Every workspace with its counts content: application/json: schema: type: object properties: success: { type: boolean } data: type: object properties: tenants: type: array items: type: object properties: id: { type: string, format: uuid } slug: { type: string } name: { type: string } status: { type: string, enum: [active, suspended] } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } member_count: { type: integer } license_count: { type: integer } total: { type: integer } "403": $ref: "#/components/responses/AdminForbidden" "404": description: Not the platform host post: tags: [Platform] summary: Open a workspace description: | Creates the workspace, its default settings and the owner's membership. The owner account is created as a placeholder when the address is not known yet. security: - BearerAuth: [] - CookieAuth: [] requestBody: required: true content: application/json: schema: type: object required: [slug, name, owner_email] properties: slug: type: string description: 2–32 lower-case letters, digits or hyphens; becomes the subdomain. name: { type: string } owner_email: { type: string, format: email } responses: "201": description: Created content: application/json: schema: type: object properties: success: { type: boolean } data: type: object properties: tenant: { type: object } owner: { type: object } "400": $ref: "#/components/responses/BadRequest" "409": description: "`TENANT_EXISTS`: the slug is already taken" /platform/tenants/{id}: get: tags: [Platform] summary: One workspace security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/PlatformTenantID" responses: "200": description: The workspace and its counts "404": description: No such workspace patch: tags: [Platform] summary: Rename or suspend a workspace description: A suspended workspace refuses sign-ins until activated again. security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/PlatformTenantID" requestBody: required: true content: application/json: schema: type: object properties: name: { type: string } status: { type: string, enum: [active, suspended] } responses: "200": description: The updated workspace "400": $ref: "#/components/responses/BadRequest" "404": description: No such workspace /platform/tenants/{id}/members: get: tags: [Platform] summary: Members of a workspace security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/PlatformTenantID" responses: "200": description: The members, with their workspace role post: tags: [Platform] summary: Add a member description: A placeholder global account is created for an address not seen yet. security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/PlatformTenantID" requestBody: required: true content: application/json: schema: type: object required: [email] properties: email: { type: string, format: email } role: { type: string, enum: [owner, admin, member], default: member } responses: "200": description: The membership "400": $ref: "#/components/responses/BadRequest" /platform/tenants/{id}/members/{user_id}: patch: tags: [Platform] summary: Change a member's role security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/PlatformTenantID" - $ref: "#/components/parameters/PlatformUserID" requestBody: required: true content: application/json: schema: type: object required: [role] properties: role: { type: string, enum: [owner, admin, member] } responses: "200": description: The new role "400": $ref: "#/components/responses/BadRequest" delete: tags: [Platform] summary: Remove a membership description: The global account stays; only its place in this workspace goes. security: - BearerAuth: [] - CookieAuth: [] parameters: - $ref: "#/components/parameters/PlatformTenantID" - $ref: "#/components/parameters/PlatformUserID" responses: "200": description: Removed /platform/settings: get: tags: [Platform] summary: Platform-wide settings description: The defaults tenants inherit unless they override them. Secrets are reported as set or not, never returned. security: - BearerAuth: [] - CookieAuth: [] responses: "200": description: The settings put: tags: [Platform] summary: Write platform-wide settings security: - BearerAuth: [] - CookieAuth: [] requestBody: required: true content: application/json: schema: type: object additionalProperties: { type: string } responses: "200": description: Saved "400": description: An unknown setting or an invalid enum value webhooks: # Every event Keygate posts. Each delivery is a POST of # application/json with the WebhookPayload envelope; see the # WebhookPayload schema for headers, signing and retries. license.created: post: tags: [Webhooks] summary: A license was created description: | Sent when an admin or a `licenses:write` key creates a license, a Stripe checkout is fulfilled, or a visitor claims a license through self sign-up. Self sign-up adds `signup_source` and `marketing_consent`. parameters: - $ref: "#/components/parameters/WebhookEventHeader" - $ref: "#/components/parameters/WebhookSignatureHeader" - $ref: "#/components/parameters/WebhookDeliveryHeader" requestBody: content: application/json: schema: allOf: - $ref: "#/components/schemas/WebhookPayload" - type: object properties: data: type: object required: [license_id, email, plan_id] properties: license_id: { type: string, format: uuid } email: { type: string } plan_id: { type: string, format: uuid } signup_source: { type: string, enum: [self_signup] } marketing_consent: { type: boolean } responses: "200": $ref: "#/components/responses/WebhookAck" license.activated: post: tags: [Webhooks] summary: A new identifier was activated description: Sent by `POST /license/activate` for a new activation (not for `already_activated`). parameters: - $ref: "#/components/parameters/WebhookEventHeader" - $ref: "#/components/parameters/WebhookSignatureHeader" - $ref: "#/components/parameters/WebhookDeliveryHeader" requestBody: content: application/json: schema: allOf: - $ref: "#/components/schemas/WebhookPayload" - type: object properties: data: type: object properties: license_id: { type: string, format: uuid } identifier: { type: string } type: { type: string, enum: [device, user] } responses: "200": $ref: "#/components/responses/WebhookAck" license.deactivated: post: tags: [Webhooks] summary: An activation was removed description: | Sent by `POST /license/deactivate`, by the customer removing a device in the portal, by `DELETE /admin/activations/{id}`, and for each device that goes with a removed member (`POST /portal/seats/remove`, `DELETE /admin/licenses/{id}/seats/{seat_id}`), with a reissued license key (`POST /admin/licenses/{id}/rotate-key`) or member key (`POST /admin/licenses/{id}/seats/{seat_id}/rotate-key`), or with a rolled-back `POST /invites/accept`. parameters: - $ref: "#/components/parameters/WebhookEventHeader" - $ref: "#/components/parameters/WebhookSignatureHeader" - $ref: "#/components/parameters/WebhookDeliveryHeader" requestBody: content: application/json: schema: allOf: - $ref: "#/components/schemas/WebhookPayload" - type: object properties: data: type: object properties: license_id: { type: string, format: uuid } identifier: { type: string } responses: "200": $ref: "#/components/responses/WebhookAck" license.expiry_changed: post: tags: [Webhooks] summary: An admin changed a license's expiry description: Sent by `POST /admin/licenses/{id}/valid-until`. parameters: - $ref: "#/components/parameters/WebhookEventHeader" - $ref: "#/components/parameters/WebhookSignatureHeader" - $ref: "#/components/parameters/WebhookDeliveryHeader" requestBody: content: application/json: schema: allOf: - $ref: "#/components/schemas/WebhookPayload" - type: object properties: data: type: object properties: license_id: { type: string, format: uuid } email: { type: string } valid_until: type: [string, "null"] format: date-time description: RFC 3339; null = no expiry responses: "200": $ref: "#/components/responses/WebhookAck" license.updates_changed: post: tags: [Webhooks] summary: A perpetual license's update cutoff changed description: | `reason`: - `admin`: `POST /admin/licenses/{id}/updates-until` - `renewal`: a paid maintenance renewal was applied - `renewal_refunded`: a renewal was refunded in full and its period taken back parameters: - $ref: "#/components/parameters/WebhookEventHeader" - $ref: "#/components/parameters/WebhookSignatureHeader" - $ref: "#/components/parameters/WebhookDeliveryHeader" requestBody: content: application/json: schema: allOf: - $ref: "#/components/schemas/WebhookPayload" - type: object properties: data: type: object properties: license_id: { type: string, format: uuid } email: { type: string } updates_until: type: [string, "null"] format: date-time description: RFC 3339; null = no cutoff (updates for life) reason: { type: string, enum: [admin, renewal, renewal_refunded] } responses: "200": $ref: "#/components/responses/WebhookAck" license.expired: post: tags: [Webhooks] summary: The expiry sweep expired a license description: | `reason`: - `grace_period_ended`: past `valid_until` plus the grace period - `trial_ended`: a trial ran out - `past_due_timeout`: past due for 30 days A license that an upgrade moved onto a subscription is not expired by the grace or past due sweeps: it goes back to its free plan and `plan.changed` is sent instead. Likewise a trial whose plan names a fallback plan (`trial_fallback_plan_id`) is not expired when it runs out: it moves to that plan and `plan.changed` is sent with `reason: trial_ended`; devices already activated keep working (a paused member's once they are back), new ones are held to that plan's limit, and members beyond its seats are paused. Until the hourly sweep moves it, its key keeps working for at least a day past the trial's end, whatever the plan's grace days (an offline token issued then can carry the trial's features until that day is out). Add-ons on the license stay, as on any plan change. It still expires, with this event, should the fallback ever stop qualifying (the admin API refuses edits that would do that, so only a direct database change can), or when a Stripe subscription bills the trial: that one is Stripe's to settle, and expires only if nothing is heard past the plan's grace days. parameters: - $ref: "#/components/parameters/WebhookEventHeader" - $ref: "#/components/parameters/WebhookSignatureHeader" - $ref: "#/components/parameters/WebhookDeliveryHeader" requestBody: content: application/json: schema: allOf: - $ref: "#/components/schemas/WebhookPayload" - type: object properties: data: type: object properties: license_id: { type: string, format: uuid } email: { type: string } reason: { type: string, enum: [grace_period_ended, trial_ended, past_due_timeout] } responses: "200": $ref: "#/components/responses/WebhookAck" license.canceled: post: tags: [Webhooks] summary: A Stripe subscription ended and the license was canceled description: | `reason`: - `subscription_deleted`: Stripe deleted the subscription - `subscription_unpaid`: the subscription turned unpaid (sent once; Stripe never deletes an unpaid subscription) Not sent for an upgraded license, which goes back to its free plan (`plan.changed`). parameters: - $ref: "#/components/parameters/WebhookEventHeader" - $ref: "#/components/parameters/WebhookSignatureHeader" - $ref: "#/components/parameters/WebhookDeliveryHeader" requestBody: content: application/json: schema: allOf: - $ref: "#/components/schemas/WebhookPayload" - type: object properties: data: type: object properties: license_id: { type: string, format: uuid } email: { type: string } reason: { type: string, enum: [subscription_deleted, subscription_unpaid] } responses: "200": $ref: "#/components/responses/WebhookAck" license.suspended: post: tags: [Webhooks] summary: A license was suspended description: | Sent by `POST /admin/licenses/{id}/suspend` (no `reason`), and when Stripe pauses the subscription (`reason: subscription_paused`). parameters: - $ref: "#/components/parameters/WebhookEventHeader" - $ref: "#/components/parameters/WebhookSignatureHeader" - $ref: "#/components/parameters/WebhookDeliveryHeader" requestBody: content: application/json: schema: allOf: - $ref: "#/components/schemas/WebhookPayload" - type: object properties: data: type: object properties: license_id: { type: string, format: uuid } email: { type: string } reason: { type: string, enum: [subscription_paused] } responses: "200": $ref: "#/components/responses/WebhookAck" license.reinstated: post: tags: [Webhooks] summary: A license became usable again description: | Sent by `POST /admin/licenses/{id}/reinstate`, by `POST /admin/licenses/{id}/valid-until` when it reactivates an expired license (both with the new `status`), and when Stripe resumes a paused subscription (no `status`). parameters: - $ref: "#/components/parameters/WebhookEventHeader" - $ref: "#/components/parameters/WebhookSignatureHeader" - $ref: "#/components/parameters/WebhookDeliveryHeader" requestBody: content: application/json: schema: allOf: - $ref: "#/components/schemas/WebhookPayload" - type: object properties: data: type: object properties: license_id: { type: string, format: uuid } email: { type: string } status: { type: string } responses: "200": $ref: "#/components/responses/WebhookAck" license.revoked: post: tags: [Webhooks] summary: A license was revoked description: | Sent by `POST /admin/licenses/{id}/revoke` (no `reason`), and with `reason: refund` by the admin "cancel and revoke" action `POST /admin/licenses/{id}/refund` and when a purchase is refunded in full in Stripe. A full refund of an upgrade sends `plan.changed` instead, and a refunded renewal sends `license.updates_changed`. parameters: - $ref: "#/components/parameters/WebhookEventHeader" - $ref: "#/components/parameters/WebhookSignatureHeader" - $ref: "#/components/parameters/WebhookDeliveryHeader" requestBody: content: application/json: schema: allOf: - $ref: "#/components/schemas/WebhookPayload" - type: object properties: data: type: object properties: license_id: { type: string, format: uuid } email: { type: string } reason: { type: string, enum: [refund] } responses: "200": $ref: "#/components/responses/WebhookAck" license.payment_failed: post: tags: [Webhooks] summary: A subscription payment failed parameters: - $ref: "#/components/parameters/WebhookEventHeader" - $ref: "#/components/parameters/WebhookSignatureHeader" - $ref: "#/components/parameters/WebhookDeliveryHeader" requestBody: content: application/json: schema: allOf: - $ref: "#/components/schemas/WebhookPayload" - $ref: "#/components/schemas/WebhookLicenseEmailData" responses: "200": $ref: "#/components/responses/WebhookAck" license.payment_recovered: post: tags: [Webhooks] summary: A past due subscription was paid description: Sent once per past due episode, when the license goes from past due back to active. parameters: - $ref: "#/components/parameters/WebhookEventHeader" - $ref: "#/components/parameters/WebhookSignatureHeader" - $ref: "#/components/parameters/WebhookDeliveryHeader" requestBody: content: application/json: schema: allOf: - $ref: "#/components/schemas/WebhookPayload" - $ref: "#/components/schemas/WebhookLicenseEmailData" responses: "200": $ref: "#/components/responses/WebhookAck" license.quantity_changed: post: tags: [Webhooks] summary: The seats a license is paid for changed description: | Sent when a license's quantity moves on a plan sold per seat (or to or from one): a change the customer made in the portal or the seller made in Stripe, once Stripe applied it, or an admin's change to a license nothing bills. Not sent for a change still waiting for payment. A downgrade is sent as the license takes the lower seats; when Stripe's answer was lost and it turns out not to have made the change, a second event moves them back. The license, when its plan is sold per seat, now holds `new_quantity` people, and its devices are the plan's per-seat value times it. parameters: - $ref: "#/components/parameters/WebhookEventHeader" - $ref: "#/components/parameters/WebhookSignatureHeader" - $ref: "#/components/parameters/WebhookDeliveryHeader" requestBody: content: application/json: schema: allOf: - $ref: "#/components/schemas/WebhookPayload" - type: object properties: data: type: object properties: license_id: { type: string, format: uuid } email: { type: string } plan_id: { type: string, format: uuid } old_quantity: { type: integer } new_quantity: { type: integer } reason: type: string enum: [upgrade, upgrade_reverted] description: Present when a free license's upgrade, or undoing it, moved the seats responses: "200": $ref: "#/components/responses/WebhookAck" quota.warning: post: tags: [Webhooks] summary: Usage crossed the warning threshold description: | Sent once when a usage increment crosses `threshold` (the `QUOTA_WARNING_THRESHOLD` ratio of the limit). Not sent for unlimited quotas. parameters: - $ref: "#/components/parameters/WebhookEventHeader" - $ref: "#/components/parameters/WebhookSignatureHeader" - $ref: "#/components/parameters/WebhookDeliveryHeader" requestBody: content: application/json: schema: allOf: - $ref: "#/components/schemas/WebhookPayload" - type: object properties: data: type: object properties: license_id: { type: string, format: uuid } feature: { type: string } used: { type: integer, format: int64 } limit: { type: integer, format: int64 } threshold: { type: number, example: 0.8 } responses: "200": $ref: "#/components/responses/WebhookAck" quota.exceeded: post: tags: [Webhooks] summary: A usage increment was refused description: | Sent on **every** usage call refused with `429 QUOTA_EXCEEDED`, not once per period. Not sent when usage lands exactly on the limit. `used` is the counter as it stands (the refused amount is not added). parameters: - $ref: "#/components/parameters/WebhookEventHeader" - $ref: "#/components/parameters/WebhookSignatureHeader" - $ref: "#/components/parameters/WebhookDeliveryHeader" requestBody: content: application/json: schema: allOf: - $ref: "#/components/schemas/WebhookPayload" - type: object properties: data: type: object properties: license_id: { type: string, format: uuid } feature: { type: string } used: { type: integer, format: int64 } limit: { type: integer, format: int64 } responses: "200": $ref: "#/components/responses/WebhookAck" seat.added: post: tags: [Webhooks] summary: A seat was added or restored description: | Sent by `POST /portal/seats/add` when it creates a seat or restores a removed one; not when the seat already existed, nor when a pending invitation is sent again. parameters: - $ref: "#/components/parameters/WebhookEventHeader" - $ref: "#/components/parameters/WebhookSignatureHeader" - $ref: "#/components/parameters/WebhookDeliveryHeader" requestBody: content: application/json: schema: allOf: - $ref: "#/components/schemas/WebhookPayload" - type: object properties: data: type: object properties: license_id: { type: string, format: uuid } seat_id: { type: string, format: uuid } email: { type: string } role: { type: string, enum: [member] } outcome: { type: string, enum: [created, restored] } responses: "200": $ref: "#/components/responses/WebhookAck" seat.removed: post: tags: [Webhooks] summary: A seat was removed description: | Sent when the license owner removes a member or cancels a pending invitation (`POST /portal/seats/remove`), or an admin does (`DELETE /admin/licenses/{id}/seats/{seat_id}`), once: removing a seat already removed sends nothing. Each device that went with a member follows as its own `license.deactivated`. parameters: - $ref: "#/components/parameters/WebhookEventHeader" - $ref: "#/components/parameters/WebhookSignatureHeader" - $ref: "#/components/parameters/WebhookDeliveryHeader" requestBody: content: application/json: schema: allOf: - $ref: "#/components/schemas/WebhookPayload" - type: object properties: data: type: object properties: license_id: { type: string, format: uuid } seat_id: { type: string, format: uuid } email: { type: string } responses: "200": $ref: "#/components/responses/WebhookAck" plan.changed: post: tags: [Webhooks] summary: A license moved to another plan description: | Sent when a portal change (`POST /portal/subscription/change` or change-plan) or an edit in Stripe moves the license's plan (a downgrade as the license takes the new plan, with a second event back should Stripe turn out not to have made it), by `POST /admin/licenses/{id}/change-plan`, and when a paid upgrade is applied, all without `reason`. When an upgrade is undone and the license goes back to its free plan, `reason` says why: `refund`, `subscription_ended`, `subscription_unpaid`, `past_due_timeout`, `grace_period_ended` or `admin_canceled`. `trial_ended`: the license's trial ran out and its trial plan names a fallback plan (`trial_fallback_plan_id`), so it moved there instead of expiring. parameters: - $ref: "#/components/parameters/WebhookEventHeader" - $ref: "#/components/parameters/WebhookSignatureHeader" - $ref: "#/components/parameters/WebhookDeliveryHeader" requestBody: content: application/json: schema: allOf: - $ref: "#/components/schemas/WebhookPayload" - type: object properties: data: type: object properties: license_id: { type: string, format: uuid } email: type: string description: The license's email, with `reason` `trial_ended` old_plan_id: { type: string, format: uuid } new_plan_id: { type: string, format: uuid } old_quantity: type: integer description: With `new_quantity`, when an admin moved the license to a plan sold per seat and its seats changed new_quantity: { type: integer } reason: type: string enum: [refund, subscription_ended, subscription_unpaid, past_due_timeout, grace_period_ended, admin_canceled, trial_ended] responses: "200": $ref: "#/components/responses/WebhookAck" release.published: post: tags: [Webhooks] summary: A release was published parameters: - $ref: "#/components/parameters/WebhookEventHeader" - $ref: "#/components/parameters/WebhookSignatureHeader" - $ref: "#/components/parameters/WebhookDeliveryHeader" requestBody: content: application/json: schema: allOf: - $ref: "#/components/schemas/WebhookPayload" - $ref: "#/components/schemas/WebhookReleaseData" responses: "200": $ref: "#/components/responses/WebhookAck" release.yanked: post: tags: [Webhooks] summary: A release was yanked parameters: - $ref: "#/components/parameters/WebhookEventHeader" - $ref: "#/components/parameters/WebhookSignatureHeader" - $ref: "#/components/parameters/WebhookDeliveryHeader" requestBody: content: application/json: schema: allOf: - $ref: "#/components/schemas/WebhookPayload" - $ref: "#/components/schemas/WebhookReleaseData" responses: "200": $ref: "#/components/responses/WebhookAck" release.unyanked: post: tags: [Webhooks] summary: A yanked release was restored parameters: - $ref: "#/components/parameters/WebhookEventHeader" - $ref: "#/components/parameters/WebhookSignatureHeader" - $ref: "#/components/parameters/WebhookDeliveryHeader" requestBody: content: application/json: schema: allOf: - $ref: "#/components/schemas/WebhookPayload" - $ref: "#/components/schemas/WebhookReleaseData" responses: "200": $ref: "#/components/responses/WebhookAck" webhook.test: post: tags: [Webhooks] summary: Test delivery description: | Sent by `POST /admin/webhooks/{id}/test` to that endpoint only, whatever it subscribes to. It cannot be subscribed to. parameters: - $ref: "#/components/parameters/WebhookEventHeader" - $ref: "#/components/parameters/WebhookSignatureHeader" - $ref: "#/components/parameters/WebhookDeliveryHeader" requestBody: content: application/json: schema: allOf: - $ref: "#/components/schemas/WebhookPayload" - type: object properties: data: type: object properties: webhook_id: { type: string, format: uuid } message: { type: string, example: "This is a test delivery from Keygate." } responses: "200": $ref: "#/components/responses/WebhookAck" components: parameters: ProductSlug: in: path name: product_slug required: true schema: { type: string } example: my-app CheckoutID: in: path name: checkout_id required: true description: The plan's 8-character checkout id, as in its `/pay/{checkout_id}` link. schema: { type: string, minLength: 8, maxLength: 8 } example: a1b2c3d4 Platform: in: query name: platform required: true description: | Also accepts the aliases updaters produce (`darwin-aarch64`, `darwin-x86_64`, `windows-x86_64`, `windows-aarch64`, `linux-x86_64`, `linux-aarch64`, `linux-armv7`). schema: type: string enum: [ darwin-arm64, darwin-x64, windows-arm64, windows-x64, linux-arm64, linux-x64, linux-armhf, ] Channel: in: query name: channel schema: type: string enum: [stable, beta, alpha, dev] default: stable LicenseKey: in: header name: X-License-Key required: false schema: { type: string } description: | Optional, and header-only: the key is a long-lived credential that also opens activate, verify and download, and one placed in the URL is written to CDN, proxy and access logs before the request arrives. A feed called with a `license_key` query parameter answers 400; use `license_token` there instead. Identifies the installation's license. A perpetual license with a maintenance period then sees only releases published at or before its `updates_until`; other licenses see the full feed. An unknown or unusable key answers 404, so the gate cannot be skipped by dropping the key. A product must set `feed_license_required` before any of its plans can sell an update period or renewals; its feeds then answer 401 `LICENSE_KEY_REQUIRED` without a key or token. Turning it on is a rollout, in this order: ship an updater that sends the license, set the flag, wait out the links the public feed already handed out (the admin API says how long), and only then give a plan an update period. Other products keep a public, ungated feed. An updater that cannot set headers uses `license_token`. LicenseToken: in: query name: license_token required: false schema: { type: string } description: | The only credential accepted in the URL, and what updaters that can only put things there use (Sparkle's `feedParametersForUpdater`). The signed token from `/license/verify` or `/license/activate` (also accepted as an `X-License-Token` header): it identifies the same license, expires with the plan's `token_ttl_days`, and is accepted on the feeds only. The period comes from the license as it reads now, not from the token, so a renewal or a revocation takes effect at once. An invalid, expired or unusable token answers 404, like an unknown key. Limit: in: query name: limit schema: type: integer minimum: 1 maximum: 100 default: 20 IdempotencyKey: in: header name: Idempotency-Key required: false schema: type: string minLength: 1 maxLength: 256 description: | Same key and same body replays the stored response (2xx or 4xx) for 24 hours. Keys are shared by all callers of the endpoint, so use random ones (UUIDv4). See Idempotency in the top-level docs. PathLicenseKey: in: path name: license_key required: true description: The license key. Redacted from server logs; redact it in yours too. schema: { type: string } LicenseID: in: path name: id required: true description: License id schema: { type: string, format: uuid } ReleaseID: in: path name: id required: true description: Release id schema: { type: string, format: uuid } ArtifactID: in: path name: aid required: true description: Artifact id; must belong to the release in the path schema: { type: string, format: uuid } WebhookID: in: path name: id required: true description: Webhook endpoint id schema: { type: string, format: uuid } DeliveryID: in: path name: delivery_id required: true description: Delivery id; must belong to the endpoint in the path schema: { type: string, format: uuid } PlatformTenantID: in: path name: id required: true description: Workspace id schema: { type: string, format: uuid } PlatformUserID: in: path name: user_id required: true description: Global account id schema: { type: string, format: uuid } PageLimit: in: query name: limit description: Page size. Values above 200 are clamped to 200; values below 1 or unparseable take the default. schema: { type: integer, default: 50, maximum: 200 } PageOffset: in: query name: offset schema: { type: integer, default: 0, minimum: 0 } WebhookEventHeader: in: header name: X-Keygate-Event required: true description: The event name, as in the payload's `event` schema: { type: string } WebhookSignatureHeader: in: header name: X-Keygate-Signature required: true description: | `sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with the endpoint's secret (the secret string as returned at creation, used as bytes). Verify it over the bytes received, before parsing, with a constant-time comparison. schema: { type: string, example: "sha256=5d41402abc4b2a76b9719d911017c592..." } WebhookDeliveryHeader: in: header name: X-Keygate-Delivery required: true description: The delivery id. Retries of a delivery keep it; a manual resend gets a new one. schema: { type: string, format: uuid } responses: SubscriptionChangeRefused: description: | The change cannot be made as asked: - `SUBSCRIPTION_NOT_ACTIVE`: the license or subscription is not active or trialing (settle open payments first) - `NO_CHANGE` (preview only): the subscription already has these terms and no change waits for payment - `CHANGE_NOT_SUPPORTED`: a subscription the portal cannot change (on a schedule, several items, metered usage, or no plan of the product), or, for a change charged now, one not charged automatically or with collection paused - `CHANGE_IN_PROGRESS`: another change to this license is being made with Stripe; try again shortly - `PAYMENT_METHOD_UNSUPPORTED`: a change charged now, on a subscription paid with a method Stripe cannot hold a change for until it is paid (bank debits such as SEPA or ACH). Downgrades charge nothing and are not refused this way - `QUANTITY_OUT_OF_RANGE`: the plan sells `details.min` to `details.max` seats; never moved into range silently - `SEATS_IN_USE`: fewer seats than the people on the license (`details.in_use`, `details.seat_limit`) - `DEVICES_IN_USE`: fewer devices (or floating sessions) than are in use (`details.in_use`, `details.activation_limit`; `details.kind` is `activations`, which the owner can remove, or `sessions`, which end on their own). Only a change that lowers a limit is refused this way; one that leaves a license over a limit it was already over goes ahead - `DOWNGRADE_NOT_SUPPORTED`: a change that lowers a limit but raises the other one, costs more for the rest of the period, bills on another schedule (monthly to yearly, say), which Stripe would invoice at once, or moves to another plan from a price no plan sells any more - `PREVIEW_EXPIRED` (change only): the preview is over an hour old, the subscription renewed since, or the plan's limits changed so the change is billed another way - `SUBSCRIPTION_CHANGED`: the subscription moved since the preview, or Stripe replayed an answer for a change made earlier on a subscription that has moved on since - `CHANGE_REFUSED`: Stripe refused it (or, in a preview, would not price it): a payment method that cannot hold a change until paid (bank debits), a plan in another currency, a missing tax address content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } RateLimited: description: "`RATE_LIMITED`: too many requests from this IP. `Retry-After` is the bucket's window in seconds (usually 60)." content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } LicenseThrottled: description: | `LOCKED_OUT`: this IP is locked out after repeated failed license calls; `Retry-After` and `details.retry_after` give the seconds to wait. `RATE_LIMITED`: too many requests from this IP (`Retry-After: 60`). content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } IdempotencyBodyTooLarge: description: "`BODY_TOO_LARGE`: sent with an `Idempotency-Key`, the body is over 256 KiB" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } IdempotencyConflict: description: | `IDEMPOTENCY_KEY_CONFLICT`: the same Idempotency-Key was used with a different body (possibly by another client; keys are shared per endpoint) content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } SessionRequired: description: "`UNAUTHORIZED`: no portal session, or it is invalid or expired" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } PortalBodyTooLarge: description: "`BODY_TOO_LARGE`: the body is over 64 KiB" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } BadRequest: description: The request body or one of its fields is invalid. content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } Unauthorized: description: "`UNAUTHORIZED`: no session, or it is invalid or expired" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } AdminUnauthorized: description: | `INVALID_API_KEY`: unknown `kg_live_` key. `UNAUTHORIZED`: no credential, or an invalid or expired session. content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } AdminForbidden: description: | - `INSUFFICIENT_SCOPE`: the API key has none of the scopes in `x-required-scopes` - `PRODUCT_SCOPE_MISMATCH`: the key is bound to another product - `FORBIDDEN`: the signed-in user is not an admin content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } AdminLicenseNotFound: description: "`NOT_FOUND`: license not found" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } AdminReleaseNotFound: description: "`NOT_FOUND` or `RELEASE_NOT_FOUND`: release not found" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } AdminWebhookNotFound: description: "`NOT_FOUND`: webhook endpoint not found" content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } WebhookAck: description: | Any 2xx marks the delivery delivered. Anything else, a network error, or no answer within `WEBHOOK_HTTP_TIMEOUT` (default 10s) schedules a retry. The first 4 KiB of your response body is kept on the delivery for debugging. securitySchemes: BearerAuth: type: http scheme: bearer description: | API key (`Authorization: Bearer kg_live_…`) for server-to-server access to `/admin/*`; not for the public SDK endpoints. Its scopes decide which routes it reaches (`admin`, `licenses:write`, `releases:write`; see `x-required-scopes` on each operation). A `licenses:write` or `releases:write` key may be bound to one product. Mint keys in the dashboard. An admin dashboard session JWT is also accepted in this header. CookieAuth: type: apiKey in: cookie name: session description: | Session cookie (a JWT valid for 24 hours) issued by `/auth/otp/verify`, `/auth/refresh`, `/signup/{checkout_id}/verify`, `/invites/accept`, or `/auth/dev-login` on a development install. Required for `/portal/*`; an admin user's session also opens `/admin/*`. The cookie is HttpOnly + SameSite=Lax; browsers attach it automatically. The same JWT is accepted as `Authorization: Bearer `. RefreshCookie: type: apiKey in: cookie name: refresh_token description: | Refresh token cookie (30 days), set with the session cookie and sent only to `/api/v1/auth/refresh`. HttpOnly + SameSite=Lax. schemas: VelopackFeed: type: object description: Velopack's VelopackAssetFeed. SHA256 is upper case hex, as Velopack compares it. properties: Assets: type: array items: type: object properties: PackageId: { type: string } Version: { type: string } Type: { type: string, enum: [Full] } FileName: { type: string, description: "{version}_{channel}_{platform}.nupkg, downloaded from the velopack base URL" } SHA256: { type: string } Size: { type: integer, format: int64 } NotesMarkdown: { type: string } ResponseMeta: type: object description: Server identification sent with activate and verify answers. properties: server: { type: string, example: Keygate } url: { type: string, example: "https://keygate.app" } ActivateResponse: type: object properties: success: type: boolean example: true data: type: object properties: status: type: string enum: [activated, already_activated] license_id: type: string format: uuid token: type: string description: | Ed25519-signed offline verification token. Format: `.`. Verify using the server's public key from /license/pubkey. Payload fields are listed under VerifyResponse. meta: $ref: "#/components/schemas/ResponseMeta" VerifyResponse: type: object properties: success: type: boolean example: true data: type: object properties: status: type: string enum: [active, trialing, past_due, canceled] description: | Only usable states reach a 200: `canceled` while still inside its paid period, and `active`, `trialing` or `past_due` up to the end of the grace period. Every other state answers 404. plan_id: type: string plan_name: type: string valid_until: type: string format: date-time description: Absent when the license has no expiry. updates_until: type: string format: date-time description: | End of a perpetual license's maintenance period. The license stays valid after this date, but releases published later cannot be installed; the signed token carries the same instant as `upd`. Absent when the license has no separate limit (subscriptions, trials, perpetual plans with updates for life). features: type: object description: | Key-value map of feature entitlements, as the signed token's `ftr` carries it: `bool` and `flag` features as JSON booleans (a flag is off only at the value `false`), every other type as its string value. example: premium_support: true api_calls: "50000" storage_gb: "100" token: type: string description: | Ed25519-signed offline verification token, `.`; the signed bytes are the base64url payload string. The payload is a JSON object with these fields (all times are unix seconds): | field | meaning | |---|---| | `lid`, `pid`, `pln` | license, product, plan ids | | `sts` | license status at issue time | | `did` | the identifier the token was issued for (trimmed) | | `fpr` | device binding fingerprint: the first 8 bytes of SHA-256(`did` + ":" + `pid`), hex encoded (16 characters) | | `ftr` | feature entitlements, same as `features` | | `iat`, `exp` | issued at; how long the client may stay offline before verifying again. `exp` is a check-in interval, not the license term, and never later than `vun` plus grace | | `vun` | when the license itself lapses; absent = no expiry | | `upd` | end of the maintenance period: install only releases published at or before this instant; absent = no separate limit | | `grc` | grace days after `vun` (0 for a trial that Stripe does not bill and whose plan names no fallback plan; at least 1 for one whose plan does, until it moves) | | `nce` | per-issuance nonce | A client that ships updates through its own channel must compare the release's publish date against `upd` itself; the server's feeds and download endpoint apply the same rule when given the license token or key. grace_days: type: integer description: Grace days after `valid_until`; 0 for a canceled license, or for a trial that Stripe does not bill and whose plan names no fallback plan; at least 1 for a trial whose plan names a fallback plan (`trial_fallback_plan_id`) and that Stripe does not bill, until it moves. external_customer_id: type: string description: Echoed when the license was created with one, so the client can check it belongs to the expected customer. Absent otherwise. external_workspace_id: type: string description: Echoed when set; absent otherwise. meta: $ref: "#/components/schemas/ResponseMeta" EntitlementResponse: type: object properties: success: type: boolean data: type: object properties: licensed: type: boolean enum: [true] description: Always true; a license that cannot be used answers 404 instead. status: type: string plan_id: type: string plan_name: type: string features: type: object additionalProperties: type: object properties: enabled: type: boolean value_type: type: string enum: [bool, int, string, quota, flag] value: type: string used: type: integer description: Quota features only limit: type: integer description: Quota features only; 0 = unlimited remaining: type: integer description: Quota features only; -1 when unlimited, never otherwise negative period: type: string enum: [hourly, daily, monthly, yearly] resets_at: type: string format: date-time UsageResponse: type: object properties: success: type: boolean example: true data: type: object properties: accepted: type: boolean used: type: integer example: 451 limit: type: integer example: 1000 description: 0 = unlimited remaining: type: integer example: 549 description: -1 when unlimited period: type: string example: monthly enum: [hourly, daily, monthly, yearly] period_key: type: string example: "2026-03" description: "The counted period: `2026-03-14T09` hourly, `2026-03-14` daily, `2026-03` monthly, `2026` yearly (UTC)" QuotaStatusResponse: type: object properties: success: type: boolean data: type: object properties: feature: type: string used: type: integer limit: type: integer description: 0 = unlimited remaining: type: integer description: -1 when unlimited, never otherwise negative period: type: string period_key: type: string FloatingCheckoutResponse: type: object properties: success: type: boolean data: type: object properties: session_id: type: string format: uuid expires_at: type: string format: date-time active_sessions: type: integer max_sessions: type: integer Error: type: object properties: success: type: boolean example: false error: type: object properties: code: type: string example: QUOTA_EXCEEDED message: type: string example: "usage quota exceeded for api_calls" details: type: [object, "null"] description: Present only for codes that document it Page: type: object description: Paging fields every paginated list carries next to its items. properties: total: { type: integer, description: "Rows matching the filter, not rows in this page" } limit: { type: integer, description: The limit actually applied } offset: { type: integer } LicenseStatus: type: string enum: [active, trialing, past_due, canceled, expired, suspended, revoked] License: type: object description: | A license as admin and portal payloads show it. The key is never part of it; the few responses that carry it add `license_key`. Optional fields are absent when empty. Some responses embed `product`, `plan` (with its entitlements), `activations`, `seats` and `addons`. properties: id: { type: string, format: uuid } product_id: { type: string, format: uuid } plan_id: { type: string, format: uuid } user_id: { type: string, format: uuid } email: { type: string } status: { $ref: "#/components/schemas/LicenseStatus" } valid_from: { type: string, format: date-time } valid_until: { type: string, format: date-time } updates_until: { type: string, format: date-time } canceled_at: { type: string, format: date-time } suspended_at: { type: string, format: date-time } suspended_by: { type: string, enum: [admin, stripe] } past_due_at: { type: string, format: date-time } notes: { type: string } org_name: { type: string } external_customer_id: { type: string } external_workspace_id: { type: string } payment_provider: { type: string } stripe_customer_id: { type: string } stripe_subscription_id: { type: string } stripe_payment_intent_id: { type: string } stripe_checkout_session_id: { type: string } quantity: type: integer description: Seats the license is paid for; a Stripe subscription's quantity. Only a plan sold per seat turns it into limits. seat_limit: type: integer description: People the license holds, the owner included (0 = no limit), as the server enforces it; not used on a desktop product, which has no people. Present when the plan is loaded. activation_limit: type: integer description: Devices or floating sessions the license holds, as the server enforces it. Present when the plan is loaded. stripe_pending_update_expires_at: type: string format: date-time description: A subscription change waits for payment until then; absent when none signup_source: { type: string, enum: [self_signup] } signup_plan_id: { type: string, format: uuid } marketing_consent: { type: boolean } marketing_consent_at: { type: string, format: date-time } activation_count: { type: integer } active_session_count: { type: integer } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } product: { type: object, description: "The product, when embedded" } plan: { type: object, description: "The plan, when embedded" } activations: type: array items: { $ref: "#/components/schemas/Activation" } seats: type: array items: { $ref: "#/components/schemas/Seat" } addons: type: array items: { $ref: "#/components/schemas/LicenseAddon" } Activation: type: object properties: id: { type: string, format: uuid } license_id: { type: string, format: uuid } identifier: { type: string } identifier_type: { type: string, enum: [device, user] } label: { type: string } ip_address: { type: string } last_verified: { type: string, format: date-time } created_at: { type: string, format: date-time } seat_id: type: string description: The member whose own key activated the device; absent for the license's key member_email: type: string description: That member's address, where devices are listed for a license (the admin license, the portal) RotatedLicenseKey: type: object properties: license_key: { type: string, description: The license's new key } devices_removed: { type: integer, description: How many devices the old key had activated, now removed } Seat: type: object properties: id: { type: string, format: uuid } license_id: { type: string, format: uuid } user_id: { type: string, format: uuid } email: { type: string } role: { type: string, enum: [member], description: Every seat is a member; the license owner manages the members } invited_at: type: string format: date-time description: When the invitation was last sent accepted_at: type: string format: date-time description: Absent while the invitation is pending removed_at: { type: string, format: date-time } invite_expires_at: { type: string, format: date-time } paused: type: boolean description: A member who joined beyond the license's seat limit; their own key may not be used until a seat is free. Absent otherwise created_at: { type: string, format: date-time } UsageEvent: type: object properties: id: { type: string, format: uuid } license_id: { type: string, format: uuid } feature: { type: string } quantity: { type: integer, format: int64 } metadata: { type: object } ip_address: { type: string } recorded_at: { type: string, format: date-time } UsageCounter: type: object properties: id: { type: string, format: uuid } license_id: { type: string, format: uuid } feature: { type: string } period: { type: string } period_key: { type: string } used: { type: integer, format: int64 } updated_at: { type: string, format: date-time } LicenseAddon: type: object properties: id: { type: string, format: uuid, description: The attachment's id } license_id: { type: string, format: uuid } addon_id: { type: string, format: uuid } enabled: { type: boolean } created_at: { type: string, format: date-time } addon: type: object description: The add-on, when embedded properties: id: { type: string, format: uuid } product_id: { type: string, format: uuid } name: { type: string } slug: { type: string } description: { type: string } feature: { type: string } value_type: { type: string } value: { type: string } quota_period: { type: string } quota_unit: { type: string } active: type: boolean description: Whether the add-on is still offered; one that is not is attached to no further license, and licenses that have it keep it FloatingSession: type: object properties: id: { type: string, format: uuid } license_id: { type: string, format: uuid } identifier: { type: string } label: { type: string } ip_address: { type: string } checked_out: { type: string, format: date-time } expires_at: { type: string, format: date-time } heartbeat: { type: string, format: date-time } seat_id: type: string description: The member whose own key checked the session out; absent for the license's key Release: type: object properties: id: { type: string, format: uuid } product_id: { type: string, format: uuid } version: { type: string } channel: { type: string, enum: [stable, beta, alpha, dev] } name: { type: string } release_notes: { type: string } status: { type: string, enum: [draft, published, yanked] } yanked_reason: { type: string } published_at: { type: string, format: date-time } yanked_at: { type: string, format: date-time } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } product: { type: object, description: "The product, when embedded" } artifacts: type: array items: { $ref: "#/components/schemas/ReleaseArtifact" } ReleaseArtifact: type: object properties: id: { type: string, format: uuid } release_id: { type: string, format: uuid } platform: { type: string } filename: { type: string, description: The name given at upload; empty for older artifacts } file_key: { type: string, description: Storage key } file_size: { type: integer, format: int64 } sha256: { type: string, description: Set by finalize; empty until then } ed25519_sig: { type: string, description: Set when the release is published with a signing key } content_type: { type: string } signing_key_id: { type: string } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } ReleaseUpdate: type: object minProperties: 1 properties: name: { type: string, maxLength: 256 } release_notes: { type: string, maxLength: 65536 } WebhookEndpoint: type: object description: A webhook endpoint. The secret is never included. properties: id: { type: string, format: uuid } product_id: { type: string, format: uuid } url: { type: string } events: type: array items: { $ref: "#/components/schemas/WebhookEventName" } active: { type: boolean } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } product: { type: object, description: "The product, when embedded" } WebhookDelivery: type: object properties: id: { type: string, format: uuid } webhook_id: { type: string, format: uuid } event: { type: string } payload: { $ref: "#/components/schemas/WebhookPayload" } response_code: { type: integer, description: Last HTTP status; 0 when the receiver could not be reached } response_body: { type: string, description: "First 4 KiB of the last response body, or the network error" } attempts: { type: integer } next_retry: { type: string, format: date-time } status: { type: string, enum: [pending, delivered, failed] } created_at: { type: string, format: date-time } delivered_at: { type: string, format: date-time } SubscriptionChangeRequest: type: object required: [license_id] properties: license_id: { type: string, format: uuid } price_id: type: string description: Stripe price id of the plan to move to; empty keeps the plan quantity: type: integer minimum: 0 description: Seats on a plan sold per seat; 0 keeps the current number. Within the plan's `min_quantity` and `max_quantity`. SubscriptionTerms: type: object properties: plan_id: { type: string, format: uuid } plan_name: { type: string } price_id: { type: string } per_seat: { type: boolean } quantity: { type: integer } seat_limit: { type: integer, description: "0 = no limit" } activation_limit: { type: integer } SubscriptionChangeResult: type: object properties: status: { type: string, enum: [applied, pending_payment, processing] } plan_id: { type: string, format: uuid } plan_name: { type: string } quantity: { type: integer } hosted_invoice_url: { type: string, description: "With `pending_payment`: Stripe's page to pay on" } expires_at: { type: string, format: date-time, description: "With `pending_payment`: when Stripe drops the change" } downgrade: { type: boolean, description: "The change lowered a limit: applied at once, billed from the next invoice" } keep: { type: boolean, description: "The subscription's own terms were kept, dropping a change waiting for payment; nothing was charged" } WebhookEventName: type: string description: An event a webhook endpoint can subscribe to. `webhook.test` is not one. enum: - license.created - license.activated - license.deactivated - license.expiry_changed - license.updates_changed - license.expired - license.canceled - license.suspended - license.reinstated - license.revoked - license.payment_failed - license.payment_recovered - license.quantity_changed - quota.warning - quota.exceeded - seat.added - seat.removed - plan.changed - release.published - release.yanked - release.unyanked WebhookPayload: type: object description: | The body of every webhook delivery, posted as `application/json` to each active endpoint of the event's product that subscribes to the event (exact name match). Headers: `X-Keygate-Event` (the event), `X-Keygate-Delivery` (the delivery id) and `X-Keygate-Signature: sha256=`, the HMAC-SHA256 of the raw body keyed with the endpoint's secret. The signature covers the body only; check `timestamp` yourself if you want to refuse old deliveries. Delivery: any 2xx within `WEBHOOK_HTTP_TIMEOUT` (default 10s) counts as delivered. Otherwise the delivery is retried 1, 2, 4 and 8 minutes after the failed attempts (30 seconds times 2 to the number of attempts so far), checked every `WEBHOOK_RETRY_INTERVAL` (default 30s), until `WEBHOOK_MAX_ATTEMPTS` attempts (default 5) have been made; then it is marked `failed`. Redirects are followed up to 10 hops, each checked against the private address rules. Deliveries are sent concurrently, so their order is not guaranteed. Retries keep the `X-Keygate-Delivery` id; use it to deduplicate. required: [event, timestamp, data] properties: event: { type: string, example: license.created } timestamp: type: string format: date-time description: When the event was dispatched, RFC 3339 in UTC data: type: object description: Event-specific fields; see each event example: event: license.created timestamp: "2026-10-23T08:15:00Z" data: license_id: 3f1d2c4b-5a6e-4f70-8a9b-0c1d2e3f4a5b email: buyer@example.com plan_id: 0a9b8c7d-6e5f-4a3b-2c1d-0e9f8a7b6c5d WebhookLicenseEmailData: type: object properties: data: type: object properties: license_id: { type: string, format: uuid } email: { type: string } WebhookReleaseData: type: object properties: data: type: object properties: release_id: { type: string, format: uuid } version: { type: string } channel: { type: string, enum: [stable, beta, alpha, dev] } platforms: type: array items: { type: string } description: Platforms of the release's artifacts