> For the complete documentation index, see [llms.txt](https://docs.ohalee.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.ohalee.com/products/obsidian-license/license-check.md).

# 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](/products/obsidian-license/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:

```yaml
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

```java
public final class MyPlugin extends JavaPlugin {

    private ObsidianLicense.Result license;

    @Override
    public void onEnable() {
        saveDefaultConfig();
        var cfg = getConfig();

        var client = new ObsidianLicense(
                cfg.getString("license.serverUrl"),
                cfg.getString("license.serverPublicKey"));

        try {
            license = client.check(
                    cfg.getString("license.key"),
                    "my-plugin",
                    ObsidianLicense.machineId(getDataFolder().toPath()));
        } catch (ObsidianLicense.LicenseException e) {
            getLogger().severe("License check failed: " + e.getMessage());
            getServer().getPluginManager().disablePlugin(this);
            return;
        }

        getLogger().info("License OK" + (license.tier() != null ? " (" + license.tier() + ")" : ""));
    }

    /** Gate a paid feature on an entitlement attached to the license. */
    private boolean canUseAddons() {
        return license != null && license.has("addons");
    }
}
```

`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:

```
validate.v1.<nonce>.<productId>.<expiresAt>.<entB64>
```

`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:

```java
getServer().getScheduler().runTaskTimerAsynchronously(this, () -> {
    try {
        client.check(key, "my-plugin", machineId);
    } catch (ObsidianLicense.LicenseException e) {
        getServer().getScheduler().runTask(this, () -> {
            getLogger().severe("License no longer valid: " + e.getMessage());
            getServer().getPluginManager().disablePlugin(this);
        });
    }
}, 20L * 3600, 20L * 3600); // hourly
```

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`](https://github.com/ohAleee/obsidianlicense-api/blob/main/core/src/main/java/com/obsidian/license/core/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:

```kotlin
repositories {
    mavenCentral()
    maven("https://jitpack.io")
}

dependencies {
    implementation("com.github.ohAleee.obsidianlicense-api:core:v1.2.1")
}
```

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:

{% embed url="<https://github.com/ohAleee/obsidianlicense-api>" %}

## 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](https://github.com/ohAleee/obsidianlicense-api/blob/main/examples/PaperCheck.java), [Velocity](https://github.com/ohAleee/obsidianlicense-api/blob/main/examples/VelocityCheck.java), [standalone](https://github.com/ohAleee/obsidianlicense-api/blob/main/examples/StandaloneCheck.java).

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