For the complete documentation index, see llms.txt. This page is also available as Markdown.

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

License check
Protected plugin

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:

  1. 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.

  2. Tier and entitlements travel inside that signed message. So tier() and has(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:

Host
Where to call it
How to fail closed

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