← Back to Guides UnifiedLicensing
🐈

PHP Integration Guide

Add license validation to any PHP app — raw PHP, Laravel, Symfony, or a WordPress plugin. Everything runs server-side, so your API key never touches the browser.

PHP 7.4+ Laravel WordPress No dependencies

01Quick Start

Got a PHP app and 30 seconds? Here's the whole thing — drop in the SDK, create a manager, check a license:

<?php
require_once __DIR__ . '/UnifiedLicensing.PHP.v4.0-Complete.php';

$manager = new UnifiedLicenseManager('ul_live_YOUR_API_KEY', 'your-product-key');

$result = $manager->validate('ABCD-EFGH-IJKL-MNOP');   // machine ID is auto-detected

if ($result['valid']) {
    echo "License OK — plan: {$result['plan']}";
} else {
    exit("License check failed: " . $result['message']);
}

That's genuinely it. If $result['valid'] is true, the license is real, active, and hasn't hit its machine limit. If it's false, you lock the door.

Heads up

The rest of this guide goes deeper — Laravel wiring, WordPress hooks, trials, caching — but nothing below is required. The snippet above is a complete integration on its own.

02Setup

Grab your keys

  1. Log in to your vendor dashboard.
  2. Open Settings → API Keys and copy your API key. It starts with ul_live_ (or ul_test_ while you're experimenting).
  3. Open Products, pick your product, and copy its Product Key.

Copy the SDK into your project

Download UnifiedLicensing.PHP.v4.0-Complete.php from the dashboard and drop it somewhere sensible — most folks use a lib/ or includes/ folder:

your-app/
├── lib/
│   └── UnifiedLicensing.PHP.v4.0-Complete.php
├── public/
│   └── index.php
└── .env

Composer or manual?

Manual (zero dependencies): just require_once the file wherever you need it. The SDK is a single self-contained file — no packages, no autoloader gymnastics.

Composer users: you don't need to publish anything. Just add the file to your autoload so it's always available:

composer.json
{
    "autoload": {
        "files": ["lib/UnifiedLicensing.PHP.v4.0-Complete.php"]
    }
}

Then run composer dump-autoload once and you're set.

Requirements

03Initialize

The constructor takes two arguments: your API key and the product key. One manager instance handles everything — validation, activation, trials, heartbeats.

<?php
require_once __DIR__ . '/lib/UnifiedLicensing.PHP.v4.0-Complete.php';

$manager = new UnifiedLicenseManager(
    'ul_live_YOUR_API_KEY',     // your account API key
    'your-product-key'          // which product this app belongs to
);

Don't hardcode keys in committed files

Treat your API key like a database password. Pop it in a .env file (and make sure .env is in your .gitignore):

.env
UNIFIEDLICENSING_API_KEY=ul_live_YOUR_API_KEY
UNIFIEDLICENSING_PRODUCT_KEY=your-product-key
Never ship the API key to the browser

This is the whole point of doing licensing in PHP: the key lives on your server and never appears in JavaScript, HTML source, or network requests the client can inspect. Section 7 shows the pattern in detail.

04Validate Licenses

Validation is the workhorse. You send a license key, the API tells you whether it's real, active, and allowed on this machine. Here's a full example with proper error handling and a seat-quota check:

<?php
require_once __DIR__ . '/lib/UnifiedLicensing.PHP.v4.0-Complete.php';

$manager = new UnifiedLicenseManager('ul_live_YOUR_API_KEY', 'your-product-key');

// A stable ID for "this machine". Hostname + arch works great for servers.
$machineId = hash('sha256', php_uname('n') . '|' . php_uname('m'));

$licenseKey = strtoupper(trim($_POST['license_key'] ?? ''));

try {
    $result = $manager->validate($licenseKey, $machineId);

    if ($result['valid']) {
        // Optional: warn when the seat quota is nearly full
        if ($result['quota_used'] >= $result['quota_limit']) {
            error_log("License {$licenseKey} has used all {$result['quota_limit']} seats.");
        }

        echo "Welcome! Plan: {$result['plan']}, ";
        echo "seats: {$result['quota_used']}/{$result['quota_limit']}, ";
        echo "renews: {$result['expires_at']}";
    } else {
        // The license exists but can't be used — show the human-readable reason
        http_response_code(403);
        echo "Access denied: " . htmlspecialchars($result['message']);
    }
} catch (Exception $e) {
    // Network error, timeout, DNS failure — the API couldn't be reached.
    // Fail closed for paid features, or fail open briefly if you prefer.
    http_response_code(503);
    error_log("License server unreachable: " . $e->getMessage());
    echo "Could not verify your license right now. Please try again shortly.";
}

What comes back

FieldTypeMeaning
validboolThe big one. true means usable on this machine.
statusstringactive, expired, suspended, or revoked.
planstringThe plan name — handy for gating pro features.
quota_usedintMachines currently activated against this license.
quota_limitintTotal seats the customer bought.
expires_atstringISO-8601 expiry date (null for lifetime licenses).
messagestringHuman-friendly reason when valid is false.
Validate vs. activate

validate() is a read-only check — use it freely. activate() consumes a seat and binds the machine to the license, so call it once when the customer first enters their key. After that, stick to validate().

05Laravel Integration

Laravel users get the nicest experience: register the manager in the container, guard routes with middleware, and never think about it again.

1. Config + environment

config/unifiedlicensing.php
<?php

return [
    'api_key'     => env('UNIFIEDLICENSING_API_KEY'),
    'product_key' => env('UNIFIEDLICENSING_PRODUCT_KEY'),
];
.env
UNIFIEDLICENSING_API_KEY=ul_live_YOUR_API_KEY
UNIFIEDLICENSING_PRODUCT_KEY=your-product-key

2. Register a singleton

app/Providers/AppServiceProvider.php
<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use UnifiedLicenseManager;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(UnifiedLicenseManager::class, function () {
            return new UnifiedLicenseManager(
                config('unifiedlicensing.api_key'),
                config('unifiedlicensing.product_key')
            );
        });
    }
}

