The header-only C++17 library at include/moonbase/. It
covers activation requests, polling for fulfilled activations, local RS256 JWT
validation, cross-SDK device fingerprinting, offline (machine-file) activation,
revocation, and overridable license storage. No UI, no framework, no build step.
Building a JUCE plugin? The moonbase_licensing module wraps this
same core and adds a ready-made activation UI.
- CMake 3.20 or newer
- A C++17 compiler
- Windows, macOS, or Linux (the default device id resolver has native implementations for each)
CURL::libcurland OpenSSL (OpenSSL::SSL,OpenSSL::Crypto) — must be findable on the system (e.g. via your distro, Homebrew, or vcpkg)nlohmann_json3.11+ — used iffind_package(nlohmann_json)succeeds; otherwise it is fetched automatically at build time from the upstream release tarball
The installed package config calls find_dependency() for CURL, OpenSSL, and nlohmann_json, so a consuming project does not need to repeat those find_package calls itself — but the libraries must be available when find_package(moonbase_cpp) is invoked.
Clone the repository (or download a release tarball at https://github.com/Moonbase-sh/moonbase-cpp/archive/refs/tags/v<version>.tar.gz), then configure, build, and install:
cmake -B build -DMOONBASE_BUILD_TESTS=OFF -DMOONBASE_BUILD_EXAMPLES=OFF
cmake --build build
cmake --install build --prefix /your/prefixTo pull the SDK into your own CMake build without a separate install step:
include(FetchContent)
FetchContent_Declare(moonbase_cpp
GIT_REPOSITORY https://github.com/Moonbase-sh/moonbase-cpp.git
GIT_TAG v4.13.4)
set(MOONBASE_BUILD_TESTS OFF)
set(MOONBASE_BUILD_EXAMPLES OFF)
FetchContent_MakeAvailable(moonbase_cpp)
target_link_libraries(your_app PRIVATE moonbase::licensing)add_subdirectory() works the same way if you vendor the source tree into your repo.
find_package(moonbase_cpp REQUIRED)
target_link_libraries(your_app PRIVATE moonbase::licensing)The package exports the moonbase::licensing interface target, which propagates the include directory along with CURL::libcurl, OpenSSL::SSL, OpenSSL::Crypto, and nlohmann_json::nlohmann_json as transitive dependencies.
The build exposes these options. Everything except the SDK target itself defaults
off when the project is consumed as a subproject, so a FetchContent or
add_subdirectory integration never silently acquires test or application targets.
| Option | Default | Purpose |
|---|---|---|
MOONBASE_USE_CURL |
ON |
Build the SDK target with the libcurl HTTP transport. Off drops the CURL dependency entirely; the interface then defines MOONBASE_DISABLE_CURL_TRANSPORT=1 and you supply your own http_client. |
MOONBASE_JUCE_MODULE_ONLY |
OFF |
Add only the moonbase_licensing module target, with JUCE's juce_add_module, so JUCE has to be added first. Skips the SDK target and everything built on it (SDK tests, examples, device id tool, bridge example, install rules), along with its OpenSSL, CURL and nlohmann_json lookups. |
MOONBASE_BUILD_TESTS |
ON top-level, OFF as a subproject |
Build the doctest-based unit and live tests. |
MOONBASE_BUILD_EXAMPLES |
ON top-level, OFF as a subproject |
Build the standalone activation example under examples/. |
MOONBASE_BUILD_DEVICE_ID_TOOL |
ON top-level, OFF as a subproject |
Build the moonbase_device_id diagnostic, which prints this machine's device id and how it was derived. |
MOONBASE_BUILD_JUCE_EXAMPLE |
OFF |
Fetch JUCE and build the OnlineUnlockStatus bridge sample. |
MOONBASE_BUILD_JUCE_NATIVE_EXAMPLE |
OFF |
Fetch JUCE and build the moonbase_licensing module sample. |
MOONBASE_BUILD_JUCE_TESTS |
OFF |
Fetch JUCE and doctest and build the JUCE module's test suite (tests/juce/). |
MOONBASE_BUILD_UI_SNAPSHOTS |
OFF |
Fetch JUCE and build the offscreen UI snapshot harness. |
MOONBASE_SANITIZER |
(empty) | Sanitizer for the test targets: address, thread, undefined, or address,undefined. Ignored under MSVC. |
MOONBASE_JUCE_VERSION |
8.0.4 |
JUCE tag the four JUCE options above fetch. Anything from 6.1.3 up works. It only exists as a cache variable once one of them is on. |
Override MOONBASE_BUILD_TESTS and MOONBASE_BUILD_EXAMPLES explicitly when you want
a subproject integration to build SDK artifacts too. The four JUCE options are opt-in
everywhere: JUCE is fetched and compiled only when one of them is set.
#include <moonbase/moonbase.hpp>
moonbase::licensing_options options;
options.endpoint = "https://demo.moonbase.sh";
options.product_id = "demo-app";
options.public_key = public_key_pem;
options.account_id = "tenant-id"; // optional issuer check
options.client_info = "my-framework/1.2.0"; // optional, see below
options.http_connect_timeout = std::chrono::seconds(10);
options.http_request_timeout = std::chrono::seconds(30);
moonbase::licensing licensing(options);client_info identifies a higher-level integration built on top of the SDK (the
JUCE module sets moonbase-juce/<version> (JUCE …; OS), for example). It is
appended to the User-Agent after moonbase-cpp/<version>, so requests report
every layer, outermost last:
User-Agent: moonbase-cpp/4.3.1 my-framework/1.2.0
Use product tokens (Name/Version, with an optional (comment)) and keep it
ASCII. If your code sits on top of another integration that already set it,
append a segment rather than replacing the string. Control characters are
stripped and the value is capped at 256 characters when the header is built, so
a stray newline can never inject a header.
auto request = licensing.request_activation();
std::cout << "Open: " << request.browser_url << "\n";
std::optional<moonbase::license> license;
while (!license) {
std::this_thread::sleep_for(std::chrono::seconds(2));
license = licensing.get_requested_activation(request);
}
licensing.store().store_local_license(*license);Poll every 2 to 3 seconds. The user is switching back from the browser, so a faster cadence gains nothing.
To protect the Moonbase API, the SDK sends at most one poll per second for each
request. A call to get_requested_activation less than a second after the
previous poll of the same request returns std::nullopt without contacting the
API, so a caller polling from a fast UI timer does not flood the server. Each
request is limited separately, so polling several requests in turn never holds
one of them back. The limit is built in and cannot be turned off.
A 400 from the poll means the server will never complete the request: it
expired, was cancelled, or was refused. A request still unfulfilled an hour
after it was made expires. get_requested_activation then throws
activation_request_error (error_type::activation_request_ended) carrying the
server's reason. Stop polling and start a new activation with
request_activation. Earlier releases threw license_invalid_error here, so
if your polling loop catches that type to stop, catch activation_request_error
too.
A 404 also returns std::nullopt. The API reads requests with eventual
consistency, so one created a moment ago can briefly answer 404, and the SDK
cannot tell Moonbase's 404 from one a proxy sent, so it never ends a request on
one.
Network failures, rate limiting and other server errors throw api_error;
those are worth retrying on the next poll. When the server rate limits with a
Retry-After header, api_error::retry_after() carries it, and later polls
return std::nullopt without contacting the API until it has passed (at most
5 minutes).
request_activation takes an optional moonbase::activation_method. Pass
activation_method::offline to have the same browser flow mint an offline
license instead:
auto request = licensing.request_activation(moonbase::activation_method::offline);The URL, the polling and the storage step are unchanged, but the resulting token
carries method: Offline, so it is validated locally for good
(validate_token_online short-circuits it) and
cannot be revoked. The product must have offline
activations enabled in Moonbase, otherwise the call throws
license_invalid_error reading "Product does not allow offline activations".
On startup, validate the stored token. validate_token_online runs the local
checks (signature, device fingerprint, expiry) and then re-validates against the
Moonbase API when needed:
if (auto local = licensing.store().load_local_license()) {
auto validated = licensing.validate_token_online(local->token);
licensing.store().store_local_license(validated); // persist refreshed token
}Two licensing_options knobs control how often the API is contacted and how
much offline tolerance is allowed:
online_validation_min_interval(default 5 minutes) — if the localvalidated_atis newer than this, the API call is skipped. Makes the method cheap to call frequently (e.g. on every plugin instantiation).online_validation_grace_period(default 7 days) — maximum age the local token may reach without a successful online check. Within grace, transient API failures (network down, 5xx, etc.) fall back to the local result. Beyond grace, the failure is propagated.
Both are measured from validated_at by the device's clock. A validated_at more
than a few minutes ahead of that clock says nothing about how long ago it was, so it
counts as past both: the API is called, and a failure to reach it is propagated.
Definitive server rejections (license_invalid_error, license_expired_error)
always propagate regardless of grace. See Errors for what counts as
definitive.
A rejection stores nothing, so the stored license keeps the time of its last
successful check, and the next call that can't reach Moonbase falls back on it
for the rest of the grace period. When Moonbase revoked the license, or this
device's activation of it (license_revoked_error), delete it: a revoked license
is never restored, and a revoked activation only comes back by activating again,
which stores a license of its own. Keep it for the other rejections: a lapsed
subscription validates again once it is renewed (offline, the device still
enforces the token's own expiry), and a refusal without a revoked code can be
about the request rather than the license.
Offline-activated tokens (activation_method::offline) are validated locally
even when calling validate_token_online — the SDK never contacts the API for
them. Use validate_token_local directly when you want the local-only check
explicitly.
A license bought outright can cover only some versions of your app: the releases
published while its updates lasted ("12 months of updates"), or every version up to
a major ("versions up to 2.x"). It never expires because of it; it just doesn't cover
the versions after that. license::release_access says what it covers, and is empty
for a license that covers every version:
updates_until: releases published until then are covered, and every version while it has not passed.up_to_major: every version whose major is at most this is covered.up_to_version: once updates have ended, the last version published before they did, covered along with every older one. Empty while updates last, and when nothing was published before they ended (then every version is covered).
The limits add up: a license with both covers a version that either one covers.
Set application_version to the version that has to be covered. A license that
doesn't cover it throws release_not_covered_error (error_type::release_not_covered).
For an online license, validate_token_online leaves that to Moonbase when it asks
it, since Moonbase knows of an upgrade bought since the token was issued. The device
judges it by its own clock when Moonbase isn't asked: for an offline license, within
online_validation_min_interval, and when Moonbase can't be reached within the grace
period. validate_token_local and read_offline_license check it too, but
get_requested_activation does not: Moonbase activates only a license that covers the
version the request named, and the seat is taken by then. Set
enforce_release_access = false to turn the check on the device off; Moonbase checks
either way. A version that can't be read is not checked. parse_app_version reads one
the way Moonbase does: an optional leading v, then two to four numbers (a fourth is
ignored), with any -beta or +build suffix dropped, so 3.0.0-beta.2 is judged as
3.0.0.
An offline license is never checked online, so it is judged by what its token said when the license file was made. A version limit ("up to 2.x") holds for good, and so does a time limit in a file made after updates ended, which names the last version covered. A file made while updates last can't name it, so once they end it keeps covering later versions until the customer downloads a new license file, which does. Downloading a later release is still refused when the product's downloads are limited to owners.
release_not_covered_error derives from license_invalid_error, so code that refuses
to run on an invalid license refuses here too. But don't delete the stored license for
it: it still covers the versions it did, so the customer can go back to one of them,
or upgrade and keep going. Catch it first and offer the upgrade:
try {
auto validated = licensing.validate_token_online(local->token);
// unlock
} catch (const moonbase::release_not_covered_error& ex) {
// Keep the license. validate_token_online already stored the copy Moonbase
// sent along with the refusal (ex.license()).
offer_upgrade(ex.app_version(), ex.release_access(), ex.upgrade_url());
} catch (const moonbase::license_revoked_error&) {
licensing.store().delete_local_license(); // only activating again brings it back
} catch (const moonbase::license_invalid_error&) {
// refused, but not revoked: lock, and keep the license
}upgrade_url() is where the customer buys the upgrade or renewal. Only Moonbase knows
it, so it is empty when the device refused, and when the store sells none. Moonbase's
browser activation page offers the upgrade as well, to a signed-in customer, and then
activates the device, so starting a request_activation works without a link.
To check another version, such as an update before offering to download it, use
covers_version:
if (!moonbase::covers_version(license, latest_version)) {
// offer the upgrade along with the update
}covers_version(license, version, now) applies the same rule as Moonbase and every
other Moonbase SDK. The vendored
release-access-vectors.json pins it
down case by case. For downloads, inventory_client::get_release says whether the
customer may download a release (downloads_allowed, which an older Moonbase doesn't
send) and whether their licenses cover it (release_covered, with release_access and
upgrade_url). can_download takes both into account, download_needs_full_license
says whether activating a full license would lift a refusal, and get_download_url
throws release_not_covered_error for a release they don't cover. revoke_activation and
the add-on calls never judge the version, so a license can always be deactivated and
its add-ons managed.
To free up the activation seat for the current device — typically wired to a
"Deactivate" or "Sign out" button — call revoke_activation with the JWT:
if (auto local = licensing.store().load_local_license()) {
licensing.revoke_activation(local->token); // server-side revoke + clears local store
}On success the SDK both tells the server to release the seat and deletes the
matching license from the local store. Revoke is only meaningful for
online-activated paid licenses; calling it for offline or trial tokens raises
operation_not_supported_error without contacting the API. Server rejections
(license_invalid_error) and transport failures (api_error) propagate the
same way they do for validate_token_online, but with no grace-period
fallback — revoke is a one-shot operation.
An add-on (a sub-product, with a product ID like my-app.drum-kit) that the
merchant includes in its main product's license travels in the main license, so
one validation answers for both. When a device activates or validates the main
product, it takes a seat of each such add-on the customer owns, and the license
lists them in sub_products:
if (moonbase::has_sub_product(license, "drum-kit")) {
// unlock the drum kit
}has_sub_product takes the add-on's own part of its ID or the whole ID. It is
true while the license lists the add-on as active and it has not expired, which
matters for an offline license, whose add-on trial can run out long before the
license is checked again. An add-on the customer owns, but with every seat taken
on other devices, is listed with active false. One bought after the device
activated shows up at the next online validation: validate_token_online asks the
server at most once per online_validation_min_interval, and
client().validate_token_online asks right away. find_sub_product returns the
entry itself, with its trial, revoked, expires_at and seats.
owned_sub_product_ids still lists every add-on the customer owns, included in
the license or not, without saying whether it may be used on this device.
If the customer revokes an add-on's seat on this device (from their account, or
the merchant does it for them), validating does not take it back: the license
lists it with revoked set until the app activates it again. To take seats
yourself instead of on every activation and validation, set
licensing_options::auto_activate_sub_products to false. Either way, take a
seat with:
auto updated = licensing.activate_sub_product(license.token, "drum-kit");To let a customer try an add-on that offers trials, start its trial from the main license:
auto updated = licensing.request_sub_product_trial(license.token, "drum-kit");Both return the main license again, now reporting the add-on, and store it as
validate_token_online stores its own (pass a should_persist predicate to
decide at the last moment, as there). The license must be a full license, not a
trial: a trial token throws operation_not_supported_error without contacting
the API. A trial also needs an online license. A customer who owns the add-on
gets a seat of it instead of a trial, and a device gets one trial of each
add-on: asking again returns the license with the trial it had, expired or not.
Revoking the main license's activation frees the device's add-on seats too.
To show the customer what else there is, for example on an upgrade screen, list the product's add-ons from the license (or trial) the device holds. Each comes with its prices when it is for sale, whether the customer owns it, whether to unlock it here, and its trial:
const auto catalog = licensing.list_sub_products(license.token);
for (const auto& add_on : catalog.sub_products) {
if (add_on.active) {
// owned, or trialing, on this device
} else if (add_on.trial && add_on.trial->status == moonbase::sub_product_trial_status::available) {
// offer "Try it", which calls request_sub_product_trial
} else if (!add_on.buy_url.empty()) {
// offer "Buy" for add_on.product.default_variation->price[catalog.currency],
// opening add_on.buy_url in the browser
}
}It lists only the add-ons the customer can do something with: ones they own or
subscribe to, ones on sale, and ones whose trial can be started or is running on
this device. trial->status is available when request_sub_product_trial can
start one now, active or expired for the trial the device had, and
unavailable otherwise, including for a status this release does not know.
Listing changes nothing, and is not stored.
The add-on calls refuse in two ways, and only one of them is about the license:
| Exception | Meaning |
|---|---|
no_eligible_license_error |
The customer has no seat of the add-on to give: they don't own it, or every seat is taken. The license is fine; offer to buy the add-on or free a seat |
operation_not_supported_error |
The add-on can't be activated or tried from this license: it is not included in the license, a trial was asked for from an offline license, or the token is a trial. The license is fine |
license_invalid_error, license_expired_error |
The license itself is no longer good, as from validate_token_online |
Everything the SDK throws derives from moonbase::moonbase_error, whose type()
returns an error_type. For calls to the API, the exception type says whether
trying again can help:
| Exception | Meaning | What to do |
|---|---|---|
api_error |
No definitive answer. The API was unreachable (status_code() is 0), rate limited (429), failing (5xx, including the gateway's own 502 and 504), or something other than the API answered, such as a captive portal |
Retry later; retry_after() says when, if the server did |
license_invalid_error |
The API refused the license for good: a bad signature or product, or a revoked license or activation | Lock, and offer to activate again. Keep the stored license unless it is a license_revoked_error |
license_revoked_error |
The license, or this device's activation of it, was revoked. A license_invalid_error, whose type() is still error_type::license_invalid; activation_revoked() says which |
Lock, delete the stored license, and offer to activate again |
release_not_covered_error |
The license is valid, but doesn't cover application_version. A license_invalid_error |
Lock, keep the license, and offer the upgrade |
license_expired_error |
The license has expired: a trial ended, or a subscription lapsed | Lock, and offer to buy |
store_closed_error |
The merchant closed their Moonbase account. No verdict on the license | Treat it like being offline |
activation_request_error |
A browser activation request can never complete | Start a new request |
no_eligible_license_error |
No seat of an add-on to give. No verdict on the license | Offer to buy the add-on or free a seat |
operation_not_supported_error |
The call can't apply to this license: revoking an offline or trial license, or an add-on it can't take | Keep the license |
configuration_error |
Bad options, or a device id resolver that returned an empty id | Fix the setup |
How each call maps what the API returns:
| Call | Response | Result |
|---|---|---|
request_activation |
400 (product not active, offline activations disabled, invalid product id, a rejected field) | license_invalid_error with the server's reason |
| 404 (no such product or account) | api_error |
|
get_requested_activation |
204 or 404 | std::nullopt |
| 400 | activation_request_error |
|
validate_token_online, revoke_activation |
400 | license_invalid_error (license_expired_error when errorType is LicenseExpired, release_not_covered_error when it is ReleaseNotCovered, license_revoked_error when it is LicenseRevoked or LicenseActivationRevoked) |
| 403 or 404 | api_error, covered by the grace period |
|
activate_sub_product, request_sub_product_trial, list_sub_products |
400 with errorType LicenseInvalid, LicenseRevoked or LicenseActivationRevoked |
license_invalid_error (license_revoked_error for the two revoked codes, license_expired_error for LicenseExpired) |
400 with errorType: NoEligibleLicense |
no_eligible_license_error |
|
| any other 400 | operation_not_supported_error with the server's reason |
|
| 404 | api_error |
|
inventory_client |
403 with errorType: ReleaseNotCovered |
release_not_covered_error with the upgrade link: the customer's licenses don't cover the release |
| any other failure but a closed store | api_error with the status; these endpoints never judge the license |
|
| any | 410 with errorType: StoreClosed |
store_closed_error, read before the status |
| any | 429, 5xx, a transport failure | api_error |
Only an answer that can only have come from Moonbase is definitive, because
license_invalid_error skips the offline grace period. So a 2xx whose body is
not what the endpoint returns, such as a sign-in page where a license token
belongs, is an api_error. So is a 403 or 404, although /validate and
/revoke send those for a license that no longer exists or changed hands: their
body is stock ASP.NET ProblemDetails with no errorType, which a misrouted
request gets from any other ASP.NET service too. A license deleted on the server
therefore keeps working until the grace period runs out.
The message of each exception carries the server's reason where it gave one:
the problem's detail, the first field error of a validation failure, or the
gateway's own message.
There are two routes to an offline license, and which one fits depends on whether the machine has network at activation time:
- It does: run the normal browser activation and ask for an offline license
with
request_activation(activation_method::offline). Nothing else about the flow changes. - It does not: use the file-based exchange below, which involves no network on the device at all.
Either way the resulting token is permanent and unrevokable; it stays valid until the machine's device fingerprint changes.
For machines without internet access, Moonbase supports a file-based flow: the app emits a device token ("machine file"), the user exchanges it for a license token on the Moonbase activation page, and the app reads that token back in. No network is involved on the device.
-
Generate the device token and write it to a file (conventionally
.dt):const auto device_token = licensing.generate_device_token(); std::ofstream("device-token.dt") << device_token;
-
The user uploads
device-token.dtand receives a license token file (the raw JWT, conventionallylicense.mb) in return. They can do this through any of:- Moonbase's hosted portal —
https://<your-tenant>.moonbase.sh/activate. - The embedded storefront on your own site — trigger the
activate_productintent (Moonbase.activate_product()), which prompts for the device token and hands back the license token file. ThedeviceTokenFileExtension(default.dt) andlicenseTokenFileName(defaultlicense-file.mb) config options control the file types involved. - Your own custom flow — drive the exchange yourself with the Moonbase
APIs and SDKs
(the
/api/customer/inventory/activateendpoint).
- Moonbase's hosted portal —
-
Read the downloaded token back in, validate it locally, and persist it:
std::ifstream file("license.mb"); const std::string token((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>()); auto license = licensing.read_offline_license(token); // local validation only licensing.store().store_local_license(license);
read_offline_license runs the same local checks as validate_token_local
(signature, audience, issuer, device fingerprint, expiry) and additionally
requires the token to have been issued via offline activation, throwing
license_invalid_error otherwise. On startup, validate the stored token with
validate_token_local — offline tokens are never re-validated against the API
and cannot be revoked; they stay valid until the
machine's device fingerprint changes.
class my_resolver final : public moonbase::device_id_resolver {
public:
std::string device_name() const override { return "Studio Mac"; }
std::string device_id() const override { return "stable-device-id"; }
};
auto store = std::make_shared<moonbase::file_license_store>("licenses/license.mb");
auto resolver = std::make_shared<my_resolver>();
moonbase::licensing licensing(options, store, resolver);The default store is in-memory. file_license_store persists a JSON representation of the
validated license, replacing the file in one step so a reader never finds half of one.
Instances that share the file take turns in validate_token_online, so one may wait for
another's network check. When shutting down, call cancel_lock_waits() on the store and a
waiting call throws storage_error instead.
A custom resolver's id is compared literally, so it does not need to follow the
mbd2_ stamp format, and it gives up cross-SDK compatibility by
definition. If you
include narrow SDK headers instead of <moonbase/moonbase.hpp>, include
<moonbase/moonbase_device_id_resolver.hpp> for the default resolver and
<moonbase/http_curl.hpp> for the default CURL transport.
Renamed in 4.0.0.
fingerprint_provider,static_fingerprint_providerandlicensing::fingerprint()have new names; the old ones remain as deprecated aliases until 5.0.0. See Renamed APIs.