brain/knowledge/platform-editions-ee/license-keys.md
A license key is a self-hosted customer's activation/recovery handle for their Autumn billing identity — an opaque string, not a bundle of feature flags. The user pastes it into the billing UI; the AP backend delegates activation to the Activepieces console, which resolves the key to an Autumn customer (creating one if needed), attaches the license's plan, and returns autumnCustomerId plus a customer-scoped Autumn key. Plan limits and feature flags are then projected from Autumn entitlements — never from the key itself. EE + Cloud; the whole seam is a CE no-op on billingProvider.
History: the legacy system — public
/v1/license-keys/*endpoints,licenseKeysService(verifyKeyOrReturnNull,applyLimits,downgradeToFreePlan), the dailyTRIAL_TRACKERjob, and allsecrets.activepieces.comcalls — was deleted when billing moved to Autumn. Already-released self-hosted builds bundle their own copy of that path; nothing in the current codebase serves them.
autumnBilling.activateLicense({ platformId, licenseKey }) — calls autumnConsole.activate({ licenseKey }) (POST {console}/api/v1/billing/activate, key as Bearer token), then saves platform_plan.licenseKey, stores the returned credentials via platformPlanService.setAutumnCredentials, and runs refreshEntitlements./activate is idempotent — a key maps to one Autumn customer, so re-activating on a fresh instance returns the same customer + creds (support hands the key back to a customer who lost their instance).refreshEntitlements — fetches the Autumn customer and writes mapAutumnFeaturesToPlatformPlan output onto platform_plan: plan, billedTeamProjectsLimit, usersLimit, activeFlowsLimit, includedCredits, and every boolean flag feature.ensureEnrolled — lazy enrollment under a distributedLock; if a licenseKey is already stored it re-activates through the console, otherwise enrollFree with the platform owner's email.provisionLicenseKeyIfPaid — during refreshEntitlements, self-serve paid customers who never entered a key get one minted by the console and saved, so every paying platform ends up with a recovery handle./?licenseKey=… (any origin; self-hosters swap the domain) lets sales hand a customer one link instead of a key to paste. Nothing is stored: the URL carries the key across sign-in. DefaultRoute folds pathname + search into from, useRedirectAfterLogin navigates back to it, and for Google/SAML from rides the OAuth state — see [[ce-authentication]], whose AuthenticatedDefaultRoute note is why that last hop had to learn to carry location.search. An earlier version stashed the key in sessionStorage and stripped the param at sign-in; the write was suppressed with tryCatchSync while the strip was unconditional, so on any origin where Web Storage is blocked (sandboxed iframe, cookies-blocked browser, a privacy extension that stubs setItem and cannot even throw) the key vanished from both places silently. <AutomaticTrialActivation />, rendered inside AllowOnlyLoggedInUserOnlyGuard, reads the param once a platform exists and takes over the viewport with <TrialActivationScreen /> — a full-screen card on the builder canvas with four states (activating with a fake asymptotic progress ramp, success with confetti and a 5s redirect to the default route, not_admin offering the link to forward, failed with retry and a support mailto). It is an overlay rather than a route so no destination is lost and no endpoint or migration is added. AutomaticTrialActivation decides whether to show it in a lazy useState initialiser — every input (the query param, platform, edition, platform role) is a suspense query or a synchronous read, so there is nothing to wait for — and capturing alreadyLicensed at mount is what stops the post-activation platform refetch from unmounting the success panel mid-confetti. Capture precedes the scrub: the key is in React state from the first render, and the screen deletes the param on mount, so the URL copy is only discarded once the key is already held. Two consequences worth knowing — a reload after the scrub loses the key (the user replays the original link), and if the parent skips (Community, or already licensed) nothing mounts to scrub, so the param lingers until the next navigation. The screen holds one useEffect (mount-only) that fires the activation and starts a 250ms clock; the view, the progress ramp, the client-side timeout and the redirect countdown are all derived from that clock plus the mutation's isPending/isSuccess/isError rather than stored, so the only state is now. Activation still posts to the existing POST /v1/platform-billing/activate via useUpdateLisenceKey, with both toasts suppressed (messages: { success: null, error: null }) because the screen renders the outcome itself. AutomaticTrialActivation is a component rather than a hook because the guard's early returns would make a hook call conditional. The link is built in the console (packages/web/src/lib/activation-link.ts, surfaced as "Copy activation link" in the license-key table and on the key-created page) and consumed here, with nothing but the licenseKey query-param name joining the two repos — rename the param or move the route and the console keeps handing out links that silently do nothing.POST /v1/platform-billing/activate — body { licenseKey }, securityAccess.platformAdminOnly([USER]); thin wrapper over billingProvider.activateLicense with platformId from the principal.POST /v1/admin/platforms/apply-license-key — cloud admin (module-level api-key header preHandler checked against AppSystemProp.API_KEY); body { email, licenseKey }; resolves email → platform-admin user → owned platform, then calls the same activateLicense.billingProvider.activateLicense is a CE no-op, but platformPlanModule is registered only under ApEdition.CLOUD and ApEdition.ENTERPRISE in app.ts, so POST /v1/platform-billing/activate 404s on CE. Shared web code that activates a key must gate on the EDITION flag first, or a CE self-hoster gets an activation-failed message whose real cause is AP_EDITION=ce.license_keys table, plan-to-attach + term, trial issuance, autumn_customers ledger). Old-world per-feature flags on the key no longer exist.activateLicense the console call happens before platform_plan.licenseKey is saved — a rejected key is never persisted.AUTUMN_CONSOLE_URL is a hardcoded constant in autumn-utils.ts (currently the testing console); all console calls go through safeHttp with a request timeout.licenseKey column on platform_plan is retained; there is no expiry job in AP — plan lapse is handled console/Autumn-side and lands here via entitlement refresh. A license's expiresAt currently has no effect for non-trial keys: the console's comp attach sends customize: { price: null } with no ends_at, so the comped plan never lapses. Nothing in AP reads licenseExpiresAt either.licenseKeysService.create for sales, externalService.generateKey for the self-serve form) write expiresAt = now + valid_days up front, and both set activatedAt to that same creation timestamp — the column name is a misnomer, nothing ever restamps it. Console activate only ever reads expiresAt and attaches the remainder (trialDays = ceil((expiresAt - now) / day)), so every day a customer waits before activating is a day of trial they never get. A 20-day key activated on day 8 is a 12-day trial; on day 21 it is the dead-key case below. The only lever today is sales extending the expiry by hand.expiresAt activates into no plan at all. Console activate attaches only when isTrial && trialDaysRemaining(expiresAt) >= 1, else when !isTrial (comp) — a trial whose remaining days round to 0 falls through both branches, the customer is created with no subscription, and Autumn's auto_enable puts it on free. The platform then gets every EE flag revoked, one seat, billingEnforced on and powered-by branding on its first request after upgrade.mapAutumnFeaturesToPlatformPlan does flags[feature] = entitlements.flags[feature] ?? false, so any flag the target plan omits is revoked — a license-key → plan mapping that drops one feature silently downgrades that customer. Audit a migration mapping flag-by-flag against the live Autumn catalog before shipping it, not just plan-by-plan.platform_plan flags stand; ensureEnrolled is re-attempted every 300s (getEnrollAttemptKey), and entitlement refresh is throttled to 15 min thereafter.platform_plan.licenseKey is null, since a Cloud platform's key is normally an enterprise trial key handed out by sales. It used to be hidden behind an Alt+A keydown easter egg on the billing route; that reveal was removed (support could not talk customers through it).enrollFree lands the platform on Autumn free (aiProvidersEnabled: false, usersLimit: 1, billingEnforced: true, showPoweredBy: true), which is materially narrower than the OPEN_SOURCE_PLAN an unlicensed EE instance used to get.Entry point: activateLicense on billingProvider (CE no-op in billing-provider.ts, EE impl in autumn-billing.ts), called from the platform-plan controller and the cloud admin service.
packages/server/api/src/app/ee/platform/platform-plan/platform-plan.controller.ts — POST /v1/platform-billing/activatepackages/server/api/src/app/platform/billing-provider.ts — activateLicense(params) seam (CE no-op)packages/server/api/src/app/ee/platform/platform-plan/billing-providers/autumn-billing.ts — EE activateLicense implpackages/server/api/src/app/ee/platform/platform-plan/billing-providers/autumn-utils.ts — autumnConsole.activate, ensureEnrolled, refreshEntitlements, provisionLicenseKeyIfPaidpackages/server/api/src/app/ee/platform/admin/ — admin controller (/platforms/apply-license-key) and applyLicenseKeyByEmail servicepackages/core/shared/src/lib/management/platform/platform.request.ts — ApplyLicenseKeyByEmailRequestBodypackages/web/src/features/billing/components/ — activate-license-dialog.tsx (activation flow) and license-key.tsx (key display)packages/web/src/api/platforms-api.ts — activateLicenseKey(); mutation in packages/web/src/hooks/platform-hooks.tsPaths verified 2026-07-26. An earlier version described the pre-Autumn world (packages/server/api/src/app/ee/license-keys/, remote verification against secrets.activepieces.com, applyLimits, TRIAL_TRACKER); that module was removed.