← Back to Guides

Vue.js Integration Guide

Add license validation to Vue 2, Vue 3, and Nuxt apps with a reusable composable, store module, router guard, and drop-in validator component.

Contents

  1. Quick Start
  2. Setup
  3. Vue 3 Composition API
  4. Vue 3 Options API
  5. Vue 2 Compatibility
  6. Nuxt.js Integration
  7. Pinia / Vuex Store
  8. License Validator Component
  9. Offline Support
  10. Trial Support
  11. Router Guard
  12. Complete App Example
  13. Testing
  14. Troubleshooting

1. Quick Start

In a hurry? Here is the smallest possible licensed Vue 3 component — a single <script setup> block that calls the API and gates the UI on the result:

src/components/QuickCheck.vue
<script setup>
import { ref, onMounted } from 'vue'

const status = ref('checking')
const message = ref('')

async function validate() {
  const res = await fetch('https://api.unifiedlicensing.com/api/v1/validate-license', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      api_key: 'ul_YOUR_API_KEY',
      product_key: 'YOUR_PRODUCT_KEY',
      license_key: localStorage.getItem('license_key'),
      machine_id: crypto.randomUUID(),
      platform: 'web'
    })
  })
  const data = await res.json()
  status.value = data.valid ? 'licensed' : 'unlicensed'
  message.value = data.message || ''
}

onMounted(validate)
</script>

<template>
  <div v-if="status === 'checking'">Checking license&hellip;</div>
  <div v-else-if="status === 'licensed'">&#10003; Thanks for purchasing!</div>
  <div v-else>
    <p>No valid license found. {{ message }}</p>
    <input v-model="key" placeholder="XXXX-XXXX-XXXX-XXXX" />
    <button @click="activate">Activate</button>
  </div>
</template>

The rest of this guide replaces this quick-and-dirty version with production-grade patterns: a proper machine ID that persists across reloads, a shared composable, offline caching, router protection, and tests.

2. Setup

Create your Vue project (skip if you already have one), then grab your credentials:

# Vue 3 with Vite
npm create vue@latest my-app
cd my-app
npm install

# Vue 2 (Vite-based, recommended over legacy webpack CLI)
npm create vue@latest   # choose "no" to Vue 3-specific features, or use:
npm create vite@legacy-vue2 my-app -- --template vue2

# Nuxt 3
npx nuxi init my-nuxt-app
  1. Your API key — log into the UnifiedLicensing dashboard, open Settings, and copy the vendor API key (starts with ul_).
  2. Your product key — open the Products tab and copy the key for the product you're licensing.

Store credentials in environment variables rather than hard-coding them. Vite exposes variables prefixed with VITE_; Nuxt exposes NUXT_PUBLIC_* for client-safe values:

.env
VITE_UL_API_KEY=ul_YOUR_API_KEY
VITE_UL_PRODUCT_KEY=YOUR_PRODUCT_KEY
Client-side keys are public: anything shipped to the browser is visible in DevTools. That's expected for license validation — the API is rate-limited per key and licenses can be revoked instantly from the dashboard. For hardened apps, proxy validation through your own backend (see Section 6).

3. Vue 3 Composition API Vue 3

This composable is the foundation used by every later section. Create it once, call it anywhere:

src/composables/useLicense.js
import { ref, computed, readonly } from 'vue'

const API_URL = 'https://api.unifiedlicensing.com/api/v1/validate-license'
const LICENSE_KEY_STORAGE = 'ul_license_key'
const CACHE_STORAGE = 'ul_license_cache'

const config = {
  apiKey: import.meta.env.VITE_UL_API_KEY,
  productKey: import.meta.env.VITE_UL_PRODUCT_KEY
}

/** Stable per-browser machine ID. Persists across reloads. */
export function getMachineId() {
  const STORAGE_KEY = 'ul_machine_id'
  let id = localStorage.getItem(STORAGE_KEY)
  if (!id) {
    id = crypto.randomUUID
      ? crypto.randomUUID()
      : 'mid-' + Array.from(crypto.getRandomValues(new Uint8Array(16)))
          .map(b => b.toString(16).padStart(2, '0')).join('')
    localStorage.setItem(STORAGE_KEY, id)
  }
  return id
}

/** Raw API call. Returns parsed JSON; throws on network failure. */
export async function validateAgainstApi(licenseKey) {
  const res = await fetch(API_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      api_key: config.apiKey,
      product_key: config.productKey,
      license_key: licenseKey,
      machine_id: getMachineId(),
      platform: 'web'
    })
  })
  if (!res.ok) {
    throw new Error(`Validation request failed with HTTP ${res.status}`)
  }
  return res.json()
}

/**
 * Shared reactive state (module scope = singleton across components).
 */
const status = ref('idle')        // idle | loading | valid | invalid | error
const licenseData = ref(null)     // full API response when valid
const error = ref(null)

export function useLicense() {
  const isLicensed = computed(() => status.value === 'valid')
  const isLoading = computed(() => status.value === 'loading')

  /**
   * Validate a license key. Returns the API response object.
   */
  async function validate(licenseKey) {
    if (!licenseKey) {
      status.value = 'invalid'
      error.value = 'No license key provided.'
      return { valid: false, message: error.value }
    }

    status.value = 'loading'
    error.value = null

    try {
      const data = await validateAgainstApi(licenseKey)

      if (data.valid) {
        licenseData.value = data
        status.value = 'valid'
        localStorage.setItem(LICENSE_KEY_STORAGE, licenseKey)
        localStorage.setItem(CACHE_STORAGE, JSON.stringify({
          ...data,
          license_key: licenseKey,
          validated_at: Date.now()
        }))
      } else {
        licenseData.value = null
        status.value = 'invalid'
        error.value = data.message || 'This license key is not valid.'
        localStorage.removeItem(CACHE_STORAGE)
      }
      return data
    } catch (err) {
      status.value = 'error'
      error.value = err.message
      return { valid: false, message: err.message, network_error: true }
    }
  }

  /** Restore session from cache without hitting the network. */
  function restoreFromCache(maxAgeHours = 72) {
    try {
      const raw = localStorage.getItem(CACHE_STORAGE)
      if (!raw) return false
      const entry = JSON.parse(raw)
      const ageHours = (Date.now() - entry.validated_at) / 3600000
      if (ageHours > maxAgeHours) return false
      licenseData.value = entry
      status.value = 'valid'
      return true
    } catch {
      return false
    }
  }

  function reset() {
    status.value = 'idle'
    licenseData.value = null
    error.value = null
    localStorage.removeItem(LICENSE_KEY_STORAGE)
    localStorage.removeItem(CACHE_STORAGE)
  }

  return {
    status: readonly(status),
    licenseData: readonly(licenseData),
    error: readonly(error),
    isLicensed,
    isLoading,
    validate,
    restoreFromCache,
    reset
  }
}

