Licence SDK
Keep your own licence system if you have one. If you don't, every Peakstone subscription comes with a key like PS-7K3M-9QXA-2HDF-W8ZN that your plugin can check with a small Java library.
- Java
- 21 or newer
- Dependencies
- None
- Size
- About 40 KB
- Version
- 0.1.0
How it works
- The buyer finds the key on Your subscriptions and puts it in your plugin's config.
- The plugin asks Peakstone on startup and every few hours. Each answer is signed with Ed25519 and bound to that request, so it cannot be faked or replayed.
- A valid answer is cached on disk and keeps the plugin running through outages for up to 72 hours.
- When the subscription ends or payment fails for good, the next check says so.
- You can see how many servers use each key, so shared keys stand out.
Install
The SDK runs on Paper for Minecraft 1.21 (Java 21) and on newer servers (Java 25). The source and the full guide live in sdk/java of the Peakstone repository. Until it is published to a repository, run mvn install there, then depend on it:
<dependency>
<groupId>app.peakstone</groupId>
<artifactId>peakstone-license</artifactId>
<version>0.1.0</version>
</dependency>Server owners run many plugins in one JVM, so relocate the SDK into your own package. That keeps it from clashing with another plugin's copy and leaves your plugin a single jar. Replace com.example.myplugin with your package:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.2</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<relocations>
<relocation>
<pattern>app.peakstone.license</pattern>
<shadedPattern>com.example.myplugin.libs.peakstone</shadedPattern>
</relocation>
</relocations>
<filters>
<filter>
<artifact>app.peakstone:peakstone-license</artifact>
<excludes>
<exclude>module-info.class</exclude>
</excludes>
</filter>
</filters>
</configuration>
</execution>
</executions>
</plugin>Quick start
Read the key from your config, check it without blocking the main thread, switch the plugin off when the licence is rejected, and check again every six hours. Your plugin's slug on Peakstone goes in product.
package com.example.myplugin;
import app.peakstone.license.LicenseResult;
import app.peakstone.license.PeakstoneLicense;
import java.time.Duration;
import org.bukkit.plugin.java.JavaPlugin;
public final class MyPlugin extends JavaPlugin {
private PeakstoneLicense.PeriodicChecks checks;
@Override
public void onEnable() {
saveDefaultConfig();
PeakstoneLicense license = PeakstoneLicense.builder()
.product("my-plugin-slug")
.key(getConfig().getString("license-key"))
.publicKey("ps-1", "xNBoJlnIjUeloKkicsJjrkUv4CXxzM/tMjGLkDC0PP4=")
.dataDirectory(getDataFolder().toPath())
.build(); // throws IllegalArgumentException if the key is missing or malformed
license.verifyAsync().thenAccept(this::handle);
checks = license.startPeriodicChecks(Duration.ofHours(6), this::handle);
}
@Override
public void onDisable() {
if (checks != null) checks.close();
}
private void handle(LicenseResult result) {
switch (result) {
case LicenseResult.Valid v ->
getLogger().info("Licence verified, plan " + v.plan());
case LicenseResult.Offline o ->
getLogger().warning("Peakstone is unreachable, using the saved licence until " + o.expiresAt());
case LicenseResult.Invalid i -> disableOnMainThread(i.reason());
case LicenseResult.Unavailable u ->
getLogger().warning("Could not check the licence: " + u.reason());
}
}
// The plugin manager is main-thread only, and handle() runs on a virtual thread.
private void disableOnMainThread(String reason) {
getLogger().severe("Licence rejected: " + reason);
getServer().getScheduler().runTask(this,
() -> getServer().getPluginManager().disablePlugin(this));
}
}# Your licence key from peakstone.app, for example PS-7K3M-9QXA-2HDF-W8ZN
license-key: ""Builder options
| Method | Required | Notes |
|---|---|---|
product(String) | Yes | The plugin slug: letters, digits, ., _ and -, at most 64 characters. |
key(String) | Yes | PS- plus four groups of four characters. Lowercase and spaces are accepted. |
publicKey(keyId, key) | Yes | Peakstone's Ed25519 public key. Call it once per key to support rotation. |
dataDirectory(Path) | Yes | Holds the instance id and the cached answer. Created if missing. |
baseUrl(URI) | No | Defaults to https://peakstone.app. |
timeout(Duration) | No | Connect and request timeout, 10 seconds by default. |
Handling results
verify() and verifyAsync() never throw for network problems, timeouts or server errors. They return a sealed LicenseResult, and the compiler checks that your switch covers every case.
| Result | Meaning | Allows use |
|---|---|---|
Valid | Peakstone confirmed the licence just now. It carries the plan and how long the answer may be reused offline. | Yes |
Offline | Peakstone could not be reached, but a signed, unexpired answer from an earlier check is on disk. | Yes |
Invalid | The licence must not be used. The status says why: unknown key, inactive subscription, revoked, wrong product, bad signature or malformed response. | No |
Unavailable | Peakstone could not be reached and there is nothing usable on disk. This says nothing about the licence itself. | No |
Public key
Embed it in your plugin. It is public and only lets you check signatures. The key id tells the SDK which key signed an answer, so you can add the next key before a rotation.
ps-1xNBoJlnIjUeloKkicsJjrkUv4CXxzM/tMjGLkDC0PP4=Protocol
The SDK handles all of this. Use the API directly only if you are not on the JVM. A check is one request with a fresh nonce:
POST /api/v1/licenses/verify
content-type: application/json
{
"key": "PS-7K3M-9QXA-2HDF-W8ZN",
"product": "my-plugin-slug",
"instance": "5b1c0f4e-8d0a-4f5e-9d3e-2f6a1b7c9e10",
"nonce": "Zk3yQ0m9u2Xv7pRtLw4HnA1sBc8dEfGh"
}The answer is the payload and a signature over exactly those bytes, both base64url without padding, plus the id of the key that signed it:
{
"payload": "eyJ2IjoxLCJ2YWxpZCI6dHJ1ZSwi...",
"signature": "mG3x0T9s...",
"keyId": "ps-1"
}The payload is UTF-8 JSON. When the licence is not valid, valid is false and the answer is still signed.
{
"v": 1,
"valid": true,
"status": "active",
"reason": null,
"product": "my-plugin-slug",
"license": "PS-7K3M-9QXA-2HDF-W8ZN",
"instance": "5b1c0f4e-8d0a-4f5e-9d3e-2f6a1b7c9e10",
"nonce": "Zk3yQ0m9u2Xv7pRtLw4HnA1sBc8dEfGh",
"plan": "Network",
"issuedAt": 1767225600,
"expiresAt": 1767484800,
"periodEnd": 1769904000
}What the SDK checks
An answer is accepted only if every one of these holds:
- The signature verifies with a public key you configured for that key id.
vis 1.productis your plugin.licenseis the key you sent.instanceis this installation's id.nonceis the one you just sent.issuedAtis not in the future andexpiresAtstill is.
Answers that are not 200, such as 429 with a retry-after header, 400 or 5xx, are not signed and mean only that the check could not be made now. GET /api/v1/licenses/keys lists the public keys; pin the key in your plugin instead of fetching it at runtime.
Offline grace
After every live Valid answer the SDK writes the raw answer to peakstone-license.json in your data directory. If a later check cannot get an answer, it loads that file, verifies it again and, while it has not expired, reports Offline. An answer is good for up to 72 hours, and less when the subscription is set to end sooner.
- A signed
Invalidanswer is final. It deletes the cache and is never masked by it. - An answer that cannot be trusted, such as a bad signature or the wrong nonce, never falls back to the cache and cannot wipe it.
- If the data directory is not writable, checks still work online and nothing is cached.
What it cannot stop
Signatures stop forged and replayed answers: a fake peakstone.app cannot produce a valid one. They cannot stop someone from patching the check out of your jar. Treat the SDK as a way to keep honest customers honest and to cut off cancelled subscriptions, not as copy protection. Offline grace also trusts the server's clock.