Now you can type-hint UnifiedLicenseManager in any controller or class and Laravel injects it automatically.

3. A controller for activation

app/Http/Controllers/LicenseController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use UnifiedLicenseManager;

class LicenseController extends Controller
{
    public function __construct(private UnifiedLicenseManager $licenses)
    {
    }

    public function activate(Request $request)
    {
        $validated = $request->validate([
            'license_key' => 'required|string|min:8',
        ]);

        $machineId = $this->machineId();

        $result = $this->licenses->activate(
            strtoupper(trim($validated['license_key'])),
            $machineId
        );

        if (! $result['valid']) {
            return back()->withErrors([
                'license_key' => $result['message'] ?? 'Activation failed.',
            ]);
        }

        // Remember the license for this installation
        setting(['license_key' => $validated['license_key']])->save();

        return redirect()->route('dashboard')
            ->with('status', "License activated — plan: {$result['plan']}");
    }

    private function machineId(): string
    {
        return hash('sha256', gethostname() . '|' . php_uname('m'));
    }
}

4. Middleware to guard routes

app/Http/Middleware/CheckLicense.php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use UnifiedLicenseManager;

class CheckLicense
{
    public function __construct(private UnifiedLicenseManager $licenses)
    {
    }

    public function handle(Request $request, Closure $next)
    {
        $licenseKey = setting('license_key');

        if (! $licenseKey) {
            return redirect()->route('license.activate')
                ->with('error', 'Please activate your license first.');
        }

        $machineId = hash('sha256', gethostname() . '|' . php_uname('m'));
        $result = $this->licenses->validate($licenseKey, $machineId);

        if (! $result['valid']) {
            abort(403, 'Your license is no longer valid: ' . ($result['message'] ?? 'unknown reason'));
        }

        return $next($request);
    }
}
Don't call the API on every request