Usage in any component:

<script setup>
import { useLicense } from '@/composables/useLicense'

const { status, error, isLicensed, isLoading, validate } = useLicense()
const key = defineModel ? null : null // (placeholder removed)
</script>

<template>
  <p v-if="isLicensed">Licensed &mdash; thank you!</p>
  <p v-else-if="isLoading">Validating&hellip;</p>
  <p v-else-if="error">{{ error }}</p>
</template>
Why module-scope refs? Declaring status outside useLicense() makes it a singleton: every component calling the composable shares the exact same state, like a tiny store with zero dependencies.

4. Vue 3 Options API Vue 3

Prefer data(), computed, and methods? Here is the equivalent as a fully self-contained component:

src/components/LicenseGate.vue
<template>
  <div class="license-gate">
    <!-- Loading -->
    <div v-if="status === 'loading'" class="state loading">
      Validating license&hellip;
    </div>

    <!-- Licensed: render protected content -->
    <slot v-else-if="isValid" />

    <!-- Activation form -->
    <div v-else class="state activate">
      <h2>Activate {{ productName }}</h2>
      <p class="hint">
        Enter the license key from your purchase email.
      </p>

      <form @submit.prevent="activate">
        <input
          v-model.trim="input"
          type="text"
          placeholder="XXXX-XXXX-XXXX-XXXX"
          :disabled="status === 'loading'"
          autocomplete="off"
          spellcheck="false"
        />
        <button type="submit" :disabled="status === 'loading' || !input">
          {{ status === 'loading' ? 'Checking&hellip;' : 'Activate' }}
        </button>
      </form>

      <p v-if="errorMessage" class="error" role="alert">{{ errorMessage }}</p>
      <p v-if="isValidOffline" class="offline">
        You're offline &mdash; using a cached license (grace period active).
      </p>
    </div>
  </div>
</template>

<script>
import { getMachineId, validateAgainstApi } from '@/composables/useLicense'

const CACHE_STORAGE = 'ul_license_cache'
const KEY_STORAGE = 'ul_license_key'
const GRACE_HOURS = 72

export default {
  name: 'LicenseGate',

  props: {
    productName: { type: String, default: 'this app' }
  },

  data() {
    return {
      status: 'idle',       // idle | loading | valid | invalid | error
      input: '',
      errorMessage: '',
      isValidOffline: false
    }
  },

  computed: {
    isValid() {
      return this.status === 'valid'
    }
  },

  mounted() {
    // Fast path: trust a fresh cache first, then re-validate silently.
    if (this.restoreFromCache()) {
      this.status = 'valid'
    }
    const saved = localStorage.getItem(KEY_STORAGE)
    if (saved) this.check(saved)
  },

  methods: {
    async activate() {
      this.errorMessage = ''
      await this.check(this.input)
    },

    async check(licenseKey) {
      this.status = 'loading'
      this.isValidOffline = false
      try {
        const data = await validateAgainstApi(licenseKey)
        if (data.valid) {
          this.status = 'valid'
          localStorage.setItem(KEY_STORAGE, licenseKey)
          localStorage.setItem(CACHE_STORAGE, JSON.stringify({
            ...data,
            license_key: licenseKey,
            validated_at: Date.now()
          }))
          this.$emit('activated', data)
        } else {
          this.status = 'invalid'
          this.errorMessage = data.message || 'That license key is not valid.'
          this.$emit('failed', data)
        }
      } catch (err) {
        // Network failed &mdash; fall back to cache within the grace period.
        if (this.restoreFromCache()) {
          this.status = 'valid'
          this.isValidOffline = true
          this.$emit('offline-grace')
        } else {
          this.status = 'error'
          this.errorMessage =
            'Could not reach the license server. Check your connection and retry.'
          this.$emit('failed', { message: err.message, network_error: true })
        }
      }
    },

    restoreFromCache() {
      try {
        const raw = localStorage.getItem(CACHE_STORAGE)
        if (!raw) return false
        const entry = JSON.parse(raw)
        const ageHours = (Date.now() - entry.validated_at) / 3600000
        if (ageHours > GRACE_HOURS) return false
        return Boolean(entry.valid)
      } catch {
        return false
      }
    }
  }
}
</script>

