Open dashboard

Developer documentation

A backend you can
see the whole of.

Draw your backend, look at exactly what would be built, then deploy it in one transaction. Underneath are real tables, real indexes and real SQL. Your app gets its own users, every table carries a rule about who may read a row — enforced by the database instead of by a WHERE clause your code has to remember — and you get the screen you manage them from rather than having to write it. Files, licensing and payments sit on the same canvas, so they are one system rather than four you wire together.

Introduction

The Authsy API is a small set of JSON-over-HTTPS endpoints. There are five surfaces, and they share one project, one key and one set of rules:

  • Database — tables and collections you design on a canvas and reach over REST. Real tables in a schema of their own: indexable, joinable, and readable back out row by row over the same API that put them in.
  • Your app’s users — sign-up, sign-in and sessions for the people who use your app, per project, separate from your Authsy account. Each table’s access rule decides what they can reach.
  • Storage — buckets for files, with the same tenancy rules as the rows.
  • Licensing System — activate a device against a license key, then validate it on every launch. Signed responses, seat limits, trials, entitlements and offline grace tokens.
  • Key System — free-to-play style checkpoint unlocks: the player passes ad-link checkpoints to earn a time-limited, device-bound key you validate the same way.
It is a real SQL database, not a layer that resembles one. Your tables are ordinary tables in a schema of their own — with a real query planner, real index types, JSON operators you can query into, and transactions that roll all the way back. Nothing you store is in a shape only Authsy can read.

Base URL for every call:

https://YOUR-HOST/api/v1

The database surface is ordinary REST — GET, POST, PATCH, DELETE over /db/…. The licensing endpoints are all POST with a JSON body. Every call is authenticated by a key header, a user token, or both. Responses are JSON; the licensing endpoints add a signature you must verify (see Verify the signature).

Everything on this page can be called with fetch and nothing else. If you would rather not — sessions, token refresh and query building are the tedious parts — @authsy/client wraps the whole database surface in one package, and the samples below show both.

import { createClient } from "@authsy/client";

const authsy = createClient("https://YOUR-HOST", "lsk_xxxxxxxx");

await authsy.auth.signUp({ email, password });
await authsy.from("notes").insert({ body: "mine" });

const { data } = await authsy.from("notes").select();   // only theirs

Nothing there says “belonging to me”, and it does not have to: the rule on the table decides, and the database applies it. Node 18 or newer.

Keys & who is calling

Every request carries a key in a header:

X-Api-Key: lsk_xxxxxxxxxxxxxxxxxxxx

Three kinds of caller reach the database, and which one it is decides what the database will show them — not what the endpoint decides to filter:

CallerHowSees
A signed-in end userA publishable key plus that user’s access token in Authorization: BearerWhatever the rule on each table opens to them — for owner, their own rows
Nobody in particularA publishable key aloneOnly what a rule opens to the public
YouA manage-scope key, or the dashboard sessionEverything in your workspace — the row rules do not apply

A publishable key is created inside a project, under Auth → Keys. A manage key is created for the whole workspace, under Settings → API keys. Both start lsk_ — the scope recorded against the key is what separates them, so there is no prefix to eyeball. Ship only a key you issued inside a project.

Publishable keys are public-ish; manage keys are not. A publishable key ships inside your app, so treat it as an identifier rather than a secret: it can sign users up and in, and reach only what your rules open. A manage key is you, and bypasses every row rule — keep it on your own server. Rotate keys anytime; revoking one takes effect immediately.

When a user token and a key arrive together — which is what the client library sends — the token wins. The key names the project; the token names the person. Reading the key first would serve a signed-in user as though nobody were signed in, which looks exactly like their data having vanished.

Do not embed the signing secret in a shipped app. It is symmetric: anything that can verify a response with it can also forge one. Lifted out of a binary, it lets an attacker stand up a fake endpoint that returns a perfectly signed valid = true. Shipped apps verify with the Ed25519 public key instead, which is safe to embed because it cannot sign — see Verify the signature.

Projects & the canvas

Everything you build lives in a project. A project owns its own tables, its own collections and its own buckets, in a database schema of its own — so two projects can each have an orders table without one of them needing a prefix.

You design a project on its canvas at /app/<project>, and nothing is created while you do. There are three steps, and the middle one is the point:

  • Draw — add tables, collections, buckets and services, and connect them. Saving stores a draft; it provisions nothing.
  • Plan — Authsy diffs the canvas against what actually exists and tells you exactly what it would do. Plan never writes, so it is safe to run as often as you like.
  • Deploy — the plan is applied in one transaction. Schema changes are transactional here, so a failure at step 7 of 9 leaves nothing half-built.
Dropping a table is opt-in. It is the one operation re-running a deploy cannot undo, so the plan flags it as destructive and the deploy refuses until you confirm. Removing a node from the canvas is not the same as dropping what it built.

Tables & collections

Two shapes, one database underneath. A table has columns you declare; a collection is a document store — a key and a data JSON document, indexed for search — for the parts whose shape is still moving. By default both are ordinary tables in the same schema, which means both are indexable, both join, and neither is a separate engine to run or pay for. Both carry the same access rule and appear in the same access review.

Every table you create gets four columns for free:

ColumnTypeWhat it does
idtextPrimary key, a UUID with the dashes removed. Generated.
tenant_idtextFilled in from the connection. You never send it, and you cannot set it.
created_attimestamptzSet on insert.
updated_attimestamptzMaintained by a trigger, not by your code.
Isolation is row level security, not a filter in the query builder. Every table is created with ENABLE and FORCE ROW LEVEL SECURITY and a policy tying rows to the tenant on the transaction. If a bug ever dropped a WHERE, the database would still refuse to return someone else's rows — the check is one layer below the code that could get it wrong.

