Overview
Licenses, products, policies, customers, addons, whitelists and access rules, plus the endpoints your product calls to check a key.
Core is the licensing part of Kora. It is always on and every other module builds on it.
Records
| Record | What it is |
|---|---|
| Product | Something you sell. Has a version, price, license duration and the whitelist groups it uses. |
| Policy | A named variant of a product, such as "Pro". Holds the type, max machines, duration and addons a new license gets. |
| Addon | An extra sold on top of a product, such as a source code tier. |
| Customer | Who owns a license. Name, email, Discord ID and notes. Can be blocked. |
| License | A key for one product and one customer. Can carry a policy, addons, max machines, whitelist entries and an expiry date. |
| Whitelist group | A rule that limits where a key works, by IP, request header or request body field. |
| Access rule | Blocks or excludes an IP or range, a device or a country, for one product or all of them. |
Manage them from the dashboard, the management API or the Discord bot.
Checking a Key
Your product sends a POST to /api/v1/licenses/validate on the API port. The endpoint is public unless you turn on Require an API token in the configuration.
{
"license_key": "ABCDE-FGHIJ-KLMNO-PQRST",
"product_name": "My Plugin"
}product_name takes the product's name or ID. Add hwid to log the device, and any fields your whitelist groups check.
{
"valid": true,
"code": "valid",
"message": "This license is valid.",
"license": {
"key": "ABCDE-FGHIJ-KLMNO-PQRST",
"status": "active",
"product": { "id": 1, "name": "My Plugin", "version": "1.0.0", "active": true },
"policy": { "id": 2, "name": "Pro", "type": "subscription" },
"customer": { "id": 4, "name": "Test Customer 1", "email": "[email protected]", "discord_id": null },
"addons": [],
"expires_at": null,
"whitelists": {}
},
"timestamp": 1791083116035
}license is only filled in when the key is valid. policy is null for licenses issued without one. Each IP can make 60 requests a minute.
Result Codes
| Code | Meaning |
|---|---|
valid | The key works. |
invalid_request | No key was sent. |
not_found | The key does not exist. |
missing_product | No product was sent and the configuration requires one. |
wrong_product | The key belongs to a different product. |
disabled | The product is turned off. |
expired | The license has expired. |
inactive | The license has a status Kora does not know. |
leaked | The key was used in too many places. See Leak Detection. |
not_whitelisted | The request's IP, header or field is not on the license. |
seats_full | Activation found no free seat in a whitelist group. |
blacklisted | An access rule blocks the request. |
blocked | The license's customer is blocked. |
unauthorized | An API token is required and none was sent. |
| A status ID | The license has a negative status, such as suspended or revoked. |
Change the message for each code on the Lang page under Validation messages.
Signed Responses
Every validation response is signed with an Ed25519 key. The signature is in the x-kora-signature header, base64 encoded, over the exact response body. Checking it stops someone from faking a "valid" answer with a fake server.
Get the public key from Settings > Response Signing Key or from GET /api/v1/public-key, and put it in your product.
import crypto from "crypto";
const response = await fetch("https://api.kora.example.com/api/v1/licenses/validate", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ license_key: key, product_name: "My Plugin" })
});
const body = await response.text();
const trusted = crypto.verify(null, Buffer.from(body), crypto.createPublicKey(publicKey), Buffer.from(response.headers.get("x-kora-signature"), "base64"));
const valid = trusted && JSON.parse(body).valid;The SDKs check the signature for you when you give them the public key.
Rotate Key creates a new key pair. Every build that has the old public key stops trusting Kora until you update it.
Policies
A policy is a named variant of a product, such as "Pro" or "Lifetime". Create policies on the Policies page, then pick one when you issue a license.
| Field | What it does |
|---|---|
| Product | The product the policy belongs to. A license can only use policies of its own product. |
| Name | Shown on the license and returned in the validation response as license.policy. |
| Type | Subscription licenses expire after the duration. Perpetual licenses never expire. |
| Max Machines | How many seats the license gets in each whitelist group of the product. 0 uses each group's own limit. |
| Duration | How long a subscription lasts, in days, weeks, months or years. 0 is lifetime. |
| Addons | Addons of the same product that a new license starts with. |
Issuing a license under a policy copies these values onto the license. Anything you fill in yourself wins, so you can still give one customer an extra machine or a later expiry. Validation only reads the license's own values, so editing a policy later changes new licenses but leaves issued ones alone. Deleting a policy unlinks its licenses and keeps their values.
Whitelists
A whitelist group limits where a license works. Attach groups to a product, then add entries to each license.
| Type | Checks |
|---|---|
| IP | The address the request came from. |
| Header | A request header, such as X-Server-Id. |
| Body | A field in the request body, such as hwid. |
The header or field name defaults to the group name. When a product has groups, every request must match an entry on the license for each group, or it gets not_whitelisted.
Entries are added by staff on the license, by customers on the Client Dashboard, with /addwhitelist in Discord, or by your product through activation. Max Entries per License caps how many each license can hold. A license with Max Machines set uses that number instead for every group.
Activation
Each whitelist entry is a seat. Your product can manage its own seat instead of waiting for staff:
| Endpoint | What it does |
|---|---|
POST /api/v1/licenses/activate | Adds the request's values to every group of the product that still has a free seat, then validates. Fails with seats_full when a group is full. |
POST /api/v1/licenses/deactivate | Removes the request's values from the license. Answers deactivated, not_activated or not_found. |
Both take the same body as validation and are signed. Turn on Activate new devices on validation in the configuration to make every validation activate, so the first check from a new device takes a seat.
{
"success": true,
"code": "deactivated",
"message": "This device was deactivated.",
"removed": 1,
"timestamp": 1791083116035
}Offline Licenses
Every license with a positive status can be exported as a signed file, so your product keeps working without a network or while Kora is down. Use Offline File on the license, GET /api/v1/licenses/:id/file with an API token, or let customers download their own on the Client Dashboard.
The file is base64url(payload) + "." + base64url(signature). The signature is Ed25519 over the exact payload bytes, checked with the same public key as signed responses.
| Payload field | Meaning |
|---|---|
key, license_id, status | The license. |
product, customer, addons, whitelists | What it is for and where it may run. |
expires_at | The license expiry, or null. |
issued_at | When the file was made. |
refresh_after | Stop trusting the file after this time. The configured number of days, or expires_at when sooner. |
Validate online first. Fall back to the file only when Kora cannot be reached, and reject it once refresh_after or expires_at has passed. A revoked license keeps working offline until then, which is the trade-off for working without a network.
Access Rules
Access Rules block or exclude requests before a license is judged.
| Type | Matches |
|---|---|
| IP or range | The request IP, as a single address or CIDR range such as 203.0.113.0/24. IPv6 works too. |
| Device | The hwid field of the request. |
| Country | A two-letter code. Kora reads the country header (cf-ipcountry by default) when a proxy such as Cloudflare sets it, and otherwise looks the IP up in its bundled geo database. |
The geo database ships with Kora in modules/core/resources/geo, built from DB-IP IP to Country Lite (CC BY 4.0). With Update the geo database monthly on, Kora downloads the newest release on the 3rd of each month into data/core/geo and uses it from then on. Lookups happen on your server; no IP leaves it.
A Block rule fails the request with blacklisted and counts the hit. An Exclude rule lets the request skip whitelist seats and leak detection, so you can test your own builds without using a seat. Exclude My IP adds a rule for the address you are browsing from. Pick a product to limit a rule to it, or leave it empty for every product. Block wins when both match.
Customers
Block a customer to fail every license they own with blocked, without touching the license statuses. Unblock brings them all back as they were, and licenses issued while blocked are blocked too.
Merge folds a duplicate into the customer you have open: its licenses move over, a missing email or Discord ID is filled in, notes are joined, the block stays if either was blocked, and the duplicate is deleted. BuiltByBit buyers and purchases move with it.
Expiry
A product's duration sets the expiry for new licenses. 0 means lifetime. Kora marks licenses as expired every 10 minutes, after the grace period in the configuration.
Validation Logs
Every request is logged with its key, product, result, IP, country and device. Countries come from the same lookup as country rules. Find them under Validation Logs, or on each license. Logs older than the retention setting are removed every hour.
Leak Detection
When a key is checked from more IPs or devices than allowed within the window, not counting requests that matched an exclude rule, Kora flags it and returns leaked. Flagged licenses show under Overview. Set the action to Flag and change status to also give the license a negative status such as Suspended.
To clear a flag, edit the license and set Leak to Clear.