<style scoped>
.license-gate { max-width: 420px; margin: 0 auto; padding: 32px 0; }
.state.activate h2 { margin-bottom: 4px; }
.hint { opacity: 0.7; margin-bottom: 16px; }
form { display: flex; gap: 8px; }
input {
  flex: 1; padding: 10px 12px; border-radius: 6px;
  border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.05);
  color: inherit; font-family: 'JetBrains Mono', monospace;
}
button {
  padding: 10px 18px; border-radius: 6px; border: none;
  background: #C7A34C; color: #081220; font-weight: 600; cursor: pointer;
}
button:disabled { opacity: 0.5; cursor: not-allowed; }
.error { color: #E07A55; margin-top: 12px; }
.offline { color: #3FA76B; margin-top: 12px; }
</style>

Wrap any protected content with it:

<template>
  <LicenseGate product-name="PhotoDesk Pro" @activated="onActivated">
    <Dashboard />
  </LicenseGate>
</template>

5. Vue 2 Compatibility Vue 2

The Options API component above works almost verbatim in Vue 2. Three differences to handle:

  1. No built-in fetch on old browsers — add the whatwg-fetch polyfill (npm i whatwg-fetch) or use axios.
  2. No crypto.randomUUID in older WebViews — the fallback in getMachineId() already covers this.
  3. Environment variables — Vue CLI uses VUE_APP_ instead of VITE_: process.env.VUE_APP_UL_API_KEY.
src/components/LicenseGate.vue (Vue 2)
<template>
  <div>
    <div v-if="status === 'loading'">Validating license&hellip;</div>
    <slot v-else-if="isValid"></slot>
    <div v-else>
      <h2>Activate {{ productName }}</h2>
      <form @submit.prevent="activate">
        <input v-model.trim="input" placeholder="XXXX-XXXX-XXXX-XXXX" />
        <button type="submit" :disabled="status === 'loading'">Activate</button>
      </form>
      <p v-if="errorMessage" style="color:#E07A55">{{ errorMessage }}</p>
    </div>
  </div>
</template>

<script>
import 'whatwg-fetch' // remove if targeting modern browsers only

const API_URL = 'https://api.unifiedlicensing.com/api/v1/validate-license'
const KEY_STORAGE = 'ul_license_key'
const CACHE_STORAGE = 'ul_license_cache'
const MACHINE_STORAGE = 'ul_machine_id'
const GRACE_HOURS = 72

export default {
  name: 'LicenseGate',

  props: {
    productName: { type: String, default: 'this app' }
  },

  data() {
    return {
      status: 'idle',
      input: '',
      errorMessage: ''
    }
  },

  computed: {
    isValid() {
      return this.status === 'valid'
    }
  },

  mounted() {
    var saved = localStorage.getItem(KEY_STORAGE)
    if (saved) this.check(saved)
  },

  methods: {
    getMachineId() {
      var id = localStorage.getItem(MACHINE_STORAGE)
      if (!id) {
        id = 'mid-' + Math.random().toString(36).slice(2) +
             Date.now().toString(36)
        localStorage.setItem(MACHINE_STORAGE, id)
      }
      return id
    },

    async check(licenseKey) {
      this.status = 'loading'
      this.errorMessage = ''
      try {
        var res = await fetch(API_URL, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            api_key: process.env.VUE_APP_UL_API_KEY,
            product_key: process.env.VUE_APP_UL_PRODUCT_KEY,
            license_key: licenseKey,
            machine_id: this.getMachineId(),
            platform: 'web'
          })
        })
        var data = await res.json()

        if (data.valid) {
          this.status = 'valid'
          localStorage.setItem(KEY_STORAGE, licenseKey)
          localStorage.setItem(CACHE_STORAGE, JSON.stringify({
            valid: true,
            license_key: licenseKey,
            validated_at: Date.now()
          }))
          this.$emit('activated', data)
        } else {
          this.status = 'invalid'
          this.errorMessage = data.message || 'That license key is not valid.'
          this.$emit('failed', data)
        }
      } catch (err) {
        var cached = this.readCache()
        if (cached) {
          this.status = 'valid'
          this.$emit('offline-grace')
        } else {
          this.status = 'error'
          this.errorMessage = 'Could not reach the license server.'
          this.$emit('failed', { message: err.message, network_error: true })
        }
      }
    },

    readCache() {
      try {
        var raw = localStorage.getItem(CACHE_STORAGE)
        if (!raw) return null
        var entry = JSON.parse(raw)
        var ageHours = (Date.now() - entry.validated_at) / 3600000
        return ageHours <= GRACE_HOURS ? entry : null
      } catch (e) {
        return null
      }
    }
  }
}
</script>
Using Vue 2.7? It bundles the Composition API (@vue/composition-api is built in), so the useLicense() composable from Section 3 works unchanged — just swap import.meta.env for process.env.

6. Nuxt.js Integration SSR-safe

Nuxt gives you something plain SPAs don't: a server. Use it to hide your API key entirely — the browser talks to your server route, which talks to UnifiedLicensing.

Step 1 — Configure runtime secrets

nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    // Server-only keys (never shipped to the browser)
    ulApiKey: process.env.UL_API_KEY,
    ulProductKey: process.env.UL_PRODUCT_KEY,

    public: {
      ulApiUrl: 'https://api.unifiedlicensing.com/api/v1'
    }
  }
})
.env
UL_API_KEY=ul_YOUR_API_KEY
UL_PRODUCT_KEY=YOUR_PRODUCT_KEY

Step 2 — Server proxy endpoint

server/api/validate-license.post.ts
export default defineEventHandler(async (event) => {
  const config = useRuntimeConfig(event)
  const body = await readBody(event)

  if (!body?.license_key) {
    throw createError({ statusCode: 400, statusMessage: 'license_key is required' })
  }

  try {
    return await $fetch(`${config.public.ulApiUrl}/validate-license`, {
      method: 'POST',
      body: {
        api_key: config.ulApiKey,
        product_key: config.ulProductKey,
        license_key: body.license_key,
        machine_id: body.machine_id,
        platform: 'web'
      }
    })
  } catch (err) {
    throw createError({
      statusCode: 502,
      statusMessage: 'License service unreachable'
    })
  }
})

Step 3 — Shared license state

composables/useLicenseState.ts
import { computed } from 'vue'

interface LicenseResponse {
  valid: boolean
  message?: string
  [key: string]: unknown
}

export function useLicenseState() {
  // useState is SSR-safe: serialized to the client during hydration
  const state = useState<'idle' | 'loading' | 'valid' | 'invalid' | 'error'>(
    'license-status', () => 'idle')
  const data = useState<LicenseResponse | null>('license-data', () => null)

  const isLicensed = computed(() => state.value === 'valid')

  async function validate(licenseKey: string) {
    state.value = 'loading'
    try {
      const res = await $fetch<LicenseResponse>('/api/validate-license', {
        method: 'POST',
        body: { license_key: licenseKey, machine_id: getMachineId() }
      })
      data.value = res
      state.value = res.valid ? 'valid' : 'invalid'

      if (import.meta.client) {
        if (res.valid) {
          localStorage.setItem('ul_license_key', licenseKey)
          localStorage.setItem('ul_license_cache', JSON.stringify({
            ...res, license_key: licenseKey, validated_at: Date.now()
          }))
        } else {
          localStorage.removeItem('ul_license_cache')
        }
      }
      return res
    } catch {
      state.value = 'error'
      return { valid: false, message: 'License service unreachable' }
    }
  }

  return { state, data, isLicensed, validate }
}

function getMachineId(): string {
  if (import.meta.server) return 'ssr-pending'
  const KEY = 'ul_machine_id'
  let id = localStorage.getItem(KEY)
  if (!id) {
    id = crypto.randomUUID()
    localStorage.setItem(KEY, id)
  }
  return id
}