The middleware above hits the API on every page load. Wrap the validate() call with the file-based cache from section 9 (or Laravel's own Cache::remember()) and check every 10–15 minutes instead.

5. Routes

routes/web.php
<?php

use App\Http\Controllers\DashboardController;
use App\Http\Controllers\LicenseController;
use App\Http\Middleware\CheckLicense;

Route::get('/activate', [LicenseController::class, 'showForm'])
    ->name('license.activate');
Route::post('/activate', [LicenseController::class, 'activate'])
    ->name('license.store');

// Everything below requires a valid license
Route::middleware([CheckLicense::class])->group(function () {
    Route::get('/dashboard', [DashboardController::class, 'index'])
        ->name('dashboard');
});

06WordPress Plugin

Shipping a premium plugin or theme? Here's a complete, minimal licensing layer you can paste into your main plugin file. It covers the activation hook, a settings page, and an AJAX-powered "Activate" button.

1. Bootstrap the plugin

wp-content/plugins/my-premium-plugin/my-premium-plugin.php
<?php
/**
 * Plugin Name: My Premium Plugin
 * Description: Does amazing things, once a valid license is present.
 * Version: 1.0.0
 */

if (! defined('ABSPATH')) {
    exit; // No direct access.
}

require_once plugin_dir_path(__FILE__) . 'UnifiedLicensing.PHP.v4.0-Complete.php';

define('MPL_API_KEY', 'ul_live_YOUR_API_KEY');
define('MPL_PRODUCT_KEY', 'your-product-key');

// Stable machine identity for this WordPress install.
function mpl_machine_id()
{
    return hash('sha256', wp_salt('auth') . '|' . home_url());
}

function mpl_manager()
{
    static $manager = null;
    if (null === $manager) {
        $manager = new UnifiedLicenseManager(MPL_API_KEY, MPL_PRODUCT_KEY);
    }
    return $manager;
}

function mpl_license_key()
{
    return get_option('mpl_license_key', '');
}

// Gate your premium features behind this helper.
function mpl_is_licensed()
{
    $cached = get_transient('mpl_is_licensed');
    if (false !== $cached) {
        return (bool) $cached;
    }

    $key = mpl_license_key();
    if ('' === $key) {
        return false;
    }

    $result = mpl_manager()->validate($key, mpl_machine_id());
    $licensed = ! empty($result['valid']);

    set_transient('mpl_is_licensed', $licensed ? 1 : 0, 15 * MINUTE_IN_SECONDS);

    return $licensed;
}

2. Activation hook + admin settings page

// When the plugin is activated, validate whatever key was previously saved.
register_activation_hook(__FILE__, function () {
    $key = mpl_license_key();
    if ('' !== $key) {
        $result = mpl_manager()->validate($key, mpl_machine_id());
        set_transient('mpl_is_licensed', empty($result['valid']) ? 0 : 1, 15 * MINUTE_IN_SECONDS);
    }
});

add_action('admin_menu', function () {
    add_options_page(
        'My Premium Plugin License',
        'My Plugin License',
        'manage_options',
        'mpl-license',
        'mpl_render_settings_page'
    );
});

add_action('admin_init', function () {
    register_setting('mpl_license_group', 'mpl_license_key', [
        'type'              => 'string',
        'sanitize_callback' => function ($value) {
            return strtoupper(sanitize_text_field($value));
        },
    ]);
});

function mpl_render_settings_page()
{
    $savedKey = mpl_license_key();
    ?>
    <div class="wrap">
        <h1>My Premium Plugin — License</h1>

        <p id="mpl-status">
            <?php echo mpl_is_licensed()
                ? '<span style="color:green">✓ License active.</span>'
                : '<span style="color:#b5502e">No valid license — premium features are disabled.</span>'; ?>
        </p>

        <form method="post" action="options.php" id="mpl-form">
            <?php settings_fields('mpl_license_group'); ?>
            <table class="form-table">
                <tr>
                    <th><label for="mpl_license_key">License key</label></th>
                    <td>
                        <input type="text" id="mpl_license_key" name="mpl_license_key"
                               value="<?php echo esc_attr($savedKey); ?>"
                               class="regular-text" placeholder="ABCD-EFGH-IJKL-MNOP">
                        <button type="button" class="button button-secondary" id="mpl-check">
                            Check license
                        </button>
                    </td>
                </tr>
            </table>
            <?php submit_button('Save License Key'); ?>
        </form>
    </div>
    <?php
}

3. AJAX validation endpoint

add_action('wp_ajax_mpl_check_license', function () {
    check_ajax_referer('mpl_nonce', 'nonce');

    if (! current_user_can('manage_options')) {
        wp_send_json_error(['message' => 'Not allowed.'], 403);
    }

    // Read the key straight from the form, even if it isn't saved yet.
    $key = isset($_POST['license_key'])
        ? strtoupper(sanitize_text_field(wp_unslash($_POST['license_key'])))
        : mpl_license_key();

    if ('' === $key) {
        wp_send_json_error(['message' => 'Enter a license key first.']);
    }

    try {
        $result = mpl_manager()->validate($key, mpl_machine_id());
    } catch (Exception $e) {
        wp_send_json_error(['message' => 'Could not reach the license server.']);
    }

    delete_transient('mpl_is_licensed'); // force a fresh check next time

    wp_send_json([
        'valid'   => (bool) ($result['valid'] ?? false),
        'message' => $result['valid']
            ? "License active — plan: {$result['plan']}"
            : ($result['message'] ?? 'License is not valid.'),
    ]);
});

// Tiny bit of JS to wire up the "Check license" button.
add_action('admin_footer-options_page_mpl-license', function () {
    ?>
    <script>
    document.getElementById('mpl-check').addEventListener('click', function () {
        var btn = this;
        var status = document.getElementById('mpl-status');
        btn.disabled = true;
        btn.textContent = 'Checking…';

        var body = new FormData();
        body.append('action', 'mpl_check_license');
        body.append('nonce', '<?php echo esc_js(wp_create_nonce('mpl_nonce')); ?>');
        body.append('license_key', document.getElementById('mpl_license_key').value);

        fetch(<?php echo wp_json_encode(admin_url('admin-ajax.php')); ?>, { method: 'POST', body: body })
            .then(function (r) { return r.json(); })
            .then(function (res) {
                status.innerHTML = res.success && res.data.valid
                    ? '<span style="color:green">✓ ' + res.data.message + '</span>'
                    : '<span style="color:#b5502e">' + res.data.message + '</span>';
            })
            .finally(function () {
                btn.disabled = false;
                btn.textContent = 'Check license';
            });
    });
    </script>
    <?php
});
Why AJAX instead of validating on save?

You absolutely can validate inside sanitize_callback. The AJAX route gives instant feedback without saving an invalid key, and it doubles as a "re-check" button customers love when they've just upgraded their plan.

07Server-Side Validation

If your product has a frontend (a web dashboard, a SaaS panel, an Electron wrapper talking to your API), the golden rule is: the browser talks to you, you talk to UnifiedLicensing.

Browser                    Your PHP backend                  UnifiedLicensing API
  |                              |                                   |
  |--- POST /api/license/check-->|                                   |
  |                              |--- POST /validate-license ------->|
  |                              |<--- { valid: true, plan: "pro" }--|
  |<-- { licensed: true } -------|                                   |

The API key only ever travels along the second hop. The browser never sees it.

A thin JSON endpoint

public/api/license-check.php
<?php
declare(strict_types=1);

require_once __DIR__ . '/../../lib/UnifiedLicensing.PHP.v4.0-Complete.php';

header('Content-Type: application/json');

// Only accept POSTs from your own frontend
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    echo json_encode(['error' => 'Method not allowed']);
    exit;
}

