FurnFlowDocs
Open the app →
Docs › Admin › Licensing

Licensing

Issue enterprise licenses to clients, monitor their lifecycle, and let trial customers self-serve activation. Distinct from promo codes (which only adjust price) — a license sets the tier itself.

Last updated 2026-05-23 · covers Convert / Resend / Deactivate / Run-expiry-check / Health.

🧠 Two concepts, side by side

Entitlement licensePromo code
PurposeProvisions a paid tier with caps (seats, branches, modules, duration).Discount on the existing plan — % off, $ off, free days/months.
Tenant effectOverrides their plan; they get the entitled tier.Plan stays the same; price/trial is adjusted.
UI sectionGlobal Admin → LicensingGlobal Admin → Billing → Promo codes
Email notificationsActivation, expiring, extended, revoked.None.
🔑

A signup code field accepts either. The server detects the format and routes accordingly — a signed key (FFL1.…) or redemption code (FFL-XXXX-XXXX-XXXX) is treated as an entitlement; anything else falls through to promo-code lookup.

🎟 Three ways a license enters a tenant

🔐 Signed key (FFL1.…)

Ed25519-signed bearer token, ~376 chars. One-time activation. Verifiable offline. Best for VIP clients you want a personalized artifact for.

🎟 Redeem code (FFL-XXXX-XXXX-XXXX)

Short human-friendly code; configurable max-redemptions. Best for batches sent to multiple prospects or campaigns.

⚡ Direct provision

Operator applies the license to an existing tenant from the admin console — no client action needed.

🛠 Issue a license

Global AdminLicensing
  1. Open Global Admin → Licensing. The console lists every tenant with their current license status.
  2. Click 🔑 Mint key for a signed key, or 🎟 Generate codes for redeemable codes.
  3. Set the tier (enterprise / ops / core), duration in days, optional seat limit, and the trial flag.
  4. Use the 📋 Copy button on the result modal to copy the artifact, then deliver to the client through a secure channel.
🔒

Signed keys and redeem codes are bearer tokens — anyone with the value can use them. Treat them like passwords. Use one-time-use limits, set a short expiry, or bind a key to a specific tenant for higher-risk artifacts.

👤 What the client does

The client has two entry points for activating their license:

📝 At signup

The signup wizard has a "Have a license or promo code?" field on step 4. The wizard previews what the code grants before submission. On submit, the tenant lands on the entitled tier from minute one.

⚙️ Existing workspace

An existing tenant owner opens Tenant admin → License and uses the Activate a license key textarea or Redeem a license code input. The tier flips immediately on success.

📆 Day-2 operations

You want to…WhereWhat happens
See every tenant + their licenseGlobal Admin → Licensing (filter by name/email + status, sortable)Default sort puts expiring soonest at the top. Owner email shown beneath the tenant name.
Filter to overdue / expiring tenantsConsole toolbar status dropdownReal-time client-side filter; matches tenant name OR owner email.
Extend a trialRow → ExtendSame license row preserved; license.extend event logged; extended email fired.
Mark a trial as paid (out-of-band payment)Row → 💳 ConvertSets trial=false and optionally extends. Fires "welcome to paid" email.
Revoke a licenseRow → RevokeTenant downgraded to starter; revoked email fired.
Re-send the welcome emailRow → 📧 ResendSame activation template; supports an optional personal note for VIP clients.
See what changed recentlyConsole → Recent license activity (with ⬇ CSV)Cross-tenant feed of last 30 events. Event keys recorded in the audit table: license.issue, license.extend, license.revoke, license.expire, license.trial_converted, license.key_issue, license.codes_generate, license.code_deactivate.
Browse / retire redeem codesConsole → 🎟 Redeem codesPer-code status + usage counter. Deactivate stops new redemptions without unwinding existing tenant licenses.
Switch a client's tierRow → ProvisionSupersedes the prior license with the new one.
Force-fire expiry reminders nowConsole → ⏰ Run expiry checkSweeps every tenant + fires the debounced reminder for any in the 7-day window. Safe to call hourly from cron.
Export the licenses tableToolbar → ⬇ CSVHonours the active filter + sort, so you can download just one slice.

📧 Email lifecycle

Every state transition fires a templated email to the tenant owner (best-effort; failures are logged, never blocking):

TriggerEmailNotes
Activation (signup, admin provision, key activate, code redeem)"FurnFlow {tier} license is active"One per activation.
Expiring soon (within 7 days, not trial)"Your FurnFlow {tier} license expires in N days"Debounced: at most once per 24h per license. Triggered by admin or tenant reading the license endpoint.
Extended"Your FurnFlow {tier} license was extended"Includes the new expiry date and additional days.
Revoked"Your FurnFlow {tier} license was revoked"Optional reason included.
📨

Transport is auto-detected from environment variables: AZURE_COMMUNICATION_CONNECTION_STRING + EMAIL_FROM_ADDRESS (preferred), or SMTP_HOST + SMTP_USER + SMTP_PASS, or console-only in dev. The same fallback chain serves password resets and collection reminders.

Wiring expiry reminders to a scheduler