Step 4 — Global route middleware

middleware/license.global.ts
export default defineNuxtRouteMiddleware(async (to) => {
  // Always allow the activation page itself
  if (to.path === '/activate') return

  const { state, isLicensed, validate } = useLicenseState()

  // Already validated this session
  if (isLicensed.value) return

  // Try cached key from a previous visit (client only)
  const savedKey = import.meta.client
    ? localStorage.getItem('ul_license_key')
    : null

  if (savedKey) {
    await validate(savedKey)
    if (isLicensed.value) return
  }

  // No license &mdash; bounce to activation with a return path
  return navigateTo({ path: '/activate', query: { redirect: to.fullPath } })
})

Step 5 — Activation page

pages/activate.vue
<script setup lang="ts">
const { state, validate } = useLicenseState()
const key = ref('')
const message = ref('')
const route = useRoute()
const router = useRouter()

async function submit() {
  message.value = ''
  const res = await validate(key.value)
  if (res.valid) {
    router.push((route.query.redirect as string) || '/')
  } else {
    message.value = res.message || 'Invalid license key.'
  }
}
</script>

<template>
  <div class="activate">
    <h1>Activate</h1>
    <form @submit.prevent="submit">
      <input v-model.trim="key" placeholder="XXXX-XXXX-XXXX-XXXX" />
      <button :disabled="state === 'loading'">
        {{ state === 'loading' ? 'Checking&hellip;' : 'Activate' }}
      </button>
    </form>
    <p v-if="message" role="alert">{{ message }}</p>
  </div>
</template>
Nuxt 2? Same architecture, different syntax: put the proxy in serverMiddleware/ or use serverMiddleware with Express, secrets in privateRuntimeConfig, and fetch()/$axios instead of $fetch. Middleware lives in middleware/ with context.redirect().

7. Pinia / Vuex Store

When multiple distant components need license state, promote it to a real store.

Pinia (recommended)

src/stores/license.js
import { defineStore } from 'pinia'
import { validateAgainstApi } from '@/composables/useLicense'

const KEY_STORAGE = 'ul_license_key'
const CACHE_STORAGE = 'ul_license_cache'

export const useLicenseStore = defineStore('license', {
  state: () => ({
    status: 'idle',                                  // idle|loading|valid|invalid|error
    data: null,                                      // last successful API response
    error: null,
    licenseKey: localStorage.getItem(KEY_STORAGE) || '',
    offlineGrace: false
  }),

  getters: {
    isValid: (s) => s.status === 'valid',
    isLoading: (s) => s.status === 'loading',
    plan: (s) => s.data?.plan ?? null,
    expiresAt: (s) => s.data?.expires_at ?? null,
    daysRemaining(state) {
      if (!state.data?.expires_at) return null
      return Math.max(0, Math.ceil(
        (new Date(state.data.expires_at) - Date.now()) / 86400000
      ))
    }
  },

  actions: {
    async validate(licenseKey = this.licenseKey) {
      if (!licenseKey) {
        this.status = 'invalid'
        this.error = 'No license key on file.'
        return false
      }

      this.status = 'loading'
      this.error = null
      this.offlineGrace = false

      try {
        const data = await validateAgainstApi(licenseKey)

        if (data.valid) {
          this.status = 'valid'
          this.data = data
          this.licenseKey = licenseKey
          localStorage.setItem(KEY_STORAGE, licenseKey)
          localStorage.setItem(CACHE_STORAGE, JSON.stringify({
            ...data, license_key: licenseKey, validated_at: Date.now()
          }))
        } else {
          this.status = 'invalid'
          this.error = data.message || 'License is not valid.'
          localStorage.removeItem(CACHE_STORAGE)
        }
      } catch (err) {
        // Network down &mdash; grace period from cache
        const cached = this.readCache()
        if (cached) {
          this.status = 'valid'
          this.data = cached
          this.offlineGrace = true
        } else {
          this.status = 'error'
          this.error = err.message
        }
      }
      return this.isValid
    },

    readCache() {
      try {
        const raw = localStorage.getItem(CACHE_STORAGE)
        if (!raw) return null
        const entry = JSON.parse(raw)
        const ageHours = (Date.now() - entry.validated_at) / 3600000
        return ageHours <= 72 ? entry : null
      } catch {
        return null
      }
    },

    /** Silent re-check on app boot; falls back to cache. */
    async verifyOnBoot() {
      if (this.licenseKey) return this.validate()
      const cached = this.readCache()
      if (cached) {
        this.status = 'valid'
        this.data = cached
        this.offlineGrace = true
        return true
      }
      return false
    },

    deactivate() {
      this.status = 'idle'
      this.data = null
      this.error = null
      this.offlineGrace = false
      this.licenseKey = ''
      localStorage.removeItem(KEY_STORAGE)
      localStorage.removeItem(CACHE_STORAGE)
    }
  }
})

Vuex 4 (if your app already uses Vuex)

src/store/modules/license.js
import { validateAgainstApi } from '@/composables/useLicense'

const KEY_STORAGE = 'ul_license_key'
const CACHE_STORAGE = 'ul_license_cache'

export default {
  namespaced: true,

  state: () => ({
    status: 'idle',
    data: null,
    error: null,
    licenseKey: localStorage.getItem(KEY_STORAGE) || ''
  }),

  getters: {
    isValid: (s) => s.status === 'valid',
    plan: (s) => (s.data && s.data.plan) || null
  },

  mutations: {
    SET_STATUS(state, status) { state.status = status },
    SET_DATA(state, data) { state.data = data },
    SET_ERROR(state, error) { state.error = error },
    SET_KEY(state, key) { state.licenseKey = key }
  },

  actions: {
    async validate({ commit, dispatch }, licenseKey) {
      commit('SET_STATUS', 'loading')
      commit('SET_ERROR', null)
      try {
        const data = await validateAgainstApi(licenseKey)
        if (data.valid) {
          commit('SET_STATUS', 'valid')
          commit('SET_DATA', data)
          commit('SET_KEY', licenseKey)
          localStorage.setItem(KEY_STORAGE, licenseKey)
          localStorage.setItem(CACHE_STORAGE, JSON.stringify({
            ...data, license_key: licenseKey, validated_at: Date.now()
          }))
        } else {
          commit('SET_STATUS', 'invalid')
          commit('SET_ERROR', data.message || 'License is not valid.')
        }
      } catch (err) {
        await dispatch('restoreFromCache') || commit('SET_ERROR', err.message)
      }
    },

    restoreFromCache({ commit }) {
      try {
        const raw = localStorage.getItem(CACHE_STORAGE)
        if (!raw) return false
        const entry = JSON.parse(raw)
        if ((Date.now() - entry.validated_at) / 3600000 > 72) return false
        commit('SET_STATUS', 'valid')
        commit('SET_DATA', entry)
        return true
      } catch {
        return false
      }
    }
  }
}
// src/store/index.js
import { createStore } from 'vuex'
import license from './modules/license'

