Lemon Squeezy License Activate and Validate Responses Are Not the Same
A license key can be valid and still be rejected by your app if you parse the wrong response shape.
The Lemon Squeezy License API has separate operations for activation, validation, and deactivation. Do not assume every endpoint returns the same JSON structure just because the domain concept is "license key."
For a desktop app, this mistake often appears as a false negative: the user enters a valid key, the provider accepts it, but the app reads the wrong field and treats the key as invalid.
The symptom: valid keys become false negatives
The bug usually looks like this:
const data = await response.json();
const status = data.license_key?.data?.attributes?.status;
if (status !== "active") {
return { ok: false, reason: "invalid_key" };
}
That parser may be correct for one endpoint and wrong for another.
If the activation endpoint returns a flatter response, data.license_key.data.attributes.status is undefined. Your code then rejects a key that was activated successfully.
Compare activate and validate before writing parser code
Treat each endpoint as a separate contract.
For activation, first check activation success:
type ActivateResponse = {
activated: boolean;
error: string | null;
license_key?: {
status?: string;
activation_limit?: number;
activation_usage?: number;
};
instance?: {
id?: string;
name?: string;
};
};
For validation, use a separate type:
type ValidateResponse = {
valid: boolean;
error: string | null;
license_key?: unknown;
instance?: unknown;
};
The exact fields you keep should come from the current Lemon Squeezy docs and the responses you test against. The important part is not to reuse an activation parser for validation, or a validation parser for activation.
Map provider responses into your own license state
Do not let provider JSON shapes spread through the app.
Create a small mapping layer:
type AppLicenseResult =
| { ok: true; status: "active"; instanceId: string | null }
| { ok: false; reason: "invalid_key" | "limit_reached" | "network_error" | "provider_error" };
function mapActivateResponse(data: ActivateResponse): AppLicenseResult {
if (!data.activated) {
return { ok: false, reason: "invalid_key" };
}
return {
ok: true,
status: "active",
instanceId: data.instance?.id ?? null,
};
}
Your Tauri command, React store, and settings UI should consume AppLicenseResult, not raw Lemon Squeezy JSON.
This is the same principle as any IPC or API boundary: deserialize once, normalize once, and keep the rest of the app on your own contract.
Test success, revoked, not found, and network error separately
License UX suffers when every failure becomes "invalid key."
At minimum, test these cases with mocked provider responses:
- activation succeeds
- activation limit is reached
- key is not found
- key is expired or disabled
- validation says the key is no longer valid
- provider returns malformed JSON
- network request fails or times out
The app-level result should preserve decisions that matter:
type ValidationStatus = "valid" | "revoked" | "no_license" | "network_error";
For a paid desktop app, network_error is not the same as revoked. A network failure may be fail-open for a short offline grace period. A known revocation should usually be fail-closed.
Where offline validation fits
Activation and validation are online checks.
For daily startup, many desktop apps also store a local license state and verify it offline. A common pattern is:
activate online
store license key, instance id, activation time, and HMAC
on startup, recompute HMAC with device id
only call provider periodically or on explicit refresh
Offline validation should not call the provider parser at all. It should verify your own stored record. That separation makes it easier to debug whether a failure came from provider response parsing, local storage, device binding, or network access.
Verification checklist
Before shipping:
- save sample activation and validation responses from test keys
- write separate parser tests for each endpoint
- assert that
activated: truecannot becomeinvalid_key - assert that malformed provider data becomes
provider_error - ensure logs never print full license keys
- test deactivation and reactivation on a second machine or test instance
- confirm the UI shows different messages for invalid key, activation limit, and network error