$input = json_decode(file_get_contents('php://input'), true) ?? [];
$licenseKey = strtoupper(trim($input['license_key'] ?? ''));

if ($licenseKey === '') {
    http_response_code(400);
    echo json_encode(['error' => 'license_key is required']);
    exit;
}

$manager = new UnifiedLicenseManager('ul_live_YOUR_API_KEY', 'your-product-key');

// Identify the *customer's* browser install, not your server.
// A cookie set at first visit works well as a lightweight machine ID.
if (empty($_COOKIE['install_id'])) {
    setcookie('install_id', bin2hex(random_bytes(16)), ['httponly' => true, 'samesite' => 'Lax']);
}
$machineId = $_COOKIE['install_id'];

try {
    $result = $manager->validate($licenseKey, $machineId);
} catch (Exception $e) {
    http_response_code(503);
    echo json_encode(['error' => 'License service temporarily unavailable']);
    exit;
}

// Return ONLY what the frontend needs — never forward raw API responses.
echo json_encode([
    'licensed'      => (bool) ($result['valid'] ?? false),
    'plan'          => $result['plan'] ?? null,
    'reason'        => $result['valid'] ? null : ($result['message'] ?? 'invalid'),
]);

And the frontend side is a boring little fetch:

assets/app.js
async function checkLicense(key) {
    const res = await fetch('/api/license-check.php', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ license_key: key })
    });
    return res.json();   // { licensed: true, plan: "pro", reason: null }
}
Sanitize your responses

Notice the endpoint above builds its own JSON instead of echoing the SDK result. Get in the habit of returning exactly the fields your UI needs — it keeps your contract stable and leaks nothing by accident.

08Trials & Heartbeats

Start a trial

Trial keys are generated by the API and tied to a machine ID, so the same visitor can't farm unlimited trials by clearing cookies (well, they can clear cookies — but the trial is still burned server-side).

<?php
session_start();