Your app’s users

These are the people who use your app — not your Authsy account, and not shared with any other project. They sign up against a project with its publishable key, and the token they get back is what makes a query return their rows.

POST/api/v1/auth/signup

Sign in and refresh share one endpoint, chosen by grant_typepassword or refresh_token:

POST/api/v1/auth/token
GET/api/v1/auth/user
PATCH/api/v1/auth/user
POST/api/v1/auth/logout
import { createClient } from "@authsy/client";

const authsy = createClient("https://YOUR-HOST", "lsk_xxxxxxxx");

await authsy.auth.signUp({ email, password });
await authsy.auth.signInWithPassword({ email, password });
await authsy.auth.getUser();
await authsy.auth.signOut();                 // { scope: "global" } ends every session

// Now every query is that user's.
const { data } = await authsy.from("notes").select();
# Sign in. The publishable key names the project.
curl -X POST "https://YOUR-HOST/api/v1/auth/token" \
  -H "x-api-key: lsk_xxxxxxxx" -H "content-type: application/json" \
  -d '{"grant_type":"password","email":"ada@example.com","password":"…"}'

# -> { "user": {…}, "session": { "access_token": "…", "refresh_token": "…" } }

# Then send both: the key names the project, the token names the person.
curl "https://YOUR-HOST/api/v1/db/notes" \
  -H "x-api-key: lsk_xxxxxxxx" \
  -H "authorization: Bearer $ACCESS_TOKEN"

Access tokens last an hour. Refresh tokens are rotated on every use, so a stolen one is good for exactly one use — and its theft shows up as a failed refresh rather than as nothing at all. The client library refreshes before a request rather than after one fails.

A user belongs to one project. The project is pinned inside the token, not read from the query string, so a user of one project cannot reach another project on the same account by asking for it.

Managing them is the users table. What is not built yet — email verification, password reset, social sign-in for app users, and per-user rules on storage objects and realtime — is listed in docs/APP_AUTH.md §6 rather than left for you to discover.

Disabling a user takes effect immediately. It revokes their sessions, and because an access token already issued keeps verifying for up to an hour, the database surface checks the account on every request that carries one. A disabled caller gets 401 user_disabled — told apart from “not signed in” on purpose, so your client can sign them out rather than showing them a world with their own rows missing.

The users table

/app/<project>/users

Every backend-as-a-service shows you a list of your users: email, signed up, a delete button. Nobody runs a business on that, so everybody ends up building an internal admin tool — a week of work, per product, never quite finished and never quite safe. This is that tool, as part of the backend rather than beside it.

It is editable top to bottom: ban, unban, verify, correct an address, set a value, inline in the grid. Three things beyond that are worth knowing.

Columns you add

Your product needs a user to carry things Authsy has never heard of — plan, credits, trial_ends, referred_by. Declare them per project and they are typed, validated and sorted like any other column: text, number, yes/no, date, one-of-a-list, or JSON.

One decision on that form matters. A user row carries two bags: metadata, which the user may change about themselves, and app_metadata, which is server-side only. A column called credits or plan in the first is one its owner can set to anything they like, from your app, with their own token. New columns therefore land in the private bag, and “let users edit this themselves” is a switch you have to reach for.

Removing a column removes the declaration; the values stay on each user, so re-adding it brings them back. An admin tool that loses a thousand users’ plan because somebody tidied the grid is not one to trust with a business.

Licensing, already joined

A licence is issued to an email address and a user has one, so the link needs no configuration and no foreign key. If your account sells software through Authsy Licensing, every user row carries their licence — key, product, status, expiry, machines in use — and you can suspend it, move the expiry, raise the machine allowance, or free every machine it is bound to, from the row of the person who wrote in about it.

That last one is the commonest support request for licensed software, and it otherwise means going and finding the licence by key.

What they own, beside what they can see

Open a user and every table reports two numbers:

notes           owner/owner      owns 1 · sees 1 · of 15
announcements   public/none      owns — · sees 0 · of 0

Owns comes from a WHERE clause: rows filed under them. Sees comes from putting on their claims and asking PostgreSQL what it will actually return. When the two disagree, something is wrong that no amount of reading the policy would have told you — a rule left closed after a migration, an owner_id written by a server using the wrong id, a table opened to everybody.

See lists the rows themselves, exactly as their app would receive them.

Looking cannot write. Every query made while wearing a user’s identity runs in a transaction PostgreSQL marks read-only, so acting as a customer by accident is refused by the database rather than merely not intended. The claims are verified before anything is counted, too — a number measured under the wrong identity is worse than no number, because it looks like an answer.

Roles

Free-form names — admin, moderator, beta — granted and revoked from the panel and held in app_metadata.roles. Authsy does not interpret them; your code reads them from getUser() and decides what to show.

A role is not an access rule. Granting somebody admin does not widen what the database will hand them — the rule on each table decides that, and it is the only thing that does. The separation is deliberate: the role claim in an access token is the caller kind, and “is this the platform?” is defined as “role is not one of the two end-user values”. An app role written into that claim would not make a user an admin; it would make them the platform, past every policy on every table.

Full reference, including the API behind every control on the page, in docs/USERS_TABLE.md.

Row access rules

Every table and collection carries one rule for reading and one for writing. You pick them in the inspector, under Row access, and deploy turns them into policies in the database — so they hold for every route, every query, and the endpoint somebody adds next year and forgets to filter.

RuleReadWrite
none (default)Nobody but your own serverSame
ownerEach user, their own rowsEach user, their own rows
authenticatedAny signed-in userAny signed-in user
publicAnybody with the publishable key— never

