# Troubleshooting and error codes

Every client API, SDK and vendor API error code, what to tell your customer, and fixes for the most common problems.

Start with the code. The client API and the SDKs return a stable `code` (the `message` is English text for logs, not meant to be parsed), and the [Events](/docs/dashboard/#events) screen of the dashboard records what happened to each license. The tables below say what each code means, what to show your customer and how to fix it; the second half covers common symptoms.

## Client API errors

Returned by `activate`, `validate` and `deactivate` ([client API](/docs/client-api/#errors)), and passed through by the SDKs (Node: `KeyrailApiError.code`; Rust: the code of the `LicenseError`; Tauri: the rejection's `code`, or `reason` in the state).

| HTTP | `code` | Meaning and likely cause | Tell your customer | Fix |
|---|---|---|---|---|
| 400 | `invalid_request` | Malformed body, bad fingerprint, or a key that does not have the key format (typo, missing character). | “This doesn’t look like a license key. Please paste it again.” | Check the key as typed; keys are matched ignoring case and spaces. |
| 404 | `unknown_product` | No product with this ID: the product ID compiled into the app is wrong. | “Licensing is misconfigured. Please contact support.” | Copy the **Product ID** from the product page again. |
| 404 | `unknown_key` | No license with this key **for this product**: a typo, or a key of another product. | “This license key was not found. Check it, or find your keys in the customer portal.” | Look the key up in **Licenses** (full key). |
| 404 | `not_activated` | `validate` for a machine that has no activation, typically after the machine was freed from the dashboard, the portal or the app. | “This computer is no longer activated. Enter your key to activate it again.” | Activate again. The SDK clears its cache on this code. |
| 403 | `license_expired` | Past `validUntil`: the subscription was not renewed within the grace period. | “Your license has expired. Renew it to keep using the app.” | Renewal through your provider, or **Extend** on the license page. |
| 403 | `license_suspended` | Suspended by you, or the subscription is paused. | “Your license is suspended. Contact support.” | **Reinstate** on the license page, or resume the subscription. |
| 403 | `license_refunded` | Fully refunded. A new payment lifts it. | “This purchase was refunded.” | **Reinstate** if the refund was a mistake. |
| 403 | `license_revoked` | Chargeback or manual revocation. Final. | “This license is no longer valid. Contact support.” | Issue a new license if appropriate; a revoked one cannot come back. |
| 403 | `account_closed` | Activation only: the vendor closed its Keyrail account. Machines already activated keep validating for 30 days. | “Activation is unavailable. Contact support.” | Reopen the account with the emailed link, within 30 days. |
| 409 | `activation_limit` | Every activation of the license is in use. | “This key is already used on the maximum number of computers. Free one in the customer portal, or contact support.” | See [activation limit reached](#activation-limit-reached). |
| 429 | `rate_limited` | More than 30 calls per hour for one key and IP address, or 600 per hour for one IP. | “Too many attempts. Try again later.” | Don’t call the API in a loop; the SDKs refresh about once a day. |
| 500 | `internal_error` | Our side. | Nothing: the app keeps working on its stored certificate. | Retried at the next check. |

The SDKs treat `unknown_product`, `unknown_key`, `not_activated` and the four `license_*` codes as definitive: they clear the cached certificate and the app loses access at once. Network errors, `rate_limited` and `internal_error` are transient: the cached certificate keeps being used while it is valid.

## SDK errors

Raised by the SDKs themselves, when no usable answer came back.

| `code` | SDK | Meaning and likely cause | Fix |
|---|---|---|---|
| `no_license` | All | No license activated on this machine (never activated, or the cache was cleared after a definitive refusal). | Ask for a key and activate. Treat it as “unlicensed”, not as an error. |
| `network_error` | All | The service could not be reached: offline, proxy, firewall, DNS. | Transient. A machine already activated keeps working offline; activation needs the network once. |
| `timeout` | Node | No answer within `timeoutMs` (Rust and Tauri report a timeout as `network_error`). | Transient, like `network_error`. |
| `invalid_response` | All | The answer was not the expected JSON, for example a captive portal or a proxy page. | Transient. Check the API base URL if it persists. |
| `invalid_certificate` | All | Activation succeeded, but the certificate returned does not verify with the public keys in your app: a wrong public key, or a [key rotation](/docs/offline-certificates/#key-rotation) activated before your app embedded the new key. Nothing is cached. | Check the public keys (as a map `kid` → key) against the product page, and ship the new key before activating it. |
| `fingerprint_unavailable` | Rust, Tauri | The machine identifier could not be read. | Check that the OS identifier exists (`/etc/machine-id` on Linux) and is readable. |
| `invalid_config` | Rust, Tauri | Missing product ID or public key, an invalid public key, an app version without a numeric major, or no HTTP transport. | A build problem: fix the configuration. |
| `io_error` | Rust, Tauri | The cache file could not be read or written. | Check the app data directory’s permissions. |

## Certificate verification outcomes

When the SDK checks a certificate offline, the first failing rule gives the outcome ([verification rules](/docs/offline-certificates/)). Node and Tauri return it as `reason` with `valid: false`; Rust as `LicenseError::Verify`.

| Outcome | Meaning and likely cause | Tell your customer | Fix |
|---|---|---|---|
| `invalid_signature` | The signature does not verify, or its `kid` is not among the app’s public keys: an edited cache file, or a certificate signed with a key the app does not embed. | “Your license could not be verified. Please activate again.” | Embed every active and pending public key. |
| `wrong_product` | The certificate belongs to another product. | “Licensing is misconfigured. Contact support.” | Check the product ID in the app. |
| `wrong_machine` | The certificate was issued for another machine: a copied cache file, or a machine identifier that changed (OS reinstall, new disk, cloned image). | “This computer needs to be activated again.” | Activate again; free the old machine if the limit is reached. |
| `expired` | The certificate is past its `expiresAt`: the app stayed offline longer than the offline period, or the license reached `validUntil`. | “Connect to the internet so the app can renew its license.” | See [certificate expired offline](#certificate-expired-offline). |
| `not_usable` | The certificate carries a status that grants no access. | Same as the matching `license_*` code. | Check the license status. |
| `version_not_covered` | The app’s major version is above the license’s `maxMajor` (a perpetual license for an older major version). | “Your license covers version N. Upgrade it to use this version.” | Sell the upgrade, then raise **Max major version** with **Edit** on the license. |
| `clock_tampered` | The system clock is more than one day behind the newest certificate this machine has seen. | “Your computer’s date and time look wrong. Set them automatically and restart the app.” | See [clock problems](#clock-problems). |
| `malformed` | The certificate cannot be parsed: a truncated or edited cache. | “Please activate again.” | Activate again. |

## Vendor API errors

The [vendor API](/docs/vendor-api/) and the dashboard use the same error shape, with these extra codes.

| HTTP | `code` | Meaning | Fix |
|---|---|---|---|
| 401 | `unauthorized` | Missing, invalid, revoked or expired credentials, or a closed account. | Check the `Authorization: Bearer kr_sk_…` header; create a new key in [API keys](/docs/dashboard/#api-keys) if it was revoked. |
| 401 | `invalid_token` | A dashboard sign-in link that is unknown, already used or older than 15 minutes. | Request a new sign-in link. |
| 402 | `plan_limit` | Creating a product beyond your Keyrail plan. | Upgrade in **Account & usage**. Other limits are soft and never block your customers. |
| 403 | `forbidden` | An action reserved to the account owner (export, private key export, sending domain, closing). | Sign in as the owner. |
| 403 | `forbidden_origin` | A dashboard write without the dashboard’s own `Origin`, or a “Lost your key?” request from a website that is not in the allowed list. | Use the dashboard itself; add the website’s exact origin under **Account & usage**. |
| 404 | `not_found` | No such object in your account. | Check the ID and the account the key belongs to. |
| 409 | `conflict` | A change to a revoked license, a duplicate plan code or integration, reinstating a license that is neither suspended nor refunded, or a sending domain held by another account. | Read the message; revocation is final. |
| 413 | `payload_too_large` | The request body is over the size limit of the route. | Send a smaller body. |
| 429 | `rate_limited` | More than 5,000 requests per hour for the account, or the hourly budget of license-key emails is used up (also 5 sends per license and hour). | Wait, and batch your calls. |

## Webhook returns 401

The provider shows failed deliveries with `401`: Keyrail rejected the signature, before processing anything.

- **No secret yet.** The integration shows **Secret missing**: paste the secret in **Integrations** and click **Save secret**.
- **Wrong secret.** Paddle: copy the *secret key* of the notification destination that points to this webhook URL. Lemon Squeezy: the signing secret must be exactly the one you typed when creating the webhook. Paste it again to replace it (it is write-only, so you cannot read it back to compare).
- **Sandbox and live mixed up.** Paddle sandbox and live are separate accounts with separate destinations and secrets: each needs its own integration with its own secret.
- **Old delivery.** A Paddle signature older than 5 minutes is refused.

Once the secret is fixed, replay the failed events from your provider. A `404` instead of `401` means the URL points to no integration (a typo, or an integration you disconnected): copy the **Webhook URL** again from **Integrations**.

## No license after a payment

Open **Events** in the dashboard and look for the `webhook.received` event of the payment:

- **Ignored, `unknown price`**: the Paddle price ID or Lemon Squeezy variant ID is not in the integration’s **Price map**. Add it, **Save price map**, then replay the event from your provider. Every price you sell (monthly, yearly, each currency) needs a row.
- **Ignored, `test mode event on a live integration`** (or the reverse): a Lemon Squeezy test-mode order reached a live integration. Create a sandbox integration for test mode.
- **No event at all**: the webhook never arrived or was refused. Check the URL in your provider and its delivery log; look for `401` (see above). In Paddle, check that the destination’s events include `transaction.completed` and `customer.created`.
- **A `500` in Paddle’s log, then a success**: Paddle sent the transaction before the `customer.created` event, so Keyrail did not know the buyer’s email yet; Paddle retries and the license is created on the retry. Add a **Paddle API key** with `customer.read` permission to the integration to avoid the wait.
- **`429`**: the hourly budget of license-key emails of your Keyrail plan is used up; the provider retries later.

## Customer did not receive the key

1. Open the license (search **Licenses** by email). In **History**, `License key e-mailed to the customer` means the email left Keyrail.
2. Ask the customer to check their spam folder and the address they used at checkout (shown on the license).
3. If your customer emails come from your own address, check that your [sending domain](/docs/custom-sender/#verify-your-domain) still verifies; until it does, they go out from Keyrail’s address with your sender name.
4. Click **Send key again** on the license page (at most 5 times per license and hour).

`License key not e-mailed: the account was closed` means the key was withheld because the account is closed. Customers can also find their keys in the [customer portal](/docs/customer-portal/), or with a “Lost your key?” form on your website (**Account & usage**).

## Activation limit reached

`409 activation_limit` means every activation of the license is used: `machines per seat × seats`, or the license’s override. Usually the customer replaced a computer or reinstalled the OS, which changes the machine identifier.

- The customer frees the old machine in the [customer portal](/docs/customer-portal/), or from your app if it offers a “Deactivate” action (the SDK `deactivate` call).
- You free it from the license page: **Free this machine** in **Machines**.
- To allow more machines on this license, use **Edit**: more **Seats**, or a **Machine limit override**. For all licenses of a plan, raise **Machines per seat** in **Plans**.

Re-activating a machine that is already activated is not an error and uses no extra seat.

## Certificate expired offline

A certificate is valid until the earlier of `validUntil` and the offline period (`offlineDays`, 30 by default) after its last refresh. An app that stays offline longer reports `expired`. Connecting once is enough: the next `check()` refreshes the certificate if the license is still usable. If the license itself reached `validUntil`, the refresh fails with `license_expired` and the customer must renew. Choose `offlineDays` per product or per plan in the dashboard ([Products](/docs/dashboard/#products), [Plans](/docs/dashboard/#plans)).

## Clock problems

- **`clock_tampered`**: the clock is more than one day behind a certificate this machine already received, for example a wrong date after a dead BIOS battery or a manual change. A refresh cannot fix it: the customer sets the clock right (automatic time) and restarts the app.
- **Clock far ahead**: certificates look expired (`expired`) as soon as the machine’s date passes their `expiresAt`, even after a refresh, because their dates come from Keyrail’s clock. Again, the fix is the system clock.

## Still stuck?

Write to [support@keyrail.tech](mailto:support@keyrail.tech) with the license ID (`lic_…`) or the integration ID (`int_…`) and the time of the problem. Never send a secret API key or a webhook secret.