export default createStore({
  modules: { license }
})

// Usage anywhere: this.$store.state.license.status
//                 this.$store.dispatch('license/validate', key)

8. Reusable <LicenseValidator> Component Vue 3

A polished, dependency-free form component you can drop onto any settings page or paywall. Emits events so parent apps decide what happens next.

src/components/LicenseValidator.vue
<template>
  <div class="ul-validator" :class="{ compact }">
    <form class="row" @submit.prevent="submit">
      <input
        v-model.trim="key"
        class="input"
        type="text"
        :placeholder="placeholder"
        :disabled="busy"
        autocomplete="off"
        spellcheck="false"
        aria-label="License key"
      />
      <button class="btn" type="submit" :disabled="busy || !key">
        {{ busy ? 'Checking&hellip;' : buttonLabel }}
      </button>
    </form>

    <transition name="fade">
      <p v-if="feedback.text" class="feedback" :class="feedback.kind" role="status">
        {{ feedback.text }}
      </p>
    </transition>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'
import { useLicense } from '@/composables/useLicense'

const props = defineProps({
  buttonLabel: { type: String, default: 'Activate' },
  placeholder: { type: String, default: 'XXXX-XXXX-XXXX-XXXX' },
  compact:     { type: Boolean, default: false }
})

const emit = defineEmits(['validated', 'failed'])

const { validate, isLoading } = useLicense()
const key = ref('')
const busy = ref(false)
const feedback = reactive({ kind: '', text: '' })

async function submit() {
  if (!key.value || busy.value) return
  busy.value = true
  feedback.text = ''

  const result = await validate(key.value)

  if (result.valid) {
    feedback.kind = 'ok'
    feedback.text = 'License activated successfully.'
    emit('validated', result)
    key.value = ''
  } else {
    feedback.kind = 'err'
    feedback.text = result.network_error
      ? 'Network error &mdash; please try again.'
      : (result.message || 'That license key is not valid.')
    emit('failed', result)
  }

  busy.value = false
}
</script>

