✅License Check
Verify a license from an ordinary plugin jar: one call, a signed answer, seats, expiry, revocation and entitlements.
Your plugin ships normally. On startup it asks the license server one question, "is this license valid right now?", and gets back an answer it can actually trust. No loader, no encrypted payload, no build-time packing, no change to how you write the plugin.
This is the right mode for most sellers. Read protected-plugins.md if you also need the compiled code to be unavailable without a license.
What it does and does not do
Customer installs
Your normal jar
The loader
Your code on disk
Yes, in clear
Never, memory only
Seats and machine binding
Yes
Yes
Revoke / refund kill switch
Yes
Yes
Tiers and entitlements
Yes, signed
Yes, signed
Survives someone editing your jar
No
Nothing on disk to edit
Work to adopt
One method call
Restructure the plugin
Be straight with yourself about the second-to-last row. Someone who can edit your jar can delete the check. What this stops is license sharing, expired use, and refunded-but-kept copies, which is where most sellers actually lose money.
Setup
Three values, from your dashboard:
license:
# Base URL of the license server.
serverUrl: "https://license.ohalee.com"
# The key issued to this customer.
key: "OBS-XXXX-XXXX-XXXX-XXXX"
# Dashboard, Server key. Pinning it is what stops a redirected DNS entry or a
# proxy from answering "valid" on the server's behalf.
serverPublicKey: "MCowBQYDK2VwAyEA..."Using it
check throws on every denial and every failure. Catch it, log e.getMessage() (it is written for the customer, not for you) and disable the plugin. e.reason() gives you the stable machine code if you want to branch on it.
Why the answer can be trusted
A plain HTTP 200 would be worthless. Anyone can point serverUrl at their own machine and reply "yes". Two things prevent that:
Your process generates a random nonce and sends it. The server signs a message containing that nonce with its Ed25519 private key. A recorded reply from yesterday does not carry today's nonce, and nobody without the private key can produce a signature for it.
Tier and entitlements travel inside that signed message. So
tier()andhas(flag)reflect what you sold, not what the network claimed.
The signed message is exactly:
expiresAt is ISO-8601, or empty when the license never expires. entB64 is base64 of {"tier": ..., "entitlements": {...}}. The client verifies this before it reads any field.
Seats
A check binds a seat exactly like a full activation does, keyed on the machine id you pass. ObsidianLicense.machineId(dataFolder) writes a random id into your plugin's data folder on first run and reuses it after that, so it survives restarts, differs between servers, and does not break when a host is migrated or containerised.
When a customer moves servers they free the old seat themselves at https://license.ohalee.com/portal using their key. That is the single most common support ticket in licensed plugins, and it answers itself.
Need a check that must not consume a seat, such as a health probe or a CI step? Send bindSeat: false in the request body.
Re-checking while the server runs
One check at startup is enough for most plugins. If you want a revocation to bite without waiting for a restart, re-check on a timer:
Keep the interval generous. Hourly is plenty. A plugin that checks every minute will start hitting the per-license rate limit and get denied for being noisy.
Denial reasons
reason()
What the customer should do
denied_invalid
Check the key for typos, copy it again
denied_product
The key is for a different plugin
denied_revoked
Contact the seller, usually a refund or chargeback
denied_expired
Renew
denied_max_activations
Free a seat at /portal, then restart
denied_throttled
Too many attempts, wait a few minutes
denied_ip_banned
Activation from that network is blocked for abuse
The exception message already contains a plain sentence for each of these, so printing it is usually all you need.
The client
The client is one file with no dependencies beyond Gson, which Paper and Velocity already ship. Two ways to get it:
Copy it. Grab ObsidianLicense.java from the public API repository and drop it into your project. Adjust the package and you are done. Nothing to add to your build file.
Or depend on it. The same code is published through JitPack:
The artifact targets Java 21 (current Paper requires it). On Java 17 or older, copy the file instead: on its own it needs nothing newer than Java 11.
Everything you need lives in the public repository, so you do not need access to the platform's own source to build against it:
It works the same everywhere
The client has no Paper or Velocity types in it, so the call above is identical on every host. Only the surrounding lifecycle differs:
Paper
onEnable
getServer().getPluginManager().disablePlugin(this)
Velocity
@Subscribe ProxyInitializeEvent
Register nothing and log the reason (Velocity cannot disable a plugin at runtime)
Plain Java
Startup, before anything else
Throw, or System.exit(1)
Worked examples for all three are in the public repository: Paper, Velocity, standalone.
In a Spring Boot service, run the check from an ApplicationRunner or a @PostConstruct and throw to abort startup. Spring ships Jackson rather than Gson, so either add the Gson dependency or swap the handful of lines that touch JsonObject.
Last updated