Skip to content

AIRLOCK

Reads a skill file the way an assistant would, and tells you what it found.

NEXUS

A Windows app that keeps the pieces in one place instead of five windows.

Skill index

Every skill in the index, with the verdict the engine actually produced.

to move to open

Sign in

An account is an email address and a password. Firebase stores the password, hashed — this site never sees it.

Sign in
Licence API · POST · JSON

One address, one method, one verdict.

The Skillry licence API answers exactly one question: may this server still run the Skillry resources today? The skillry_lizenz resource asks once an hour, and everything that goes over the wire is written out below — including the places where the check achieves nothing at all.

POST https://lizenz.skillry.de/v1/pruefen

How the check runs

The server asks

Once an hour, skillry_lizenz sends a POST with the key and a handful of facts about the server. No player sees any of it: the resource has no client_script at all, and a set of directions to the licence API is not something you hand to everyone who connects.

The API answers

JSON with a stand field: gilt or gilt_nicht. Anything else — a 500, a timeout, an empty body, JSON without stand — is explicitly not knowing, and not a refusal.

The resource acts

A refusal is checked a second time ten minutes later before anything happens, and a stop only follows if C.Anhalten is armed. Not knowing leads into the grace period: three days in which nothing happens.


How a script is wired to it

Anyone running the Skillry package as a whole has nothing to do here: skillry_lizenz stops everything whose name begins with skillry_ by itself. The block below is for the two other cases — a single script sold on its own, and resources that are named differently.

1 — The key goes into server.cfg

The key belongs on the customer’s server, not in the source. The environment variable SKILLRY_LIZENZ_KEY does the same job, for anyone who keeps their server.cfg in a repository.

set skillry_lizenz_key "…"

set, not sets or setr

sets would write the key into the server info, where every server browser can read it. setr would send it to every connected client.

2 — The line in your own manifest

This line is part of the block and not a formality. Without it your resource starts even when skillry_lizenz is missing, and exports.skillry_lizenz throws on first access. With it, it does not start at all — which is the right answer here: a resource whose licence check is absent is a resource running unchecked.

dependency 'skillry_lizenz'

3 — The block goes into a server file

Into a server file of the resource you want to protect. Not into a client file and not into a shared_script: the exports exist on the server only, and a call from the other side returns nil — with no error and no line in the log. The check would then look like a check and never find anything.

--- Apply a verdict from skillry_lizenz to your own resource.
local function skillryLizenzPruefen(stand)
  if stand and stand.stand == 'gilt_nicht' and stand.anhalten then
    StopResource(GetCurrentResourceName())
  end
end

-- Every change — the first answer as much as a revocation weeks later.
AddEventHandler('skillry_lizenz:stand', skillryLizenzPruefen)

-- And one late look, in case the verdict arrived before this resource did.
CreateThread(function()
  Wait(20000)
  local heil, stand = pcall(function() return exports.skillry_lizenz:Stand() end)
  if heil then skillryLizenzPruefen(stand) end
end)

Why the event and not a loop

Gilt() returns nil while the first answer is outstanding — and if the API is unreachable, it stays outstanding for three days. A start-up loop would run for three days at one call a second for an answer that does not exist. The event fires on every change, and the first answer is always a change.

Why the twenty seconds

The event does not reach a resource that starts after the verdict. skillry_lizenz waits five seconds and then up to two minutes for the Cfx identifier; look earlier and you only learn that nobody has asked yet, look later and the resource runs unchecked for longer than it needs to.

Why the pcall, given the dependency

dependency covers the moment of starting and nothing else. If skillry_lizenz is stopped while the server is running, the export throws on access — an error in a customer’s console, caused by our licence check, at exactly the moment it cannot answer anyway. If the look fails, nothing happens and the next verdict catches the resource up.

What it deliberately does not do

It does not stop on the absence of knowledge: stand has to be gilt_nicht explicitly. if not gilt then would be wrong, because nil is falsy in Lua and the resource would stop itself in the first seconds after every start. It respects C.Anhalten, so stage 2 stays a dry run. And it starts nothing again by itself.

The exports and the event

Both exist on the server only. A call on the client returns nil, with no error and no line in the log.

exports.skillry_lizenz:Gilt()   --> true | false | nil
exports.skillry_lizenz:Stand()  --> table, never nil

nil is one of three answers, not a "no"

Gilt() returns nil while the first answer is outstanding. Writing if not Gilt() then StopResource(…) stops your resource in the first seconds after every start. true also comes back when the check is switched off entirely — if nothing is being checked, everything is allowed, and an embedded block has no business noticing.

Stand() always returns a table:

{
  stand      = 'aus' | 'unbekannt' | 'gilt' | 'gilt_nicht',
  bis        = 1790000000,  -- or nil
  seit       = 1786000000,  -- when the grace period started
  grund      = '…',         -- or nil
  kontakt    = '…',
  anhalten   = true,        -- whether C.Anhalten is armed
  angehalten = false,       -- whether it has already happened
}

The event fires on every change of state, not on every check — otherwise it would be the same message twenty-four times a day, and a listener that acts on it would act twenty-four times.

AddEventHandler('skillry_lizenz:stand', function(stand) … end)

A TriggerEvent, and deliberately no RegisterNetEvent

A net event under this name could be raised by any player, telling every resource that the licence is valid. A check the checked party can answer for itself is not a check.

The three arming stages

Stage 1 is the default. A freshly installed skillry_lizenz checks nothing and contacts nobody until somebody deliberately arms it — everything above describes what happens after that decision, not before it.

Skip stage 2 and you are testing your licence API for the first time on a server with players on it.

StageC.AnC.AnhaltenWhat happens
1falsefalseNothing. No request, no waiting thread, one line in the console. Gilt() returns true.
2truefalseIt checks and logs what would happen. Nothing is stopped; the message says out loud that it has no consequences.
3truetrueArmed.

You stay at stage 2 until you have a week of logs without a false refusal. A week, because only a week contains a weekend, a restart, a release, and a night on which something of ours was broken.


The contract: the request

This page is the copy, not the original

The binding text is the README shipped alongside skillry_lizenz. This page reproduces it. Two descriptions of the same contract drift apart, and then you believe the wrong one — so if these two ever disagree, the README is right and this page is wrong.

POST https://lizenz.skillry.de/v1/pruefen

Content-Type: application/json
Accept:       application/json
User-Agent:   skillry_lizenz/<version from the manifest>
FieldTypeMeaning
schluesseltextThe licence key. In the body, not in the address — addresses end up in the access log of every intermediary, in the referrer field, and in every screenshot of an error message. Not in a header either: headers get logged at almost every reverse proxy the moment somebody turns on debugging.
kennungtextThe server’s web_baseUrl. Unique per server, derived from the Cfx licence key, public in the server list anyway — and worthless to anyone who steals it. May be empty: not available yet at startup, and never with sv_lan 1.
nametextsv_hostname.
spieltextgamename, in practice always gta5.
plaetzenumbersv_maxclients.
laufzeitnumberSeconds since this server started. Not decoration: a key that keeps reappearing with a small uptime and days between its requests is a pattern, not a coincidence.
fassungtextThe version from the manifest, so the log shows who is still on an old build without a customer having to report anything.
lizenztextOnly when C.LizenzMitschicken = true: the real sv_licenseKey. Off by default, and it should stay that way.

Not in the body: the address of the server. It comes from the connection, and the API sees it for itself. A field adresse would be an assertion by the very server being checked, and worth nothing as evidence.

kennung must never be checked against a format

Cfx.re is changing the scheme and prefixes the old identifier with deprecated-. A check against ^[0-9a-z]{8}\.users\.cfx\.re$ would silently discard every identifier — and with it the one feature that shows whether a key is running on two servers. What is needed is a string that is unique per server and does not change. That is all it has to be.

The contract: the response

HTTP 200 with JSON:

HTTP/1.1 200 OK
Content-Type: application/json

{ "stand": "gilt", "bis": 1790000000, "hinweis": "…", "kontakt": "…" }
FieldRequiredMeaning
standyes"gilt" or "gilt_nicht". Anything else is treated as not knowing, not as a refusal.
bisnoUnix time at which the licence expires. Absent means open-ended. It is also the ceiling on the grace period: a licence that expires on Tuesday does not run until Friday because somebody cut the connection on Monday.
grundon gilt_nichtPlain text for the operator’s console. They read it, so it is a sentence and not an error number.
kontaktnoOverrides C.Kontakt, so a reseller’s contact appears in the message instead of ours.
hinweisnoOne line that appears in the console even on gilt — "your subscription ends in 5 days". Printed only on change, so it is not there twenty-four times a day.

The status codes

Only two things are a no: stand = "gilt_nicht" with HTTP 200, and 401, 402, 403 — unknown or revoked key, unpaid, suspended.

Everything else is not knowing, and not knowing gets the grace period: a timeout, a 500, a 404, an intermediary’s error page, an empty body, JSON without the stand field. An outage is not allowed to end anyone’s evening.

The one rule on the API side

A fault of ours must never go out as a refusal. Answering 403 when in doubt stops every paying server at once, and that is the only outcome that could genuinely end this project.

  • A broken database query becomes 503, never 403.
  • An unreadable body becomes 400, never 401. An oversized one 413.
  • Too many requests become 429, never 403.
  • A query that does not answer within five seconds becomes 503.
  • An empty licence table becomes 503, never 401 — otherwise a wrong database name or an empty restore would make every key look unknown, and every paying server would stop within the hour without anything looking broken.
  • If the database is missing, the service does not start at all, rather than coming up and quietly answering 503.