The expiring email fires opportunistically on GET /api/admin/licenses and GET /api/tenant/license reads — fine for typical use, but it depends on someone opening the relevant page. For hands-off operation, hit POST /api/admin/license/run-expiry-check from any scheduler. The endpoint iterates every tenant, fires the same debounced reminder, and returns { considered, sent, skipped, errors } so you can alert on regressions. The 24h dedupe means safe to run hourly.

An "Run expiry check" button on the admin Licensing console triggers it manually. For automation, schedule a daily curl from Azure Functions, GitHub Actions, or any cron host: curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" https://your-host/api/admin/license/run-expiry-check.

🛡 What entitlement actually does

When a tenant has an active entitlement license, three things change automatically:

  • Plan row (tenant_plans.tier) → the entitled tier (overrides signup plan).
  • Module flags → the entitled modules (overrides plan defaults).
  • Seat cap enforcement on POST /api/tenant/users uses the license's seat_limit.

When the license expires or is revoked, the tenant downgrades to their fallback plan (configurable; default starter) — they aren't suspended. Existing data is preserved; only entitled-tier features go away.

🔐 Security model

  • Signed keys are Ed25519-signed JWTs (FFL1.<payload>.<sig>). The private key lives in .license-signing.json (gitignored) or LICENSE_SIGNING_PRIVATE_KEY env. The public key is exposed at GET /api/license/public-key for offline verification.
  • Redeem codes are random 12-char strings stored in db.license_redeem_codes. Per-code max_redemptions + expires_at enforced on the server.
  • Audit: every issue / extend / revoke / activate writes to db.audit_logs AND db.license_events (the structured event feed surfaced in the admin Recent activity card).
  • Tenant isolation: a tenant can only redeem a code once; activate-key with a tenant-bound key whose tenant_id doesn't match returns 403.
⚠️

Multi-instance signing key footgun. If LICENSE_SIGNING_PRIVATE_KEY isn't set and the .license-signing.json file isn't shared across instances/restarts (Azure App Service scale-out, Azure Container Apps, Azure Functions, Vercel/Lambda, ephemeral container filesystems), each instance generates its own keypair — keys issued by instance A won't verify on instance B, and a redeploy wipes the on-disk file. To prevent silent corruption, license-key minting is hard-failed with HTTP 503 ephemeral_signing_key when this state is detected, and GET /api/license/health flips ok to false with signing_key.ephemeral=true. Verification still works for the current invocation. The fix: run node furnflow-app/scripts/gen-license-signing-key.js and set the printed LICENSE_SIGNING_PRIVATE_KEY in your hosting env — for Azure, that's az webapp config appsettings set --settings LICENSE_SIGNING_PRIVATE_KEY=… or Portal → App Service → Configuration → Application settings.

Environment configuration

VariableDefaultPurpose
LICENSE_SIGNING_PRIVATE_KEYRequired in production / multi-instance. Ed25519 private key (PEM or base64). Without it, each instance generates its own keypair (bad — see Security model).
LICENSE_EXPIRING_WINDOW_DAYS7How many days before expiry the "expiring soon" status + reminder email kick in.
LICENSE_DEFAULT_GRACE_DAYS7How many days after expiry the license stays in "expiring" (still entitled) before flipping to "expired".
AZURE_COMMUNICATION_CONNECTION_STRING + EMAIL_FROM_ADDRESSPreferred email transport. Required to actually send activation / expiring / extended / revoked / converted / collection emails.
SMTP_HOST + SMTP_USER + SMTP_PASS (+ optional SMTP_PORT / SMTP_SECURE / SMTP_FROM)Fallback email transport. Same effect as ACS — the first one configured wins.

🔌 API reference

EndpointAuthPurpose
GET /api/license/public-keyPublicEd25519 public PEM for offline verification.
GET /api/license/healthPublicSigning-key fingerprint + origin (env / file / generated) + counts. ok flips to false when the signing key is ephemeral on a serverless runtime (minting disabled). Pair with a status-page check to alert on key rotation and on ok=false.
GET /api/public/license/preview?code=…PublicValidate + describe what a code/key grants (signup wizard uses this).
POST /api/admin/license-keysAdminMint a signed license key.
POST /api/admin/license-redeem-codesAdminGenerate a batch of redeem codes.
GET /api/admin/licensesAdminList all tenants + their license status. Fires expiring reminders as side-effect.
GET /api/admin/license-activityAdminCross-tenant license event feed (newest first).
POST /api/admin/tenants/:tid/licenseAdminProvision a license directly.
POST /api/admin/tenants/:tid/license/:lid/extendAdminBump the expiry of an existing license by N days.
POST /api/admin/tenants/:tid/license/:lid/revokeAdminRevoke and downgrade to starter.
POST /api/admin/tenants/:tid/license/:lid/convert-trialAdminFlip trial→paid (out-of-band payment); optional duration bump.
POST /api/admin/tenants/:tid/license/:lid/resend-emailAdminRe-fire the activation email; optional customNote.
POST /api/admin/license-redeem-codes/:id/deactivateAdminStop accepting new redemptions for a code.
POST /api/admin/license/run-expiry-checkAdminSweep all licenses + fire expiring reminders (24h debounced). Cron-friendly.
GET /api/tenant/licenseTenant userRead own license + last 20 events.
POST /api/tenant/license/activate-keyTenant adminActivate a signed key in an existing workspace.
POST /api/tenant/license/redeemTenant adminRedeem a code in an existing workspace.