One endpoint.Every gate in the open.
The public API answers one question: may this key run on this device, for this project, right now? It never returns a Convex ID, never exposes another tenant, and never accepts a browser origin.
01
Overview
The validation API is a single POST route on the ARN AUTH web origin. The web app forwards it to the Convex HTTP deployment, so your integration never depends on an internal deployment URL. It is intentionally inaccessible from browsers: no CORS headers, no cookies, no query-string secrets.
Base route
/api/v1
Auth header
x-api-key
Credentials
Per-application public key
Body limit
16 KB
02
Validate a key
Send the project UUID, the license key exactly as issued, and a stable hardware identifier. Every request is checked against the same server-side rules, in the same order.
POST /api/v1
Content-Type: application/json
x-api-key: pk_live_your_public_key
{
"projectId": "7c2a4e18-94b1-4f0c-b83a-2d1e5f6a9c10",
"licenseKey": "ARN-8FK2Q-M9X4T-7QW3B-2N8HP-6RZ5D",
"hardwareId": "machine-fingerprint-01"
}| Field | Type | Rules |
|---|---|---|
| projectId | string | Lowercase UUID from the app API tab |
| licenseKey | string | Case-sensitive, 10–128 chars |
| hardwareId | string | 8–256 printable ASCII chars, stable per device |
200 · valid
{
"valid": true,
"code": "VALID",
"message": "License key is valid. This device was registered for Example App on the Pro plan.",
"lifetime": false,
"app": { "name": "Example App" },
"plan": { "id": "pro", "name": "Pro" },
"license": {
"expiresAt": 1799976600000,
"deviceLimit": 3,
"devicesUsed": 1,
"hardwareRegistered": true
}
}403 · expired
{
"valid": false,
"code": "KEY_EXPIRED",
"message": "This license key has expired."
}Lifetime access responses
No binding{
"valid": true,
"code": "VALID",
"message": "This is an owner exception key. Access is granted without project or license checks.",
"lifetime": true,
"plan": { "id": "owner_exception", "name": "Owner exception" }
}Owner exception keys bypass project credentials and normal license checks. Platform always-allow hardware IDs bypass the license lookup after credentials resolve. Both return lifetime: true and create no license binding, app-wide registry row, or activity log.
New license keys do not consume their duration while unused. Their clock starts on the first successful device registration, and the valid response returns the resulting expiresAt. Before activation, the dashboard shows not started and non-valid responses may report expiresAt: 0.
03
Quickstart
Use your server runtime's fetch. Set a timeout, treat non-200 as closed, and never log the raw key or hardware ID in production analytics.
const response = await fetch(
"https://auth.arnislive.com/api/v1",
{
method: "POST",
headers: {
"content-type": "application/json",
"x-api-key": process.env.ARNAUTH_API_KEY,
},
body: JSON.stringify({
projectId: process.env.ARNAUTH_PROJECT_ID,
licenseKey: userSuppliedKey.trim(),
hardwareId: stableDeviceFingerprint,
}),
signal: AbortSignal.timeout(8_000),
},
);
const result = await response.json();
if (response.ok && result.valid) {
// Open the app. The key and this device are valid.
} else if (result.code === "DEVICE_LIMIT_REACHED") {
// Show the device reset instructions.
} else if (result.code === "HARDWARE_ALREADY_REGISTERED") {
// This machine is already tied to another key in the app.
} else {
// Treat every other code as a closed door.
}curl -X POST "https://auth.arnislive.com/api/v1" \
-H "content-type: application/json" \
-H "x-api-key: $ARNAUTH_API_KEY" \
-d '{
"projectId": "'"$ARNAUTH_PROJECT_ID"'",
"licenseKey": "ARN-8FK2Q-M9X4T-7QW3B-2N8HP-6RZ5D",
"hardwareId": "machine-fingerprint-01"
}'04
Device binding
The hardware ID is hashed with a server pepper before it touches the database. Only the dashboard's shortened preview is ever shown back to a human.
Hash
Stable hardware ID → SHA-256 with server pepper
Bind
First valid call adds a binding and starts new-key expiry
Unique
One device can bind to only one key in an app
Reset
Owner, admin, or issuing seller clears the device
HARDWARE_ALREADY_REGISTERED instead of creating a silent second binding.403 KEY_PAUSED immediately. The owner can pause any key, a developer can pause any key in their app, and a seller can pause only keys they issued. Pausing does not extend the expiry clock. Use disable for a permanent stop and pause for a temporary hold.05
Errors
Status codes are stable. Messages are for humans and may change. Branch on code, never on prose.
| HTTP | Code | Meaning |
|---|---|---|
| 200 | VALID | The key is valid. The device is now bound, was already bound, is an exception key, or is an always-allow device. |
| 400 | INVALID_REQUEST | The JSON body is valid but a required field is missing or malformed. |
| 400 | INVALID_JSON | The request body is not valid JSON. |
| 401 | INVALID_CREDENTIALS | The public API key and project ID do not resolve to one active app, or the app owner is banned/deleted. |
| 403 | KEY_DISABLED | The owner or issuing seller disabled the key. |
| 403 | KEY_PAUSED | The owner, a developer, or the issuing seller paused the key. |
| 403 | KEY_EXPIRED | The key's expiry timestamp has passed. |
| 403 | HARDWARE_BANNED | This hardware ID is banned for the app or across the platform. |
| 404 | INVALID_LICENSE | The key does not exist inside this project. |
| 409 | DEVICE_LIMIT_REACHED | A new device tried to bind after the key's device ceiling was reached. |
| 409 | HARDWARE_ALREADY_REGISTERED | This device is already registered to another key in the same app. |
| 413 | PAYLOAD_TOO_LARGE | The JSON body is over 16 KB. |
| 429 | RATE_LIMITED | An IP or key+IP bucket is exhausted. Respect Retry-After. |
| 500 | INTERNAL_ERROR | Validation is unavailable. Fail closed and retry with backoff. |
| 503 | MAINTENANCE | The platform is in maintenance mode. Respect Retry-After. |
| 503 | NOT_CONFIGURED | The deployment is missing its peppers, proxy secret, or validation target. Fail closed until configured. |
| 503 | UNAVAILABLE | Temporary contention or lookup failure. Retry after the header. |
Rate limits: 300 requests per minute per IP and 120 per minute per public key + IP pair. A 429 includes retry-after in seconds.
06
Sellers & key profiles
Sellers are app-scoped by construction. A profile fixes a key duration; each seller gets a separate key limit for every profile assigned to them.
Invite by email
A verified seller email accepts or declines inside the dashboard.
Custom profiles
Developers create any duration and set a default key limit for each profile.
Count every key
Each successful creation consumes one unit. A rejected request does not.
isOwner and bypasses seller profile quotas while retaining the seller console. Editing a profile or limit never changes keys that already exist.07
Security model
The API is deliberately boring. Its value is what it refuses to do.
The platform is not in maintenance mode
The caller IP is inside the platform rate limit
Owner exception keys are checked before project credentials
The public API key hash belongs to an app
The UUID project ID matches that app and the owner is active
The hardware ID is not globally banned
Platform always-allow devices receive lifetime access
The hardware ID is not banned for this app
The license key hash belongs to that app
The key is not paused, disabled, or expired
The device is not registered to another key in this app
The hardware ID is already bound or the key has an open slot
Never returned
- Convex document IDs
- Owner or seller user records
- Public key hashes
- Hardware binding hashes
Never accepted
- Browser-origin CORS preflights
- Public keys in query strings
- Device resets from the public API
- Archived applications
Ready
Your first project key is one dialog away.
Create an app, copy the public key once, then call the route.