What a revocation actually does

There is no back channel — the API cannot poke a server. A revocation takes effect the next time the server asks, so after an hour at the latest (C.TaktMinuten), plus ten minutes for the second look (C.NegativTaktMinuten).

That second look is the reason a revocation is not immediate, and it is deliberate: a single refusal can be our mistake. A migration that renames a column; a cache that serves the neighbour’s answer; a typo that sets every row to revoked. In all three cases the API answers cleanly, quickly and with HTTP 200 — and without the second look it would stop every customer simultaneously. A revocation is never in a hurry; a false alarm that hits everyone at once is an evening you do not want to repeat.


What happens when the API is down

Nothing, for three days. The grace period runs from the last successful check, not from the customer’s server start.

AfterWhat the operator notices
1 hourNothing. A line in the console and otherwise nothing at all. That is exactly the point — the most likely outage, our own restart or release, must not be noticeable.
1 dayStill nothing but console lines. A third of the grace period is used up and the game runs unchanged.
36 hoursA warning at every check that names the time at which the stop will happen. A day and a half of notice, not a second and a half.
72 hoursThe resources stop — with a reason and a contact.

The 72 hours are not a feeling. The case that sets the number is the Friday evening: an expired certificate, a DNS record, a lapsed domain — noticed through a customer report, fixed by a human being who is asleep, at work, or on a train. That is a little under 60 hours. 72 covers it with air, and air is cheap here.

What a stop does, and what it does not

It stops, and nothing else. No hidden sabotage: nothing that alters database rows, makes money disappear, deletes vehicles, or only starts biting days later. Damage that is not immediately visible will certainly hit a paying customer eventually, through a mistake on our side — and a script that quietly changes data on suspicion makes every debugging session impossible, including our own. Nothing restarts by itself either: when the licence is valid again the console says so, and the stopped resources come back on the next server restart, triggered by a human who knows who is online.

The honest downside

The grace period is not remembered across a restart. Restart, and you get a fresh one. This is known and accepted: a file or a KVP entry on a thief’s machine is exactly as easy to delete as this check itself. State that does not protect but can break is one piece of state too many. What is left is the pattern in the API — the uptime in every request, the sender address recorded by the API itself, the identifier, the version.


What this layer does not do

It is a threshold, not a lock. This section is here for the same reason it opens the resource’s own README: it sets the expectation everything above should be read with.

A Lua file on somebody else’s server is plain text. Whoever has the files also has config.lua — and C.An = false is a line you delete before the coffee goes cold. Someone more thorough deletes the block from the server file; someone in a hurry renames skillry_lizenz until the dependency entry no longer bites.

That is not a weakness of this implementation, it is the situation: the server has to execute the code, so the server has to have the code. Every check that lives inside the shipped script can be bypassed with one changed line. A check that could not be bypassed would have to live outside the shipped code — and for that there is exactly one tool, below.

The common denominator: it works on customers, not thieves. A customer who cancels and keeps playing stops. A thief who reads the file does not.

What it does do

Knowledge

Which server is running the scripts, since when, with how many slots, on which build. That is support and sales, not defence — and it is the part that pays off daily.

A kill switch

A revoked key stops the honest operation: a chargeback, a cancelled subscription, a copy handed to a second server.

An expiry date

A subscription is a point in time. The Cfx.re entitlement itself knows none.

And if you want more: Asset Escrow

Cfx.re encrypts resources at delivery. The customer then gets no readable Lua files, but a resource that only runs on a server the entitlement has been assigned to. That is the only stage which genuinely stops an attacker, and it sits with Cfx.re rather than with us.

The two answer different questions. Escrow answers "may this server execute the code at all" — hard, unbypassable, and with no notion of time. This layer answers "is the subscription still paid, and what do I know about this server" — soft, bypassable, but with an expiry date, a revocation and a log. If you can only have one, take escrow. If you sell, you need both: escrow alone cannot end a subscription.

The sv_licenseKey is not collected

It is the customer’s credential at Cfx.re, not ours. Whoever holds it can run a server under someone else’s identity. Collecting it would turn our licence API into a place worth breaking into, and every customer into somebody who entrusted us with something we never asked for. It is not needed either: web_baseUrl does the same job and is harmless.


Questions about wiring it in

Ask in the forum. A contract only its sender understands is not one.

Who to reach, and where

The contact in the console comes from C.Kontakt, or from the kontakt field of the response when a reseller sets one. For everything else, the site notice has the details.