Extension integration contract

Production domain: https://ghlcloner.autogencrm.com. Base: /api/v1. CORS is restricted to configured Chrome extension IDs and localhost. All extension endpoints return { ok: true, ... } or { ok: false, error: { code, message, details? } }.

Extension lifecycle (summary)

  1. Activate: the Chrome popup asks for a license key only — no account password. It sends the key plus a random per-install device_id (UUID minted by crypto.randomUUID() and persisted in chrome.storage.local) to POST /api/v1/extension/license/activate. The server hashes the key, atomically binds this device to the license (respecting the plan's device limit), and issues a short-lived access token plus a rotating refresh token — exactly the same token shape as the legacy login.
  2. Rotating tokens: every /auth/refresh call issues a new access + refresh pair and revokes the old refresh token; reusing a revoked refresh token revokes all sibling sessions for the device.
  3. Authorize a native clone: extension calls POST /api/v1/extension/usage/authorize with an idempotency key and source/destination funnel-step IDs; the server atomically debits one credit and returns a usage_id.
  4. GHL native clone runs client-side inside the tab. On success, extension calls POST /api/v1/extension/events with type: "clone.completed" and the usage_id to finalize the record.
  5. On GHL failure, extension calls POST /api/v1/extension/usage/reverse with the usage_id to refund the credit (idempotent within 10 minutes).
  6. Deactivate: POST /api/v1/extension/license/deactivate (or /auth/logout) revokes the current session and releases the device slot on this install.

GHL credentials, cookies, and page markup never leave the browser — only IDs, URLs, extension version, and diagnostics are sent to us. Email/password login on the website is for the dashboard only; admin controls appear inside that dashboard for admin users.

Required headers

Device identity

The extension MUST generate a per-install random UUID (crypto.randomUUID()) and persist it in chrome.storage.local. This device_id is sent on /license/activate; the server stores a peppered SHA-256 hash. No hardware fingerprinting.

License activation

License keys are canonical uppercase keys in this format: GHLPC-XXXX-XXXX-XXXX-XXXX-XXXX, using only non-confusing characters (A-Z except I/O, plus 2-9). Users and admins can copy/view the full key only at issue/rotation time in the dashboard; later the dashboard shows only a masked key identifier and offers a secure Check key action for admins that hashes the pasted key locally and verifies it against the stored hash.

POST /api/v1/extension/license/activate

Headers: X-Device-Id: <uuid must equal body.device_id>
{
  "license_key": "GHLPC-XXXX-XXXX-XXXX-XXXX-XXXX",
  "device_id": "<uuid-v4 minted by the extension>",
  "device_label": "Chrome on macOS",
  "extension_version": "1.3.0"
}

Returns access_token (15 min), refresh_token (30 days), session_id, license_id — identical shape to the previous login response, so v1.3 extension storage/refresh/me/usage code needs no changes. Error codes (deliberately generic to prevent enumeration): invalid_license, license_revoked, license_expired, device_limit_reached, pending_approval, account_suspended, device_id_mismatch, rate_limited.

The plaintext license key is shown exactly once at issuance/rotation and never stored on the server — only a SHA-256 hash, the prefix, and last-4 characters are kept for display. Masked dots are identifiers only, not usable activation keys.

POST /api/v1/extension/license/deactivate (auth)

Revokes the current session and releases this device slot from the license. Idempotent.

Auth (legacy — website only)

POST /api/v1/extension/auth/login

Email + password login retained for the website dashboard only. The Chrome extension MUST use /license/activate.

POST /api/v1/extension/auth/refresh

{ "refresh_token": "..." }

Refresh tokens are rotated on every call and preserve the bound license + device. Reusing a revoked token revokes every sibling session for the device. Additional error codes: refresh_revoked, refresh_expired, license_*, device_revoked.

POST /api/v1/extension/auth/logout

Revokes the current session (device binding is preserved).

License & devices

GET /api/v1/extension/license

Returns masked key, status, expiry, max/active devices, current device id.

DELETE /api/v1/extension/devices/current

Revokes the current device binding and all its sessions. POST is accepted as an alias.

GET /api/v1/extension/me

Aggregate: user, subscription with plan (including max_devices), license summary, current device id.

Cloning

POST /api/v1/extension/usage/authorize

{
  "idempotency_key": "uuid-per-clone-attempt",
  "mode": "native",
  "source": { "funnel_id": "...", "step_id": "...", "url": "https://..." },
  "destination": { "funnel_id": "...", "step_id": "..." },
  "extension_version": "1.0.0"
}

Atomically validates active profile, subscription, license, bound device, then debits 1 credit (0 on unlimited plans). Response includes usage_id and remaining balance. Do NOT include GHL tokens, page markup, or funnel bodies — only IDs and URLs.

POST /api/v1/extension/usage/reverse

{ "usage_id": "...", "reason": "ghl-native-clone-failed" }

Idempotent refund within 10 minutes of authorization.

POST /api/v1/extension/events

{ "type": "clone.completed", "usage_id": "...", "metadata": { "duration_ms": 1234 } }

Fallback converter

When native cloning is unavailable, the extension may POST the scraped payload to /api/public/convert with the same Authorization: Bearer access token. Requires an active subscription and consumes one credit.

Standard error codes

missing_token | invalid_token | legacy_token | session_missing | session_revoked | session_expired
account_suspended | license_missing | license_revoked | license_expired | device_revoked | device_limit_reached
invalid_credentials | rate_limited | invalid_json | invalid_body
no_subscription | subscription_inactive | insufficient_credits | reversal_window_expired

Environment