$manager = new UnifiedLicenseManager('ul_live_YOUR_API_KEY', 'your-product-key');
$machineId = hash('sha256', php_uname('n'));

try {
    $trial = $manager->startTrial($machineId);

    if ($trial['success']) {
        // Store the token — you need it for every future status check.
        $_SESSION['ul_trial_token'] = $trial['trial_token'];
        echo "Trial started! {$trial['days_remaining']} days remaining.";
    } else {
        echo "Trial unavailable: " . $trial['message'];
        // Typical reasons: this machine already used its trial,
        // or the product doesn't have trials enabled.
    }
} catch (Exception $e) {
    echo "Couldn't reach the license server.";
}

Check trial status

<?php
session_start();

$token = $_SESSION['ul_trial_token'] ?? null;

if ($token) {
    $status = $manager->checkTrial($token, $machineId);

    if ($status['valid']) {
        echo "Trial active — {$status['days_remaining']} days left.";
    } elseif ($status['expired']) {
        echo "Trial ended. Time to upgrade!";
        // Show your pricing page here.
    } else {
        echo "No active trial for this machine.";
    }
}

Heartbeats

A heartbeat is a periodic "still alive" ping. It keeps the machine marked as active, lets you detect revoked or refunded licenses within minutes instead of never, and gives you honest usage counts. Once an hour is plenty.

cron/heartbeat.php
<?php
// Run hourly:  0 * * * * php /var/www/your-app/cron/heartbeat.php

require_once __DIR__ . '/../lib/UnifiedLicensing.PHP.v4.0-Complete.php';
require_once __DIR__ . '/../config/settings.php';   // loads $licenseKey

$manager = new UnifiedLicenseManager('ul_live_YOUR_API_KEY', 'your-product-key');
$machineId = hash('sha256', php_uname('n') . '|' . php_uname('m'));

try {
    $result = $manager->heartbeat($licenseKey, $machineId);

    if (isset($result['valid']) && ! $result['valid']) {
        // License was revoked, refunded, or expired since our last check.
        error_log("License revoked during heartbeat — locking features.");
        // Disable premium features, notify the admin, etc.
    }
} catch (Exception $e) {
    // Network hiccup — not fatal. Next hour's beat will retry.
    error_log("Heartbeat failed: " . $e->getMessage());
}
WordPress alternative

On WordPress, schedule the heartbeat with wp_schedule_event(time(), 'hourly', 'mpl_send_heartbeat') instead of a system cron — same SDK call inside the handler.

09Caching

Every validate() is an HTTPS round-trip. For a gate you check on every request, that's wasteful and adds latency. Cache the result for a few minutes and everyone's happy — your users get fast pages, and the API gets a sane request volume.

File-based cache (works everywhere)

lib/license-cache.php
<?php

function ul_cache_path(string $key): string
{
    return sys_get_temp_dir() . '/ul_' . md5($key) . '.cache';
}

function ul_cache_get(string $key, int $maxAgeSeconds)
{
    $file = ul_cache_path($key);
    if (! is_file($file)) {
        return null;
    }
    if (time() - filemtime($file) > $maxAgeSeconds) {
        unlink($file);           // stale — pretend it's not there
        return null;
    }
    $raw = file_get_contents($file);
    return $raw === false ? null : json_decode($raw, true);
}

function ul_cache_set(string $key, array $data): void
{
    file_put_contents(ul_cache_path($key), json_encode($data), LOCK_EX);
}

function ul_cache_clear(string $key): void
{
    if (is_file(ul_cache_path($key))) {
        unlink(ul_cache_path($key));
    }
}

Wrap your validation

<?php
require_once __DIR__ . '/lib/UnifiedLicensing.PHP.v4.0-Complete.php';
require_once __DIR__ . '/lib/license-cache.php';

function licensed_check(UnifiedLicenseManager $manager, string $licenseKey, string $machineId): array
{
    $cacheKey = "validate|{$licenseKey}|{$machineId}";

    // Serve from cache for 10 minutes...
    $cached = ul_cache_get($cacheKey, 600);
    if ($cached !== null) {
        return $cached;
    }

    $result = $manager->validate($licenseKey, $machineId);

    // ...but only cache *positive* results, and only briefly.
    // Invalid licenses should always be re-checked (the customer might
    // have just renewed, and you want that reflected immediately).
    if (! empty($result['valid'])) {
        ul_cache_set($cacheKey, $result);
    }

    return $result;
}

When to bypass the cache

On WordPress?

