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/healthor/system/stats.
Do not use them from a browser. Anything that ships JS to the user belongs on the JWT surface.
Base URL
Section titled “Base URL”https://your-panel-host/api/v1/externalAuthentication header
Section titled “Authentication header”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.
Scopes
Section titled “Scopes”Every key has a scope (the scopes field) that gates which verbs it can use. Default is read.
| Scope | Verbs allowed | Typical use |
|---|---|---|
read | GET only | Read-only sync, monitoring, BI dashboards |
write | GET + POST + PUT | Provisioning, recurring billing |
delete | GET + POST + PUT + DELETE | Full lifecycle, including teardown |
Scope is enforced per request — a read key calling POST /api/v1/external/subscribers returns 403 with code SCOPE_REQUIRED.
Key ownership & data visibility
Section titled “Key ownership & data visibility”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.
Key management (JWT surface)
Section titled “Key management (JWT surface)”Keys themselves are managed via the JWT-authenticated admin endpoints under /api/api-keys. Only admins can create or revoke them.
POST /api/api-keys
Section titled “POST /api/api-keys”Mint a new key. The plaintext value is returned once.
Permission required: admin.
Request
POST /api/api-keysAuthorization: Bearer <admin-jwt>Content-Type: application/json| Field | Type | Required | Description |
|---|---|---|---|
name | string | no | Human label (defaults to "API Key") |
scopes | string | no | read, write, or delete (defaults to read) |
expires_at | string | no | YYYY-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.
GET /api/api-keys
Section titled “GET /api/api-keys”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 } ]}DELETE /api/api-keys/:id
Section titled “DELETE /api/api-keys/:id”Revoke a key (is_active=false, soft-deleted so the audit trail remains). Any subsequent request with that key returns 401 API_KEY_REVOKED.
GET /api/api-keys/stats
Section titled “GET /api/api-keys/stats”Total / active / revoked counts plus calls in the last 24 h.
GET /api/api-keys/:id/logs
Section titled “GET /api/api-keys/:id/logs”Recent calls made with this key (last 200 by default): timestamp, path, status code, IP, latency.
External endpoints
Section titled “External endpoints”Once you have a key, the external surface offers a focused subset of the panel:
Subscribers
Section titled “Subscribers”| Method | Path | Required scope |
|---|---|---|
| GET | /api/v1/external/subscribers | read |
| GET | /api/v1/external/subscribers/:id | read |
| GET | /api/v1/external/subscribers/by-username/:username | read |
| POST | /api/v1/external/subscribers | write |
| PUT | /api/v1/external/subscribers/:id | write |
| DELETE | /api/v1/external/subscribers/:id | delete |
| POST | /api/v1/external/subscribers/:id/suspend | write |
| POST | /api/v1/external/subscribers/:id/activate | write |
| GET | /api/v1/external/subscribers/:id/usage | read |
Services / NAS / transactions / system
Section titled “Services / NAS / transactions / system”| Method | Path | Required scope |
|---|---|---|
| GET | /api/v1/external/services | read |
| GET | /api/v1/external/services/:id | read |
| GET | /api/v1/external/nas | read |
| GET | /api/v1/external/nas/:id | read |
| GET | /api/v1/external/transactions | read |
| POST | /api/v1/external/transactions | write |
| GET | /api/v1/external/system/stats | read |
| 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.
Example — create a subscriber
Section titled “Example — create a subscriber”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" }'Errors
Section titled “Errors”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"}| Status | error.code | Cause |
|---|---|---|
| 401 | MISSING_API_KEY | No X-API-Key header |
| 401 | INVALID_API_KEY | Hash not found |
| 401 | API_KEY_REVOKED | Key was revoked (is_active=false) |
| 401 | API_KEY_EXPIRED | Past expires_at |
| 401 | INVALID_USER | Key owner no longer exists |
| 403 | SCOPE_REQUIRED | Verb not allowed by the key’s scope |
| 429 | RATE_LIMIT_EXCEEDED | Per-key rate limit hit (see below) |
Rate limits
Section titled “Rate limits”| Limit | Scope |
|---|---|
| Per-key limit (per minute) | Each key independently |
Counted in api_key_logs | Every 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.
Key rotation
Section titled “Key rotation”There is no key-rotation endpoint. To rotate without downtime:
- Create a new key (
POST /api/api-keys) with the same scope. - Update the integration’s config to the new key.
- Confirm calls are flowing in via
GET /api/api-keys/:new_id/logs. - Revoke the old key (
DELETE /api/api-keys/:old_id).
Security notes
Section titled “Security notes”- 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.
Related pages
Section titled “Related pages”- Authentication — JWT surface for interactive sessions
- Endpoints — Subscribers — payloads for
/external/subscribers/* - Endpoints — Services — for
/external/services