Skip to content

External API Keys

External API keys are long-lived credentials for non-interactive integrations — billing systems, helpdesks, monitoring, CRM bridges. They authenticate against a separate surface (/api/v1/external/*) that does not use JWT and does not require a refresh dance.

Use them when:

  • A backend service needs to read or write subscribers without a user interactively logging in.
  • A third-party platform needs to push transactions or subscribers in.
  • A monitoring stack needs to scrape /system/health or /system/stats.

Do not use them from a browser. Anything that ships JS to the user belongs on the JWT surface.

https://your-panel-host/api/v1/external

Every request must include the key in the X-API-Key header:

X-API-Key: pk_live_<32 hex chars>

The key format is pk_live_ followed by 32 hexadecimal characters (16 random bytes). The full value is shown to the operator once at creation time and never again — only the SHA-256 hash is stored in the api_keys table.

Every key has a scope (the scopes field) that gates which verbs it can use. Default is read.

ScopeVerbs allowedTypical use
readGET onlyRead-only sync, monitoring, BI dashboards
writeGET + POST + PUTProvisioning, recurring billing
deleteGET + POST + PUT + DELETEFull lifecycle, including teardown

Scope is enforced per request — a read key calling POST /api/v1/external/subscribers returns 403 with code SCOPE_REQUIRED.

Each key is owned by the admin user who created it and inherits that user’s data visibility. An admin-owned key sees all data, exactly as that admin would on the JWT surface. There is no separate reseller_id field on the key — if you need an integration isolated to a subset of data, create it under a dedicated user account with the appropriate permissions.

Keys themselves are managed via the JWT-authenticated admin endpoints under /api/api-keys. Only admins can create or revoke them.

Mint a new key. The plaintext value is returned once.

Permission required: admin.

Request

POST /api/api-keys
Authorization: Bearer <admin-jwt>
Content-Type: application/json
FieldTypeRequiredDescription
namestringnoHuman label (defaults to "API Key")
scopesstringnoread, write, or delete (defaults to read)
expires_atstringnoYYYY-MM-DD — omit for no expiry

Response — 201 Created

{
"success": true,
"key": "pk_live_a8f3kj92lkj3a8f3kj92lkj3a8f3kj92",
"data": {
"id": 14,
"name": "Stripe sync",
"key_prefix": "pk_live_a8f3",
"scopes": "write",
"expires_at": null,
"created_at": "2026-05-12T10:30:00Z"
}
}

Save the key field — it is never returned again. Only key_prefix (for display) and the hash are kept.

List keys (prefixes/hashes only, no plaintext).

Permission required: admin.

{
"success": true,
"data": [
{
"id": 14,
"name": "Stripe sync",
"key_prefix": "pk_live_a8f3",
"scopes": "write",
"last_used_at": "2026-05-12T09:55:11Z",
"expires_at": null,
"is_active": true
}
]
}

Revoke a key (is_active=false, soft-deleted so the audit trail remains). Any subsequent request with that key returns 401 API_KEY_REVOKED.

Total / active / revoked counts plus calls in the last 24 h.

Recent calls made with this key (last 200 by default): timestamp, path, status code, IP, latency.

Once you have a key, the external surface offers a focused subset of the panel:

MethodPathRequired scope
GET/api/v1/external/subscribersread
GET/api/v1/external/subscribers/:idread
GET/api/v1/external/subscribers/by-username/:usernameread
POST/api/v1/external/subscriberswrite
PUT/api/v1/external/subscribers/:idwrite
DELETE/api/v1/external/subscribers/:iddelete
POST/api/v1/external/subscribers/:id/suspendwrite
POST/api/v1/external/subscribers/:id/activatewrite
GET/api/v1/external/subscribers/:id/usageread
MethodPathRequired scope
GET/api/v1/external/servicesread
GET/api/v1/external/services/:idread
GET/api/v1/external/nasread
GET/api/v1/external/nas/:idread
GET/api/v1/external/transactionsread
POST/api/v1/external/transactionswrite
GET/api/v1/external/system/statsread
GET/api/v1/external/system/health(no key needed)

/system/health is intentionally unauthenticated so a Prometheus or Pingdom probe can read it without holding a credential.

Terminal window
curl -X POST https://panel.example.com/api/v1/external/subscribers \
-H "X-API-Key: pk_live_a8f3kj92lkj3a8f3kj92lkj3a8f3kj92" \
-H "Content-Type: application/json" \
-d '{
"username": "user1234@example.lb",
"password": "auto-generate",
"service_id": 4,
"full_name": "John Customer",
"phone": "+96170123456",
"expiry_date": "2026-12-31"
}'

The external surface returns a structured error envelope (note this differs from the flat {success, message} of the JWT surface):

{
"success": false,
"error": { "code": "INVALID_API_KEY", "message": "Invalid API key" },
"timestamp": "2026-05-12T11:02:31Z"
}
Statuserror.codeCause
401MISSING_API_KEYNo X-API-Key header
401INVALID_API_KEYHash not found
401API_KEY_REVOKEDKey was revoked (is_active=false)
401API_KEY_EXPIREDPast expires_at
401INVALID_USERKey owner no longer exists
403SCOPE_REQUIREDVerb not allowed by the key’s scope
429RATE_LIMIT_EXCEEDEDPer-key rate limit hit (see below)
LimitScope
Per-key limit (per minute)Each key independently
Counted in api_key_logsEvery request, regardless of status

Per-key limits are independent of the global per-IP limit — if several services share an outbound NAT, each can still get its own budget by using its own key.

There is no key-rotation endpoint. To rotate without downtime:

  1. Create a new key (POST /api/api-keys) with the same scope.
  2. Update the integration’s config to the new key.
  3. Confirm calls are flowing in via GET /api/api-keys/:new_id/logs.
  4. Revoke the old key (DELETE /api/api-keys/:old_id).
  • Keys are stored as SHA-256 hashes. A database leak does not leak the plaintext.
  • The plaintext is shown once at creation. If lost, revoke and re-create.
  • Use a separate key per integration. If one leaks, revoke it without taking the others down.
  • Treat keys like passwords in your secret store. Never commit to git, never put in a frontend env.