Skip the DIY file cache and use transients — WordPress already handles storage and expiry for you: set_transient('mpl_check_' . md5($key), $result, 10 * MINUTE_IN_SECONDS) and delete_transient(...) to bust it. You saw this pattern in section 6.

10Complete Example

Here's a self-contained page you can drop into any PHP app. It has a license form, activation, live status, and trial support — everything wired end to end. Save it as license.php, fill in your keys, and visit it in a browser.

license.php
<?php
session_start();

require_once __DIR__ . '/lib/UnifiedLicensing.PHP.v4.0-Complete.php';

const UL_API_KEY     = 'ul_live_YOUR_API_KEY';
const UL_PRODUCT_KEY = 'your-product-key';

function machine_id(): string
{
    return hash('sha256', php_uname('n') . '|' . php_uname('m'));
}

$manager     = new UnifiedLicenseManager(UL_API_KEY, UL_PRODUCT_KEY);
$message     = '';
$messageType = 'ok';                      // 'ok' or 'err' — styles the banner
$licenseKey  = $_SESSION['ul_license'] ?? '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $action   = $_POST['action'] ?? '';
    $inputKey = strtoupper(trim($_POST['license_key'] ?? ''));

    try {
        switch ($action) {
            case 'activate':
                $result = $manager->activate($inputKey, machine_id());
                if (! empty($result['valid'])) {
                    $_SESSION['ul_license'] = $inputKey;
                    $licenseKey = $inputKey;
                    $message = "License activated — plan: {$result['plan']}";
                } else {
                    $message = $result['message'] ?? 'Activation failed.';
                    $messageType = 'err';
                }
                break;

            case 'check':
                $keyToCheck = $licenseKey !== '' ? $licenseKey : $inputKey;
                $result = $manager->validate($keyToCheck, machine_id());
                $message = ! empty($result['valid'])
                    ? "License is {$result['status']} — plan: {$result['plan']}"
                    : ($result['message'] ?? 'License is not valid.');
                $messageType = ! empty($result['valid']) ? 'ok' : 'err';
                break;

            case 'trial':
                $result = $manager->startTrial(machine_id());
                if (! empty($result['success'])) {
                    $message = "Trial started — {$result['days_remaining']} days remaining.";
                } else {
                    $message = $result['message'] ?? 'Trial unavailable.';
                    $messageType = 'err';
                }
                break;
        }
    } catch (Exception $e) {
        $message = 'Could not reach the license server. Try again shortly.';
        $messageType = 'err';
    }
}