<style scoped>
.ul-validator { max-width: 460px; }
.row { display: flex; gap: 8px; }
.input {
  flex: 1; min-width: 0; padding: 11px 14px;
  border-radius: 8px; border: 1px solid rgba(255,255,255,0.16);
  background: rgba(255,255,255,0.05); color: inherit;
  font-family: 'JetBrains Mono', monospace; font-size: 0.95rem;
}
.input:focus { outline: 2px solid #C7A34C; outline-offset: 1px; }
.btn {
  padding: 11px 20px; border-radius: 8px; border: none;
  background: #C7A34C; color: #081220; font-weight: 600; cursor: pointer;
}
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.compact .input, .compact .btn { padding: 7px 10px; font-size: 0.85rem; }
.feedback { margin-top: 10px; font-size: 0.9rem; }
.feedback.ok { color: #3FA76B; }
.feedback.err { color: #E07A55; }
.fade-enter-active, .fade-leave-active { transition: opacity 0.25s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>

Usage:

<template>
  <section>
    <h2>Your License</h2>
    <LicenseValidator
      button-label="Activate license"
      @validated="onValidated"
      @failed="onFailed"
    />
  </section>
</template>

<script setup>
import LicenseValidator from '@/components/LicenseValidator.vue'

function onValidated(data) {
  console.log('Plan:', data.plan, 'Expires:', data.expires_at)
}
function onFailed(result) {
  console.warn('Activation failed:', result.message)
}
</script>

9. Offline Support

Laptops go on airplanes. Desktops lose Wi-Fi. A strict "phone home on every launch" policy punishes paying customers, so cache the last successful validation and honor a grace period:

src/utils/offlineLicense.js
const CACHE_KEY = 'ul_license_cache'
export const DEFAULT_GRACE_HOURS = 72   // how long a cached license stays trusted
const FRESH_HOURS = 24                  // below this, no re-check needed at boot

/**
 * Read the cached validation. Returns null when missing, corrupt,
 * or older than maxAgeHours.
 */
export function getCachedLicense(maxAgeHours = DEFAULT_GRACE_HOURS) {
  try {
    const raw = localStorage.getItem(CACHE_KEY)
    if (!raw) return null

    const entry = JSON.parse(raw)
    if (!entry || !entry.valid) return null

    const ageHours = (Date.now() - entry.validated_at) / 3600000
    if (ageHours > maxAgeHours) {
      localStorage.removeItem(CACHE_KEY)   // expired &mdash; clean up
      return null
    }
    return { ...entry, stale: ageHours > FRESH_HOURS }
  } catch {
    return null
  }
}

export function cacheLicense(apiResponse, licenseKey) {
  localStorage.setItem(CACHE_KEY, JSON.stringify({
    ...apiResponse,
    license_key: licenseKey,
    validated_at: Date.now()
  }))
}

export function clearLicenseCache() {
  localStorage.removeItem(CACHE_KEY)
}

/**
 * Online-first strategy:
 *  1. Fresh cache (< FRESH_HOURS)? Skip the network entirely.
 *  2. Otherwise hit the API; cache success, purge cache on definitive rejection.
 *  3. Network error? Serve the cache inside the grace period.
 */
export async function validateResilient(licenseKey, { graceHours = DEFAULT_GRACE_HOURS } = {}) {
  const cached = getCachedLicense(graceHours)

  if (cached && !cached.stale) {
    return { ...cached, source: 'cache-fresh' }
  }

  try {
    const { validateAgainstApi } = await import('@/composables/useLicense')
    const data = await validateAgainstApi(licenseKey)

    if (data.valid) {
      cacheLicense(data, licenseKey)
      return { ...data, source: 'network' }
    }

    // Definitive rejection (revoked/refunded/expired): purge and fail closed
    clearLicenseCache()
    return data
  } catch (err) {
    if (cached) {
      return { ...cached, source: 'cache-grace', network_error: err.message }
    }
    throw err
  }
}
Tuning the grace period: 72 hours suits most desktop-style tools. Subscription products often use 24–48h to limit churn after a failed payment; perpetual licenses can safely use 7+ days. Whatever you pick, surface it honestly in the UI ("offline mode, recheck required within X days").
Fail closed, not open: if there's no cache and the network fails on first run, show a friendly retry screen — never unlock the app on an unknown error.

10. Trial Support

Two complementary approaches — use either or both:

  1. Server-issued trial keys (recommended): create trial licenses in the dashboard or via the vendor API and hand them out. They validate through the exact same /validate-license endpoint, so zero extra frontend code — trials expire server-side and can't be wiped by clearing localStorage.
  2. Local trial tracking: instant, no signup friction. Bind the trial to a persistent machine ID and accept that determined users can reset it.
src/utils/trial.js
import { getMachineId } from '@/composables/useLicense'

const TRIAL_KEY = 'ul_trial_record'
export const TRIAL_DAYS = 14

export function getTrial() {
  try {
    const raw = localStorage.getItem(TRIAL_KEY)
    if (!raw) return null
    const trial = JSON.parse(raw)

    // Reject tampering: machine ID must match where the trial started
    if (trial.machine_id !== getMachineId()) return null

    if (Date.now() > trial.expires_at) {
      localStorage.removeItem(TRIAL_KEY)
      return null
    }
    return trial
  } catch {
    return null
  }
}

export function startTrial() {
  const existing = getTrial()
  if (existing) return existing   // never extend by re-clicking

  const trial = {
    machine_id: getMachineId(),
    started_at: Date.now(),
    expires_at: Date.now() + TRIAL_DAYS * 86400000
  }
  localStorage.setItem(TRIAL_KEY, JSON.stringify(trial))
  return trial
}

export function trialDaysLeft() {
  const trial = getTrial()
  if (!trial) return 0
  return Math.max(0, Math.ceil((trial.expires_at - Date.now()) / 86400000))
}

export function isTrialActive() {
  return getTrial() !== null
}

Wire it into your root component so the decision happens once, on app load:

src/App.vue
<script setup>
import { ref, onMounted } from 'vue'
import { useLicense } from '@/composables/useLicense'
import { isTrialActive, trialDaysLeft, startTrial } from '@/utils/trial'

const { isLicensed, validate, restoreFromCache } = useLicense()
const mode = ref('loading')   // loading | licensed | trial | locked
const daysLeft = ref(0)

onMounted(async () => {
  // 1. Cached license? Trust it instantly, re-validate in background.
  if (restoreFromCache()) {
    mode.value = 'licensed'
    return
  }

  // 2. Saved license key? Verify with the server.
  const saved = localStorage.getItem('ul_license_key')
  if (saved) {
    const res = await validate(saved)
    if (res.valid) { mode.value = 'licensed'; return }
  }

  // 3. Active trial?
  if (isTrialActive()) {
    daysLeft.value = trialDaysLeft()
    mode.value = 'trial'
    return
  }

  // 4. Nothing &mdash; locked.
  mode.value = 'locked'
})

function onStartTrial() {
  startTrial()
  daysLeft.value = trialDaysLeft()
  mode.value = 'trial'
}
</script>

<template>
  <main>
    <div v-if="mode === 'loading'">Loading&hellip;</div>

    <template v-else-if="mode === 'licensed'">
      <slot />
    </template>

    <div v-else-if="mode === 'trial'" class="banner">
      Trial: {{ daysLeft }} day{{ daysLeft === 1 ? '' : 's' }} remaining.
      <router-link to="/activate">Upgrade now</router-link>
      <router-view />
    </div>

    <div v-else class="locked">
      <h1>Start your 14-day free trial</h1>
      <p>Full features. No credit card required.</p>
      <button @click="onStartTrial">Start free trial</button>
      <p>or <router-link to="/activate">enter a license key</router-link></p>
    </div>
  </main>
</template>
Best practice: combine both. Local trial for friction-free evaluation, and when the trial ends, require a real (or trial) license key from the server — that's the part users can't reset.

11. Router Guard

Protect routes declaratively with meta flags. Unlicensed visitors land on /activate and get returned to where they were headed after activating:

src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import { useLicenseStore } from '@/stores/license'
import ActivateView from '@/views/ActivateView.vue'

const routes = [
  {
    path: '/activate',
    name: 'activate',
    component: ActivateView,
    meta: { public: true }        // reachable without a license
  },
  {
    path: '/',
    name: 'home',
    component: () => import('@/views/HomeView.vue')
    // no meta.public &mdash; protected by default
  },
  {
    path: '/settings',
    name: 'settings',
    component: () => import('@/views/SettingsView.vue')
  },
  {
    path: '/pricing',
    name: 'pricing',
    component: () => import('@/views/PricingView.vue'),
    meta: { public: true }
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

router.beforeEach(async (to) => {
  if (to.meta.public) return true

  const license = useLicenseStore()

  // Already good &mdash; pass through
  if (license.isValid) return true

  // First navigation of the session: verify quietly
  const ok = await license.verifyOnBoot()

  if (ok) return true

  // Send them to activation, remembering the destination
  return {
    name: 'activate',
    query: { redirect: to.fullPath }
  }
})

export default router
src/views/ActivateView.vue
<script setup>
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useLicenseStore } from '@/stores/license'

const route = useRoute()
const router = useRouter()
const license = useLicenseStore()

const key = ref('')

async function submit() {
  const ok = await license.validate(key.value)
  if (ok) {
    router.push(route.query.redirect || '/')
  }
}
</script>

<template>
  <div class="activate-page">
    <h1>Activate your license</h1>
    <form @submit.prevent="submit">
      <input v-model.trim="key" placeholder="XXXX-XXXX-XXXX-XXXX" autofocus />
      <button :disabled="license.isLoading">
        {{ license.isLoading ? 'Checking&hellip;' : 'Activate' }}
      </button>
    </form>
    <p v-if="license.error" class="err">{{ license.error }}</p>
  </div>
</template>
Per-route plans: extend the guard with meta: { requiresPlan: 'pro' } and compare against license.plan to build tiered feature gating (Free / Pro / Enterprise) with the same mechanism.

12. Complete App Example

Everything above assembled into one coherent Vue 3 application. File layout:

src/
├── main.js
├── App.vue
├── router/index.js
├── stores/license.js        (Section 7)
├── composables/useLicense.js (Section 3)
└── views/
    ├── ActivateView.vue      (Section 11)
    └── DashboardView.vue

main.js

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'

const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

App.vue

<template>
  <header class="topbar">
    <strong>PhotoDesk Pro</strong>
    <nav>
      <router-link to="/">Dashboard</router-link>
      <router-link to="/settings">Settings</router-link>
    </nav>
    <span v-if="license.isValid" class="pill" :class="{ grace: license.offlineGrace }">
      {{ license.offlineGrace ? 'Offline grace' : (license.plan || 'Licensed') }}
    </span>
  </header>

  <router-view />
</template>

<script setup>
import { useLicenseStore } from '@/stores/license'

const license = useLicenseStore()
</script>

<style>
body { margin: 0; font-family: Inter, system-ui, sans-serif; }
.topbar {
  display: flex; align-items: center; gap: 24px;
  padding: 14px 28px; border-bottom: 1px solid #22344c;
}
.topbar nav { display: flex; gap: 16px; flex: 1; }
.pill {
  font-size: 0.75rem; padding: 3px 10px; border-radius: 999px;
  background: rgba(63,167,107,0.15); color: #3FA76B;
}
.pill.grace { background: rgba(199,163,76,0.15); color: #C7A34C; }
</style>

router/index.js

import { createRouter, createWebHistory } from 'vue-router'
import { useLicenseStore } from '@/stores/license'

const routes = [
  { path: '/activate', name: 'activate', component: () => import('@/views/ActivateView.vue'), meta: { public: true } },
  { path: '/', name: 'dashboard', component: () => import('@/views/DashboardView.vue') },
  { path: '/settings', name: 'settings', component: () => import('@/views/SettingsView.vue') },
  { path: '/:pathMatch(.*)*', redirect: '/' }
]

const router = createRouter({ history: createWebHistory(), routes })

router.beforeEach(async (to) => {
  if (to.meta.public) return true

  const license = useLicenseStore()
  if (license.isValid) return true

  const ok = await license.verifyOnBoot()
  return ok ? true : { name: 'activate', query: { redirect: to.fullPath } }
})

export default router

views/DashboardView.vue

<template>
  <section class="page">
    <h1>Dashboard</h1>
    <p v-if="license.daysRemaining !== null">
      Your {{ license.plan || 'license' }} renews in {{ license.daysRemaining }} days.
    </p>
    <p v-if="license.offlineGrace" class="notice">
      Working offline &mdash; license will re-verify when you reconnect.
    </p>
    <!-- Real app content here -->
  </section>
</template>

<script setup>
import { useLicenseStore } from '@/stores/license'
const license = useLicenseStore()
</script>

views/SettingsView.vue

<template>
  <section class="page">
    <h1>Settings</h1>

    <h2>License</h2>
    <p>
      Status:
      <strong>{{ license.isValid ? 'Active' : 'Inactive' }}</strong>
      <span v-if="license.licenseKey">
        &nbsp;(key ending {{ license.licenseKey.slice(-4) }})
      </span>
    </p>

    <LicenseValidator @validated="notify" />

    <button v-if="license.isValid" class="danger" @click="deactivate">
      Deactivate on this device
    </button>
  </section>
</template>

<script setup>
import { useLicenseStore } from '@/stores/license'
import LicenseValidator from '@/components/LicenseValidator.vue'

const license = useLicenseStore()

function notify() {
  alert('License activated!')
}
function deactivate() {
  if (confirm('Deactivate PhotoDesk Pro on this device?')) {
    license.deactivate()
  }
}
</script>

Run it with npm run dev, set the two VITE_UL_* variables in .env, and the app locks itself until a valid key is entered — survives refreshes via cache, tolerates offline launches, and deactivates cleanly.

13. Testing

Vitest pairs naturally with Vite projects (Jest works too — see the note at the end).

npm i -D vitest @vue/test-utils jsdom
vitest.config.js
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath } from 'node:url'

export default defineConfig({
  plugins: [vue()],
  test: {
    environment: 'jsdom',
    globals: true
  },
  resolve: {
    alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }
  }
})

Unit-test the store with a mocked fetch

tests/license.spec.js
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useLicenseStore } from '@/stores/license'

// Stable fake for crypto.randomUUID in jsdom
vi.stubGlobal('crypto', { randomUUID: () => 'test-machine-id' })

function mockFetchOnce(payload, ok = true) {
  global.fetch = vi.fn(() =>
    Promise.resolve({ ok, json: () => Promise.resolve(payload) })
  )
}

beforeEach(() => {
  setActivePinia(createPinia())
  localStorage.clear()
  vi.restoreAllMocks()
})

describe('license store', () => {
  it('becomes valid on a successful validation', async () => {
    mockFetchOnce({ valid: true, plan: 'pro', expires_at: '2027-01-01' })

    const store = useLicenseStore()
    await store.validate('AAAA-BBBB-CCCC-DDDD')

    expect(store.isValid).toBe(true)
    expect(store.plan).toBe('pro')
    expect(localStorage.getItem('ul_license_key')).toBe('AAAA-BBBB-CCCC-DDDD')
  })

  it('stays invalid and reports the server message', async () => {
    mockFetchOnce({ valid: false, message: 'License has been revoked.' })

    const store = useLicenseStore()
    await store.validate('REVOKED-KEY')

    expect(store.isValid).toBe(false)
    expect(store.error).toBe('License has been revoked.')
  })

  it('falls back to cache when the network fails', async () => {
    // Seed a fresh cache entry
    localStorage.setItem('ul_license_cache', JSON.stringify({
      valid: true, plan: 'pro', validated_at: Date.now()
    }))

    global.fetch = vi.fn(() => Promise.reject(new Error('network down')))

    const store = useLicenseStore()
    const ok = await store.validate('AAAA-BBBB-CCCC-DDDD')

    expect(ok).toBe(true)
    expect(store.offlineGrace).toBe(true)
  })

  it('rejects cache entries older than the grace period', async () => {
    localStorage.setItem('ul_license_cache', JSON.stringify({
      valid: true, validated_at: Date.now() - 73 * 3600000  // 73h old
    }))
    global.fetch = vi.fn(() => Promise.reject(new Error('offline')))

    const store = useLicenseStore()
    const ok = await store.validate('AAAA-BBBB-CCCC-DDDD')

    expect(ok).toBe(false)
    expect(store.status).toBe('error')
  })

  it('clears stored state on deactivate', async () => {
    mockFetchOnce({ valid: true })
    const store = useLicenseStore()
    await store.validate('AAAA-BBBB-CCCC-DDDD')
    store.deactivate()

    expect(store.isValid).toBe(false)
    expect(localStorage.getItem('ul_license_key')).toBeNull()
    expect(localStorage.getItem('ul_license_cache')).toBeNull()
  })
})

Component test for the validator

tests/LicenseValidator.spec.js
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import LicenseValidator from '@/components/LicenseValidator.vue'

vi.stubGlobal('crypto', { randomUUID: () => 'test-machine-id' })

describe('<LicenseValidator>', () => {
  it('disables the button while the key is empty', () => {
    const wrapper = mount(LicenseValidator)
    expect(wrapper.find('button').attributes('disabled')).toBeDefined()
  })

  it('shows success feedback and emits validated', async () => {
    global.fetch = vi.fn(() =>
      Promise.resolve({
        ok: true,
        json: () => Promise.resolve({ valid: true, plan: 'pro' })
      })
    )

    const wrapper = mount(LicenseValidator)
    await wrapper.find('input').setValue('AAAA-BBBB-CCCC-DDDD')
    await wrapper.find('form').trigger('submit.prevent')
    await vi.waitFor(() => {
      expect(wrapper.emitted('validated')).toBeTruthy()
    })

    expect(wrapper.find('.feedback').text()).toContain('successfully')
  })

  it('shows the server rejection message on failure', async () => {
    global.fetch = vi.fn(() =>
      Promise.resolve({
        ok: true,
        json: () => Promise.resolve({ valid: false, message: 'Expired license.' })
      })
    )

    const wrapper = mount(LicenseValidator)
    await wrapper.find('input').setValue('EXPIRED-KEY')
    await wrapper.find('form').trigger('submit.prevent')
    await vi.waitFor(() => {
      expect(wrapper.emitted('failed')).toBeTruthy()
    })

    expect(wrapper.find('.feedback').text()).toContain('Expired license.')
  })
})
# package.json
"scripts": { "test": "vitest run", "test:watch": "vitest" }

npm test
Jest instead? Swap vi.fn()jest.fn(), vi.stubGlobal → assign to global.fetch directly, vi.waitForwaitFor from the utils, and add vue-jest/babel transforms. Everything else is identical.

14. Troubleshooting

CORS errors in the browser console

Symptom: Access to fetch at 'https://api.unifiedlicensing.com/...' has been blocked by CORS policy. The API allows browser origins, but if you self-host or proxy, your layer must return Access-Control-Allow-Origin. During development, route around CORS with the Vite proxy:

// vite.config.js
export default {
  server: {
    proxy: {
      '/api/ul': {
        target: 'https://api.unifiedlicensing.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api\/ul/, '/api/v1')
      }
    }
  }
}

// Then point the composable at the proxy in dev:
const API_URL = import.meta.env.DEV
  ? '/api/ul/validate-license'
  : 'https://api.unifiedlicensing.com/api/v1/validate-license'

SSR crashes: localStorage is not defined

Nuxt/server-rendered apps execute component code in Node, where window and localStorage don't exist. Defend every storage access:

function safeGet(key) {
  if (typeof window === 'undefined') return null
  try { return window.localStorage.getItem(key) } catch { return null }
}

// Or defer browser-only work:
onMounted(() => { /* localStorage OK here */ })

Note that useState-based state (Section 6) serializes correctly across the server/client boundary; bare module-level ref()s do not share between server requests — they leak across users on the server.

Common gotchas checklist

SymptomCause & fix
Validation passes but UI never updates You replaced a ref's value destructured from the composable. Destructuring returns the ref itself — always mutate via status.value inside the composable, never reassign the destructured variable.
Every page load asks for the key again machine_id regenerated each visit because you called crypto.randomUUID() inline instead of persisting it (Section 3's getMachineId()). Some servers bind licenses to machine IDs.
import.meta.env.VITE_UL_API_KEY is undefined Env vars are baked in at build time and must start with VITE_. Restart the dev server after editing .env. Vue CLI uses VUE_APP_; Nuxt uses NUXT_PUBLIC_.
Works in dev, breaks in production Hard-coded localhost proxy URL, or missing env vars in the CI/build pipeline. Log import.meta.env.MODE temporarily to confirm what shipped.
await res.json() throws Unexpected token The endpoint returned HTML (proxy error page, 404 page). Check res.status and res.headers.get('content-type') before parsing.
HTTP 429 responses Rate limited. Cache validations (Section 9) and validate once per session, not on every route change.
HTTP 401 vs valid: false Different failures: 401 means your api_key/product_key is wrong (config problem); { valid: false } means the customer's key is bad (their problem). Handle them separately in the UI.
Vue 2: Unexpected token '.' at build Optional chaining (s.data?.plan) unsupported in old toolchains. Rewrite as (s.data && s.data.plan) or upgrade the build chain.
License expired mid-session but app keeps working forever You only checked once at boot. Add a periodic re-check (e.g. every 30 minutes) or re-validate on window focus:
// Re-validate when the user returns to the tab (max once per 30 min)
let lastCheck = Date.now()
window.addEventListener('focus', async () => {
  if (Date.now() - lastCheck < 30 * 60 * 1000) return
  lastCheck = Date.now()
  const store = useLicenseStore()
  if (store.licenseKey) await store.validate()
})

Questions or issues? Reach support from the UnifiedLicensing dashboard, and see the other integration guides for React, PHP, Python, .NET, WordPress, mobile, and more.