A table with an owner rule gets an owner_id column, filled in from the signed-in user on insert. You never send it. A catalogue of products is usually public read and none write; a ticket queue is authenticated; anything personal is owner.

The default is closed. Pick nothing and the answer is none: only your own server. A project that switches auth on should not quietly begin serving every row to everybody who signs up.
Four policies, not one. SELECT is governed by the read rule, and INSERT, UPDATE and DELETE by the write rule. A single policy covering every command has one USING clause, and PostgreSQL never evaluates WITH CHECK on a DELETE — which is how a rule reading public/none would let every holder of your shipped key empty the table.

Checking that they do what you think

Under Auth → Access review, Authsy puts on each caller’s identity in turn — nobody signed in, a signed-in stranger, the user who owns the row — asks the database what each one can read, write and delete, and rolls all of it back. What comes out is measured, not inferred:

table    rule           rows    anon reads    a user reads    its owner
notes    owner/owner       1               0               0            1

It names anything worth acting on: a table anyone with your shipped key can read, a rule stricter than it looks, row security switched off. Full walkthrough in docs/ACCESS_REVIEW.md.

Reading and writing rows

One endpoint per table, addressed by the name you gave it on the canvas. Filters read as ?column=op.value — about a minute to learn, and the same shape for every table.

GET/api/v1/db/:table

Filters are ?column=op.value. Operators: eq, neq, gt, gte, lt, lte, like, ilike, in and is. Shape the result with select, order, limit (max 1000) and offset.

const BASE = "https://YOUR-HOST/api/v1";
const API_KEY = "lsk_xxxxxxxx";
const h = { "content-type": "application/json", "x-api-key": API_KEY };

// Paid orders over $10, newest first.
const res = await fetch(
  BASE + "/db/orders?status=eq.paid&total=gte.10&order=created_at.desc&limit=20",
  { headers: h },
);
const { data, count } = await res.json();

// Insert. One row or an array of them, up to 1000.
await fetch(BASE + "/db/orders", {
  method: "POST", headers: h,
  body: JSON.stringify({ status: "pending", total: 42 }),
});

// Update. The filter is required — see the note below.
await fetch(BASE + "/db/orders?id=eq." + id, {
  method: "PATCH", headers: h,
  body: JSON.stringify({ status: "shipped" }),
});

// Delete, same rule.
await fetch(BASE + "/db/orders?id=eq." + id, { method: "DELETE", headers: h });
# Read
curl -H "x-api-key: $AUTHSY_KEY" \
  "https://YOUR-HOST/api/v1/db/orders?status=eq.paid&order=created_at.desc"

# Insert
curl -X POST -H "x-api-key: $AUTHSY_KEY" -H "content-type: application/json" \
  -d '{"status":"pending","total":42}' \
  "https://YOUR-HOST/api/v1/db/orders"

# Update — note the filter
curl -X PATCH -H "x-api-key: $AUTHSY_KEY" -H "content-type: application/json" \
  -d '{"status":"shipped"}' \
  "https://YOUR-HOST/api/v1/db/orders?id=eq.$ORDER_ID"

# Delete
curl -X DELETE -H "x-api-key: $AUTHSY_KEY" \
  "https://YOUR-HOST/api/v1/db/orders?id=eq.$ORDER_ID"
import os, requests

BASE = "https://YOUR-HOST/api/v1"
H = {"x-api-key": os.environ["AUTHSY_KEY"]}

r = requests.get(f"{BASE}/db/orders",
                 params={"status": "eq.paid", "order": "created_at.desc"}, headers=H)
rows = r.json()["data"]

requests.post(f"{BASE}/db/orders", json={"status": "pending", "total": 42}, headers=H)
requests.patch(f"{BASE}/db/orders", params={"id": f"eq.{order_id}"},
               json={"status": "shipped"}, headers=H)
requests.delete(f"{BASE}/db/orders", params={"id": f"eq.{order_id}"}, headers=H)
An unfiltered update or delete is refused. PATCH /db/orders with no filter would rewrite every row, and DELETE would empty the table. Neither is ever what was meant on the first try, so both return 400 with unfiltered_update / unfiltered_delete rather than doing it.
Unknown columns are an error, not a no-op. A filter on a column that does not exist returns 400. A typo that silently matched everything is how you delete more than you meant to.

If two projects both own a table with the same name, an API key — which is scoped to your workspace, not to one project — cannot tell which you meant. Rather than picking one, Authsy says so and you disambiguate with ?project=<id or slug>.

Collections & documents

Documents, for state whose shape you do not want to migrate yet — a player’s save, a draft, a per-user settings blob, an order whose fields keep growing. Each has a key you choose and a data document with no declared shape, and the whole document is queryable.

POST/api/v1/db/collections/:name
GET/api/v1/db/collections/:name/:key
GET/api/v1/db/collections/:name
DELETE/api/v1/db/collections/:name/:key
POST/api/v1/db/collections/:name/find
POST/api/v1/db/collections/:name/count
POST/api/v1/db/collections/:name/aggregate
// Write a document. Same key writes over the old one.
await fetch(BASE + "/db/collections/saves", {
  method: "POST", headers: h,
  body: JSON.stringify({ key: "player:42", data: { level: 7, hp: 92 } }),
});

// Read one back. `data` is always the matching rows, so the document
// itself is data[0].data — the row's other columns are id and timestamps.
const res = await fetch(BASE + "/db/collections/saves/player:42", { headers: h });
if (res.status === 404) return null;            // no such key
const save = (await res.json()).data[0].data;   // { level: 7, hp: 92 }

// List the collection, newest first.
const page = await (await fetch(BASE + "/db/collections/saves?limit=50",
                                { headers: h })).json();