// Current status banner (live check — this page IS the license admin)
$status = null;
if ($licenseKey !== '') {
    try {
        $status = $manager->validate($licenseKey, machine_id());
    } catch (Exception $e) {
        $status = null;
    }
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>License Management</title>
<style>
    body { font-family: system-ui, sans-serif; background: #081220; color: #EFEAE0;
           display: flex; justify-content: center; padding: 48px 20px; }
    .card { background: #14263B; border: 1px solid rgba(199,163,76,.25);
            border-radius: 12px; padding: 32px; max-width: 480px; width: 100%; }
    h1 { color: #E4C468; font-size: 1.4rem; margin-bottom: 6px; }
    .sub { color: #A9B2C3; font-size: .9rem; margin-bottom: 22px; }
    input[type=text] { width: 100%; padding: 10px 12px; border-radius: 8px;
           border: 1px solid rgba(199,163,76,.3); background: #0E1B2C;
           color: #EFEAE0; font-family: monospace; margin-bottom: 14px; }
    .row { display: flex; gap: 8px; flex-wrap: wrap; }
    button { flex: 1; padding: 10px; border-radius: 8px; cursor: pointer;
             border: 1px solid rgba(199,163,76,.4); background: transparent;
             color: #E4C468; font-weight: 600; }
    button.primary { background: #C7A34C; color: #081220; }
    .msg { margin-top: 16px; padding: 10px 14px; border-radius: 8px; font-size: .9rem; }
    .msg.ok  { background: rgba(126,201,143,.12); color: #7ec98f; }
    .msg.err { background: rgba(181,80,46,.15);  color: #e08a63; }
    .status { margin-top: 22px; padding: 14px 16px; border-radius: 8px;
              background: #0E1B2C; border: 1px solid rgba(199,163,76,.2);
              font-size: .88rem; line-height: 1.7; }
    .status b { color: #E4C468; }
</style>
</head>
<body>
<div class="card">
    <h1>🔒 License Management</h1>
    <p class="sub">Enter the license key from your purchase email.</p>

    <form method="post">
        <input type="text" name="license_key"
               value="<?php echo htmlspecialchars($licenseKey); ?>"
               placeholder="ABCD-EFGH-IJKL-MNOP" autocomplete="off">
        <div class="row">
            <button type="submit" name="action" value="activate" class="primary">Activate</button>
            <button type="submit" name="action" value="check">Check Status</button>
            <button type="submit" name="action" value="trial">Start Trial</button>
        </div>
    </form>

    <?php if ($message !== ''): ?>
        <div class="msg <?php echo $messageType; ?>">
            <?php echo htmlspecialchars($message); ?>
        </div>
    <?php endif; ?>

    <?php if (is_array($status)): ?>
        <div class="status">
            <?php if (! empty($status['valid'])): ?>
                <b>✓ Licensed</b><br>
                Plan: <b><?php echo htmlspecialchars((string) $status['plan']); ?></b><br>
                Seats: <?php echo (int) $status['quota_used']; ?> /
                <?php echo (int) $status['quota_limit']; ?><br>
                Expires:
                <?php echo htmlspecialchars((string) ($status['expires_at'] ?? 'never')); ?>
            <?php else: ?>
                <b style="color:#e08a63">✗ Not licensed</b><br>
                <?php echo htmlspecialchars((string) ($status['message'] ?? 'Unknown reason')); ?>
            <?php endif; ?>
        </div>
    <?php endif; ?>
</div>
</body>
</html>

What this little page demonstrates:

11Troubleshooting

The stuff that actually bites people, and how to fix each one fast.

cURL error 60: SSL certificate problem

Your PHP is missing an up-to-date CA certificate bundle — very common on Windows dev machines and older distros. Download cacert.pem from the cURL website, save it somewhere permanent, and point php.ini at it:

php.ini
curl.cainfo = "C:\php\extras\ssl\cacert.pem"
openssl.cafile = "C:\php\extras\ssl\cacert.pem"

Restart PHP (or Apache/php-fpm) and the error disappears. Don't disable SSL verification as a workaround — that defeats the point of license validation.

"Invalid API key" even though you copied it correctly

Three usual suspects: a stray space when pasting (trim it), using a ul_test_ key against production data (or vice versa), or the key was revoked and regenerated in the dashboard. Re-copy from Settings → API Keys and confirm the prefix matches your environment.

"Machine limit exceeded" on activation

The license has used all its seats. Each machine_id counts as one seat, so make sure your machine ID is stable — if you regenerate it per request (e.g., random_bytes()), you'll burn seats instantly. Free up seats in the vendor dashboard, or have the customer deactivate an old machine.

HTTP 429 — rate limited

You're hammering the API. This almost always means validation runs on every request with no caching. Add the cache wrapper from section 9, move heartbeats to cron, and add a small exponential backoff around retries.

Class "UnifiedLicenseManager" not found

The SDK file wasn't loaded. Either the require_once path is wrong (use __DIR__-based paths, never relative guesses) or Composer's autoload doesn't know about the file yet — run composer dump-autoload after editing composer.json.

Works locally, times out on the production host

Plenty of shared hosts block arbitrary outbound connections. Test connectivity from the server:

php -r "var_dump(@file_get_contents('https://api.unifiedlicensing.com/api/v1'));"

If it fails, ask your host to allow outbound HTTPS to api.unifiedlicensing.com on port 443, or whitelist the domain in your firewall/security plugin.

Trial says "already started" but the user swears it's their first time

Trials are bound to the machine_id server-side, so clearing cookies or reinstalling the browser won't reset them. If a legitimate customer got burned (new PC, wiped server), reset the trial for their machine from the vendor dashboard.

Heartbeats silently stop

Ninety percent of the time the cron daemon died or the path in crontab is wrong. Check grep CRON /var/log/syslog (Debian/Ubuntu) and run the script manually as the same user cron uses — permission differences are sneaky. On WordPress, install WP Crontrol to see whether your scheduled event is actually registered.

Expired licenses still passing validation

You're serving a cached valid result past its prime. Keep TTLs short (10–15 minutes max), never cache negative results longer than a minute, and always do a live check before unlocking anything expensive.

Still stuck?

Grab the exact request/response pair from your logs and email support@unifiedlicensing.com — include your product key (never your API key) and we'll sort it out quickly.