const saves = authsy.collection("saves");

await saves.put("player:42", { level: 7, hp: 92 });
await saves.get("player:42");        // { level: 7, hp: 92 }
await saves.list({ limit: 50 });
await saves.remove("player:42");

Querying them

The document is stored as indexed JSON, so it is queryable — a collection is not a bucket of opaque text you have to read out and parse to search. The query language is the one you already know:

POST /api/v1/db/collections/orders/find

{
  "filter": {
    "status": "paid",
    "total":  { "$gt": 100 },
    "customer.email": { "$exists": true }
  },
  "sort":  { "total": -1 },
  "limit": 20
}
POST /api/v1/db/collections/orders/aggregate

[
  { "$match": { "status": { "$ne": "cancelled" } } },
  { "$group": { "_id": "$status",
                "revenue": { "$sum": "$total" },
                "orders":  { "$sum": 1 } } },
  { "$sort":  { "revenue": -1 } },
  { "$limit": 10 }
]
const orders = authsy.collection("orders");

await orders.find({
  filter: { status: "paid", total: { $gt: 100 } },
  sort: { total: -1 },
  limit: 20,
});
await orders.count({ filter: { status: "paid" } });
await orders.aggregate([
  { $group: { _id: "$status", revenue: { $sum: "$total" } } },
  { $sort: { revenue: -1 } },
]);

Operators: $eq $ne $gt $gte $lt $lte $in $nin $exists $regex $size $all, and $and / $or / $nor / $not. Dotted paths walk into subdocuments up to eight levels. Aggregation stages: $match, $group, $sort, $skip, $limit, $count, with $sum, $avg, $min, $max.

$lookup, $unwind and $facet are absent, not half-implemented. Every stage above maps onto SQL the planner handles in one pass; those need a second, and a join that silently degrades is worse than one that is not offered. If you need a join, the collection’s neighbours are SQL tables in the same schema — join them there.

{ tags: "web" } matches a document whose tags array contains "web", the way it does in Mongo, and stays index-backed. Bounds are hard and stated: 8 levels of nesting, 64 conditions per filter, 1000 values in an $in, 200 characters of regex. Exceed one and you get 400 bad_query naming the field, not a query that runs for a minute.

Fields to query by

A collection node has one extra thing in the inspector: Query by. Type a field there — tag, or customer.city — and it is recorded as an index and kept in step with every write.

PostgreSQL indexes the whole document and needs none of them. Authsy’s own storage engine does: reached directly it refuses a filter it has nothing indexed to start from, rather than quietly reading the whole collection. That is the difference between a query that works on your laptop and one that still works at a million documents. Full reference in docs/DOCUMENTS.md; the engine itself in docs/ENGINE.md.

Storage

Buckets hold files. The metadata is a row under the same row level security as everything else; the bytes are on disk, because putting blobs in the database is a decision people regret at the second terabyte.

POST/api/v1/storage/:bucket/*
GET/api/v1/storage/:bucket/*
// The path after the bucket is the object key, slashes and all.
await fetch(BASE + "/storage/media/covers/1234.png", {
  method: "POST",
  headers: { "x-api-key": API_KEY, "content-type": "image/png" },
  body: fileBytes,
});

const img = await fetch(BASE + "/storage/media/covers/1234.png",
                        { headers: { "x-api-key": API_KEY } });
curl -X POST -H "x-api-key: $AUTHSY_KEY" -H "content-type: image/png" \
  --data-binary @cover.png \
  "https://YOUR-HOST/api/v1/storage/media/covers/1234.png"

curl -H "x-api-key: $AUTHSY_KEY" \
  "https://YOUR-HOST/api/v1/storage/media/covers/1234.png" -o cover.png

Objects are capped at 25 MB by default. Keys are normalised and checked for containment, so a key of ../../etc/passwd is a 400, not a file. You can browse, upload and delete from the dashboard at /app/<project>/storage.

Buckets belong to a project, like tables do, so two projects can each have a media. You address one by the name you gave it on the canvas; if two projects use the same name, add ?project=<id or slug> — the same rule as tables, and for the same reason.

How the licensing flow works

The whole integration is three steps:

  • 1 · Fingerprint the device — build a stable machine_id (e.g. a hash of hardware IDs). The server stores only its SHA-256.
  • 2 · Activate once — call /client/activate when the user first enters their key.
  • 3 · Validate on launch — call /client/validate each start, verify the signature, and gate your app on the result.
Nonce every call. Generate a random nonce per request and confirm the signed response echoes it back. That plus the timestamp defeats replay of an old “valid” response.

Licensing SDKs

Drop-in libraries that wrap activate / validate / signature-verification / offline checks so you don't hand-roll the crypto. Each is a single self-contained file — vendor it, or wrap it in your package manager of choice.

All five share one interface — construct with baseUrl, apiKey and your signingSecret, then call activate() and validate(). Verification, nonces and freshness checks are handled for you. Prefer copy-paste? The sections below show the raw calls.

Activate a device

POST/api/v1/client/activate

Binds a license key to this device and consumes a seat. Call it once, when the user enters their key. Body: license_key, machine_id, optional machine_info, optional nonce.

const BASE = "https://YOUR-HOST/api/v1";
const API_KEY = "lsk_xxxxxxxx";

async function activate(licenseKey, machineId) {
  const nonce = crypto.randomUUID();
  const res = await fetch(BASE + "/client/activate", {
    method: "POST",
    headers: { "content-type": "application/json", "x-api-key": API_KEY },
    body: JSON.stringify({ license_key: licenseKey, machine_id: machineId, nonce }),
  });
  const data = await res.json();
  if (!data.valid) throw new Error("Activation failed: " + data.code);
  return data; // now call validate() on every launch
}
using System.Net.Http;
using System.Net.Http.Json;

const string Base = "https://YOUR-HOST/api/v1";
const string ApiKey = "lsk_xxxxxxxx";

async Task<JsonElement> Activate(string licenseKey, string machineId) {
    using var http = new HttpClient();
    http.DefaultRequestHeaders.Add("X-Api-Key", ApiKey);
    var body = new { license_key = licenseKey, machine_id = machineId,
                     nonce = Guid.NewGuid().ToString() };
    var res = await http.PostAsJsonAsync(Base + "/client/activate", body);
    var data = await res.Content.ReadFromJsonAsync<JsonElement>();
    if (!data.GetProperty("valid").GetBoolean())
        throw new Exception("Activation failed: " + data.GetProperty("code"));
    return data;
}
// libcurl + nlohmann/json
#include <curl/curl.h>
#include <nlohmann/json.hpp>
using json = nlohmann::json;

static size_t sink(void* p, size_t s, size_t n, void* out) {
    ((std::string*)out)->append((char*)p, s * n); return s * n;
}

json activate(const std::string& key, const std::string& machineId) {
    json body = {{"license_key", key}, {"machine_id", machineId},
                 {"nonce", make_uuid()}};
    std::string payload = body.dump(), resp;
    CURL* c = curl_easy_init();
    curl_slist* h = nullptr;
    h = curl_slist_append(h, "Content-Type: application/json");
    h = curl_slist_append(h, "X-Api-Key: lsk_xxxxxxxx");
    curl_easy_setopt(c, CURLOPT_URL, "https://YOUR-HOST/api/v1/client/activate");
    curl_easy_setopt(c, CURLOPT_HTTPHEADER, h);
    curl_easy_setopt(c, CURLOPT_POSTFIELDS, payload.c_str());
    curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, sink);
    curl_easy_setopt(c, CURLOPT_WRITEDATA, &resp);
    curl_easy_perform(c);
    curl_easy_cleanup(c);
    return json::parse(resp);
}
<?php
const AUTHSY_BASE = "https://YOUR-HOST/api/v1";
const AUTHSY_KEY  = "lsk_xxxxxxxx";

function authsy_activate(string $licenseKey, string $machineId): array {
    $body = json_encode([
        "license_key" => $licenseKey,
        "machine_id"  => $machineId,
        "nonce"       => bin2hex(random_bytes(16)),
    ]);
    $ch = curl_init(AUTHSY_BASE . "/client/activate");
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => $body,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => ["Content-Type: application/json", "X-Api-Key: " . AUTHSY_KEY],
    ]);
    $data = json_decode(curl_exec($ch), true);
    curl_close($ch);
    if (empty($data["valid"])) throw new Exception("Activation failed: " . $data["code"]);
    return $data;
}
// Android — OkHttp
import okhttp3.*
import org.json.JSONObject
import java.util.UUID

val client = OkHttpClient()
const val BASE = "https://YOUR-HOST/api/v1"
const val API_KEY = "lsk_xxxxxxxx"

fun activate(licenseKey: String, machineId: String): JSONObject {
    val body = JSONObject()
        .put("license_key", licenseKey)
        .put("machine_id", machineId)
        .put("nonce", UUID.randomUUID().toString())
    val req = Request.Builder()
        .url("$BASE/client/activate")
        .addHeader("X-Api-Key", API_KEY)
        .post(RequestBody.create("application/json".toMediaType(), body.toString()))
        .build()
    client.newCall(req).execute().use { res ->
        val data = JSONObject(res.body!!.string())
        if (!data.optBoolean("valid")) throw Exception("Activation failed: ${data.opt("code")}")
        return data
    }
}
// iOS: the same flow with URLSession + CryptoKit (HMAC-SHA256) for verify().

Validate a license

POST/api/v1/client/validate

Call this on every launch (and periodically for long-running apps). It confirms the key is active, the device is activated, seats aren't exceeded, and returns entitlements plus a fresh offline token. A successful validate also counts the device as a monthly active user.

The response is signed. Never trust valid without verifying signature — see the next section, which every snippet below calls into.

async function validate(licenseKey, machineId) {
  const nonce = crypto.randomUUID();
  const res = await fetch(BASE + "/client/validate", {
    method: "POST",
    headers: { "content-type": "application/json", "x-api-key": API_KEY },
    body: JSON.stringify({ license_key: licenseKey, machine_id: machineId, nonce }),
  });
  const data = await res.json();

  // 1) verify the signature, 2) check the nonce, 3) check freshness
  if (!verifySignature(data, SIGNING_SECRET)) throw new Error("Tampered response");
  if (data.nonce !== nonce) throw new Error("Nonce mismatch (replay?)");
  if (Math.abs(Date.now() - data.ts) > 5 * 60_000) throw new Error("Stale response");

  return data.valid; // gate your app on this
}
async Task<bool> Validate(string licenseKey, string machineId) {
    using var http = new HttpClient();
    http.DefaultRequestHeaders.Add("X-Api-Key", ApiKey);
    var nonce = Guid.NewGuid().ToString();
    var res = await http.PostAsJsonAsync(Base + "/client/validate",
        new { license_key = licenseKey, machine_id = machineId, nonce });
    var raw = await res.Content.ReadAsStringAsync();
    var data = JsonNode.Parse(raw)!.AsObject();

    if (!VerifySignature(data, SigningSecret)) throw new Exception("Tampered response");
    if ((string?)data["nonce"] != nonce) throw new Exception("Nonce mismatch");
    var ts = (long)data["ts"]!;
    if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - ts) > 300_000)
        throw new Exception("Stale response");

    return (bool)data["valid"]!;
}
bool validate(const std::string& key, const std::string& machineId) {
    std::string nonce = make_uuid();
    json body = {{"license_key", key}, {"machine_id", machineId}, {"nonce", nonce}};
    json data = post("/client/validate", body);   // libcurl helper (see Activate)

    if (!verify_signature(data, SIGNING_SECRET)) throw std::runtime_error("tampered");
    if (data["nonce"] != nonce)                  throw std::runtime_error("nonce mismatch");
    long now = (long)std::chrono::duration_cast<std::chrono::milliseconds>(
        std::chrono::system_clock::now().time_since_epoch()).count();
    if (std::llabs(now - data["ts"].get<long>()) > 300000)
        throw std::runtime_error("stale");

    return data["valid"].get<bool>();
}
function authsy_validate(string $licenseKey, string $machineId): bool {
    $nonce = bin2hex(random_bytes(16));
    $data = authsy_post("/client/validate", [
        "license_key" => $licenseKey, "machine_id" => $machineId, "nonce" => $nonce,
    ]);
    if (!authsy_verify_signature($data, AUTHSY_SIGNING_SECRET))
        throw new Exception("Tampered response");
    if (($data["nonce"] ?? null) !== $nonce) throw new Exception("Nonce mismatch");
    if (abs(intdiv(time() * 1000, 1) - $data["ts"]) > 300000)
        throw new Exception("Stale response");
    return (bool)$data["valid"];
}
fun validate(licenseKey: String, machineId: String): Boolean {
    val nonce = UUID.randomUUID().toString()
    val data = post("/client/validate", mapOf(
        "license_key" to licenseKey, "machine_id" to machineId, "nonce" to nonce))
    if (!verifySignature(data, SIGNING_SECRET)) throw Exception("Tampered response")
    if (data.optString("nonce") != nonce) throw Exception("Nonce mismatch")
    if (kotlin.math.abs(System.currentTimeMillis() - data.getLong("ts")) > 300_000)
        throw Exception("Stale response")
    return data.optBoolean("valid")
}

Verify the signature

Every client response is signed twice over the same bytes — the canonical JSON of the result: keys sorted lexicographically at every level, no whitespace, ts included, signature excluded. Which one you check depends on where your code runs.

Ed25519 — for anything you ship

Two response headers carry it:

X-Authsy-Signature-Ed25519: <base64>
X-Authsy-Key-Id: <first 16 hex of SHA-256 of the public key>

Your app embeds only the public key, which it cannot sign with — so lifting it out of a binary buys an attacker nothing. Fetch it once from /tenant/me (response_public_key, response_key_id) and pin it in your build. Reject any response whose X-Authsy-Key-Id does not match the key you pinned.

import { verify, createPublicKey } from "node:crypto";

// Same canonical form the server signed: sorted keys, no whitespace,
// `ts` included, `signature` excluded.
function canonical(v) {
  if (v === null || typeof v !== "object") return JSON.stringify(v);
  if (Array.isArray(v)) return "[" + v.map(canonical).join(",") + "]";
  return "{" + Object.keys(v).sort()
    .map(k => JSON.stringify(k) + ":" + canonical(v[k])).join(",") + "}";
}

function verifyEd25519(resp, headers, pinnedPem, pinnedKeyId) {
  if (headers.get("x-authsy-key-id") !== pinnedKeyId) return false; // wrong key
  const { signature, ...result } = resp;                            // drop HMAC field
  return verify(
    null,
    Buffer.from(canonical(result)),
    createPublicKey(pinnedPem),
    Buffer.from(headers.get("x-authsy-signature-ed25519") || "", "base64"),
  );
}

HMAC-SHA256 — server-to-server only

The body also carries signature, an HMAC-SHA256 of the same canonical JSON keyed with your account's signing secret (portal → Settings). It is symmetric: anything able to verify with it is equally able to forge with it. Use it only where the secret never leaves your infrastructure — your own backend validating on behalf of a client.

Never ship the signing secret. In a desktop, mobile or web build it is extractable, and an extracted secret lets an attacker sign their own valid = true from a redirected endpoint. That is what the Ed25519 headers above exist for.
import { createHmac, timingSafeEqual } from "node:crypto";

// Canonical JSON: recursively sort object keys, compact separators.
function canonical(v) {
  if (v === null || typeof v !== "object") return JSON.stringify(v);
  if (Array.isArray(v)) return "[" + v.map(canonical).join(",") + "]";
  return "{" + Object.keys(v).sort()
    .map(k => JSON.stringify(k) + ":" + canonical(v[k])).join(",") + "}";
}

function verifySignature(resp, secret) {
  const { signature, ...result } = resp;
  const expected = createHmac("sha256", secret).update(canonical(result)).digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(signature || "");
  return a.length === b.length && timingSafeEqual(a, b);
}
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;

static string Canonical(JsonNode? v) {
    if (v is JsonObject o) return "{" + string.Join(",", o
        .OrderBy(p => p.Key, StringComparer.Ordinal)
        .Select(p => JsonSerializer.Serialize(p.Key) + ":" + Canonical(p.Value))) + "}";
    if (v is JsonArray a) return "[" + string.Join(",", a.Select(Canonical)) + "]";
    return v?.ToJsonString() ?? "null";
}

static bool VerifySignature(JsonObject resp, string secret) {
    var sig = (string?)resp["signature"] ?? "";
    var clone = JsonNode.Parse(resp.ToJsonString())!.AsObject();
    clone.Remove("signature");
    using var h = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    var mac = h.ComputeHash(Encoding.UTF8.GetBytes(Canonical(clone)));
    var expected = Convert.ToHexString(mac).ToLowerInvariant();
    return CryptographicOperations.FixedTimeEquals(
        Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(sig));
}
#include <openssl/hmac.h>
#include <nlohmann/json.hpp>

// nlohmann::json with an ordered dump already sorts object keys when you
// build it via json::parse into a std::map-backed type; here we sort manually.
std::string canonical(const nlohmann::json& v) {
    if (v.is_object()) {
        std::map<std::string, nlohmann::json> sorted(v.begin(), v.end());
        std::string out = "{"; bool first = true;
        for (auto& [k, val] : sorted) {
            if (!first) out += ",";  first = false;
            out += nlohmann::json(k).dump() + ":" + canonical(val);
        }
        return out + "}";
    }
    if (v.is_array()) {
        std::string out = "["; bool first = true;
        for (auto& e : v) { if (!first) out += ","; first = false; out += canonical(e); }
        return out + "]";
    }
    return v.dump();
}

bool verify_signature(nlohmann::json data, const std::string& secret) {
    std::string sig = data.value("signature", "");
    data.erase("signature");
    std::string msg = canonical(data);
    unsigned char mac[32]; unsigned int len = 0;
    HMAC(EVP_sha256(), secret.data(), secret.size(),
         (const unsigned char*)msg.data(), msg.size(), mac, &len);
    char hex[65];
    for (unsigned i = 0; i < len; i++) sprintf(hex + i * 2, "%02x", mac[i]);
    return sig.size() == 64 && CRYPTO_memcmp(hex, sig.data(), 64) == 0;
}
function authsy_canonical($v): string {
    if (is_array($v)) {
        $isList = array_keys($v) === range(0, count($v) - 1);
        if ($isList) return "[" . implode(",", array_map("authsy_canonical", $v)) . "]";
        ksort($v);
        $parts = [];
        foreach ($v as $k => $val)
            $parts[] = json_encode((string)$k) . ":" . authsy_canonical($val);
        return "{" . implode(",", $parts) . "}";
    }
    return json_encode($v);
}

function authsy_verify_signature(array $resp, string $secret): bool {
    $sig = $resp["signature"] ?? "";
    unset($resp["signature"]);
    $expected = hash_hmac("sha256", authsy_canonical($resp), $secret);
    return hash_equals($expected, $sig);
}
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import org.json.*

fun canonical(v: Any?): String = when (v) {
    is JSONObject -> v.keys().asSequence().sorted().joinToString(",", "{", "}") {
        JSONObject.quote(it) + ":" + canonical(v.get(it))
    }
    is JSONArray -> (0 until v.length()).joinToString(",", "[", "]") { canonical(v.get(it)) }
    is String -> JSONObject.quote(v)
    null, JSONObject.NULL -> "null"
    else -> v.toString()
}

fun verifySignature(resp: JSONObject, secret: String): Boolean {
    val sig = resp.optString("signature")
    val clone = JSONObject(resp.toString()); clone.remove("signature")
    val mac = Mac.getInstance("HmacSHA256")
    mac.init(SecretKeySpec(secret.toByteArray(), "HmacSHA256"))
    val hex = mac.doFinal(canonical(clone).toByteArray()).joinToString("") { "%02x".format(it) }
    return java.security.MessageDigest.isEqual(hex.toByteArray(), sig.toByteArray())
}
Reality check. Signed responses stop network-level spoofing and replay — the strong, portable guarantee. They can't stop someone patching your compiled binary to skip the check entirely; nothing client-side can. Combine this with server-authoritative entitlements (unlock real content/features only after a verified valid), pin the Ed25519 public key rather than shipping the signing secret, and keep seat/quota logic on Authsy where it can't be edited.

Seats & lifecycle

Beyond activate and validate, four endpoints cover the rest of a licence's life. All take the same X-Api-Key client key and return a signed response.

POST /client/heartbeat

Body: license_key, machine_id. Floating licences hold a seat only while it is beating. A seat that goes quiet longer than the product's heartbeat window (default 10 minutes, set per product) is released for someone else, so call this on a timer well inside that window for floating licences. Perpetual and subscription licences do not need it.

POST /client/deactivate

Body: license_key, machine_id. Frees the activation slot this device holds, so the user can move to a new machine without contacting you. Worth wiring to a "sign out on this device" action.

POST /client/trial

Body: product_id, machine_id, optional email. Issues a device-bound trial licence for the product, using the trial length configured on it. The device fingerprint is what stops a trial being restarted indefinitely.

POST /client/license-file

Body: license_key, machine_id. Returns a signed licence file for offline use — see Offline validation.

Offline validation

Each successful validate returns an offline_token: an Ed25519-signed grant (payload.signature, base64url) that lets your app keep running through network outages until it expires (the product's offline grace window, default 7 days). Verify it with your account's offline public key (from /tenant/me or Settings) — no secret needed on device.

import { verify } from "node:crypto";

// offline_token = "<payloadB64url>.<sigB64url>"; verify with the Ed25519
// public key (PEM) from your account. Cache the token; check it while offline.
function checkOffline(token, publicKeyPem) {
  const [payloadB64, sigB64] = token.split(".");
  const ok = verify(null, Buffer.from(payloadB64),
    publicKeyPem, Buffer.from(sigB64, "base64url"));
  if (!ok) return false;
  const claims = JSON.parse(Buffer.from(payloadB64, "base64url").toString());
  return claims.exp > Date.now();   // still within the grace window
}
// .NET 8+: NSec or BouncyCastle for Ed25519. Sketch with BouncyCastle:
bool CheckOffline(string token, byte[] publicKey) {
    var parts = token.Split('.');
    var payload = Encoding.ASCII.GetBytes(parts[0]);
    var sig = Base64Url.Decode(parts[1]);
    var verifier = new Ed25519Signer();
    verifier.Init(false, new Ed25519PublicKeyParameters(publicKey));
    verifier.BlockUpdate(payload, 0, payload.Length);
    if (!verifier.VerifySignature(sig)) return false;
    var claims = JsonDocument.Parse(Base64Url.Decode(parts[0]));
    return claims.RootElement.GetProperty("exp").GetInt64()
           > DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
}
// libsodium — crypto_sign_verify_detached (Ed25519)
#include <sodium.h>
bool check_offline(const std::string& token, const unsigned char pk[32]) {
    auto dot = token.find('.');
    std::string payload = token.substr(0, dot);
    std::string sig = b64url_decode(token.substr(dot + 1));
    if (crypto_sign_verify_detached((const unsigned char*)sig.data(),
        (const unsigned char*)payload.data(), payload.size(), pk) != 0) return false;
    auto claims = nlohmann::json::parse(b64url_decode(payload));
    return claims["exp"].get<long long>() > now_ms();
}
// PHP 7.2+ has sodium built in.
function authsy_check_offline(string $token, string $publicKey): bool {
    [$payload, $sig] = explode(".", $token, 2);
    $ok = sodium_crypto_sign_verify_detached(
        sodium_base642bin($sig, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING),
        $payload, $publicKey);
    if (!$ok) return false;
    $claims = json_decode(sodium_base642bin($payload,
        SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING), true);
    return $claims["exp"] > (int)(microtime(true) * 1000);
}
// Android: use Lazysodium (libsodium JNI) or Tink for Ed25519.
fun checkOffline(token: String, publicKey: ByteArray): Boolean {
    val (payload, sigB64) = token.split(".", limit = 2)
    val sig = Base64.decode(sigB64, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
    val ls = LazySodiumAndroid(SodiumAndroid())
    val ok = ls.cryptoSignVerifyDetached(sig, payload.toByteArray(), payload.length, publicKey)
    if (!ok) return false
    val claims = JSONObject(String(Base64.decode(payload, Base64.URL_SAFE)))
    return claims.getLong("exp") > System.currentTimeMillis()
}

Key System — checkpoint unlocks

The Key System hands your free users a time-limited, device-bound key after they clear a few ad-link checkpoints. From your app's side the runtime check is identical to licensing: fingerprint the device, then validate the earned key — same signed response, same verification. The checkpoint flow itself is hosted by Authsy:

GET/api/v1/ks/checkpoint/:productId
  • Open the product's gate (/gate/:productId) in a browser/WebView — Authsy walks the user through the checkpoints and mints a key bound to their machine_id.
  • Your app polls /client/adgate/status (or reads the returned key) and then validates it like any license.
  • Keys expire; on expiry, send the user back through the gate to earn a fresh one.
A LuaU obfuscator ships in the Key System portal (Obfuscator). It wraps your loader with multi-round encryption, a restore VM, and string encryption — and emits plain Luau that stock executors run via load/loadstring (no vendor support required). Keep real key validation server-side.

Error codes

Database & users

An error is { success: false, code, message } with an HTTP status. Nothing here fails quietly: a filter that matched nothing and a filter that was refused are different answers, and you get told which.

CodeMeaning
unknown_table404 — not deployed, or deployed in a different project.
unknown_collection404 — same, for a collection.
ambiguous_table409 — two projects own that name. Add ?project=<id or slug>.
ambiguous_collection409 — same, for a collection.
forbidden403 — the access rule refused it. Check Access review.
unfiltered_update400 — a PATCH with no filter would rewrite every row.
unfiltered_delete400 — a DELETE with no filter would empty the table.
bad_query400 — an unknown operator, an unindexed filter on the storage engine, or a bound exceeded. The message names the field.
bad_pipeline400 — an aggregation stage that is not supported, or a $sort on a name the $group did not produce.
invalid_credentials400 — wrong email or password, indistinguishable from each other on purpose.
email_taken409 — that address already has an account in this project.
weak_password400 — the message says what it needs.
invalid_refresh_token400 — expired, already used, or revoked. Refresh tokens rotate on every use, so a replay lands here.
user_disabled403 — the account was disabled in the dashboard.
not_publishable403 — an auth call made with a manage key. Sign-up and sign-in take a project’s publishable key.
insufficient_scope403 — this key does not have the manage scope.

Licensing

Client endpoints return 200 with a signed { valid: false, code } for license-state outcomes, and HTTP error codes for request/account problems.

CodeMeaning
invalid_keyKey is malformed for this account.
not_foundNo such license under this account.
license_expiredPast its expiry date.
license_suspendedSuspended by the developer.
license_revokedPermanently revoked.
machine_not_activatedThis device hasn't activated the key — call /client/activate first.
seat_limitAll seats in use (activation).
over_quotaHTTP 429 — the developer's monthly validation quota is spent.
invalid_api_keyHTTP 403 — missing/revoked X-Api-Key.

Rate limits

Every /api/ route is limited per IP — 300 requests a minute by default on a self-hosted install, and sign-up and sign-in are limited more tightly than that, because an unlimited sign-in endpoint is a password-guessing service. A page of rows is one request, so limit and offset cost far less than a request per row.

Licensing client endpoints are limited per API key as well as per IP. Validate on launch and on a sensible interval (e.g. hourly for always-on apps) rather than in a tight loop — the offline token is exactly so you don't have to hammer the network. If you hit 429, back off exponentially and fall back to the cached offline token.

The machine-readable version of everything above is at /api/v1/openapi.json. Questions, or want an SDK for a stack that is not covered? Open the dashboard and reach out.