API Credentials

12 views Markdown

The seven endpoints that produce API access keys, limit them and watch their requests.

Overview

These seven endpoints run who can reach the API. They produce keys, set what those keys allow, limit where they work from, and show who called what.

The key itself is stored as a digest. The full value appears once, on the creation response, and no endpoint returns it afterwards. A lost key is not recovered, it is replaced.

Permissions are written as scopes: a single operation, or a wildcard for a whole resource. Giving a key only the scopes it truly needs is the one real piece of advice in this article.

Every key belongs to the staff account that made it. A key can never do more than that account may do in the panel. The check runs on every request, so narrowing someone's privileges narrows their keys at the same moment.

Reference

Listing the Keys

get/api/v1/admin/settings/api-credentials
Settings/GetApiCredentials admin returned masked

Returns the keys that grant access to this API.

Query parameters 3
pageintDefaults to 1.
limitintDefaults to 25, maximum 100.
searchstringSearches the keys.
Response fields data[] — 11
idintId of the credential.
namestringThe key's name. For you only, so you remember where it is used.
typestringWho made it: admin for staff, client for a customer. Customer keys are managed from the client area.
ownerobjectThe account the key belongs to, as id and name. Its privileges cap what the key can reach.
token_previewstringThe key, masked. The full value cannot be recovered; only a digest is stored.
permissionsstring[]The scopes allowed. A single operation, or a wildcard for a whole resource.
ipsstring[]The addresses allowed. Empty means it works from anywhere.
rate_limitintThe per-minute request limit for this key. Zero uses the general default.
created_atstring | nullWhen it was created.
updated_atstring | nullWhen it last changed.
last_accessstring | nullWhen it was last used. Empty means it never has been.
Meta 4
totalintHow many keys there are.
pageintThe page you are on.
limitintThe page size.
next_pageintThe next page. Zero means you are on the last one.
Errors 1
insufficient_scope403The key lacks the required scope.
Request
curl 'https://panel.example.com/api/v1/admin/settings/api-credentials' \
  -H "Authorization: Bearer $API_KEY"
const res  = await fetch('https://panel.example.com/api/v1/admin/settings/api-credentials', {
  headers: { Authorization: `Bearer ${apiKey}` },
});
const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/settings/api-credentials');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . $apiKey],
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
// Keys never used are candidates for cleanup: an empty last-access means nobody has touched it.
$keys   = Api::Settings()->GetApiCredentials()['data'];
$unused = array_filter($keys, fn (array $k): bool => $k['last_access'] === null);

Creating a Key

post/api/v1/admin/settings/api-credentials
Settings/CreateApiCredential admin the key is shown once

Produces a new access key. The full value comes back on this response only.

Body 4
namestringrequiredThe key's name.
permissionsstring[]requiredThe scopes to allow. At least one is needed; a wildcard opens every operation on a resource.
ipsstring[] | stringThe addresses to allow. Either an array or text, one per line.
rate_limitintThe per-minute request limit. Zero goes back to the general default.
Response fields data — 10
idintId of the credential.
namestringThe key's name. For you only, so you remember where it is used.
token_previewstringThe key, masked. The full value cannot be recovered; only a digest is stored.
permissionsstring[]The scopes allowed. A single operation, or a wildcard for a whole resource.
ipsstring[]The addresses allowed. Empty means it works from anywhere.
rate_limitintThe per-minute request limit for this key. Zero uses the general default.
created_atstring | nullWhen it was created.
updated_atstring | nullWhen it last changed.
last_accessstring | nullWhen it was last used. Empty means it never has been.
api_keystringThe key in full. It appears here and nowhere else; lose it and the only way back is a new key.
Errors 4
name_required422The name was empty.
permissions_required422No permission was given.
permissions_exceed_owner422None of the scopes you asked for are covered by the owning account's privileges.
insufficient_scope403The key lacks the required scope.
Request
curl -X POST 'https://panel.example.com/api/v1/admin/settings/api-credentials' \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"name":"Integration","permissions":["Clients/*","Services/GetServices"],"ips":[],"rate_limit":300}'
const res  = await fetch('https://panel.example.com/api/v1/admin/settings/api-credentials', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    name: 'Integration',
    permissions: ['Clients/*', 'Services/GetServices'],
    rate_limit: 300,
  }),
});

const body = await res.json();

// Store it now: this is the only time it is shown.
const newKey = body.data.api_key;
$ch = curl_init('https://panel.example.com/api/v1/admin/settings/api-credentials');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'name'        => 'Integration',
        'permissions' => ['Clients/*'],
        'rate_limit'  => 300,
    ]),
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
// The full key comes back HERE only; miss it now and you can never read it again.
$cred = Api::Settings()->CreateApiCredential([
    'name'        => 'Integration',
    'permissions' => ['Clients/*'],
])['data'];

$secret = $cred['api_key'] ?? null;   // absent on every later read
Response
{
  "data": {
    "id": 13,
    "name": "Integration",
    "token_preview": "wak_a1b2c3d4e••••••••",
    "permissions": ["Clients/*"],
    "ips": [],
    "rate_limit": 300,
    "api_key": "wak_a1b2c3d4e5f6..."
  }
}
{
  "error": {
    "code": "permissions_required",
    "message": "At least one permission is required."
  }
}

Key Detail

get/api/v1/admin/settings/api-credentials/{cid}
Settings/GetApiCredential admin

Returns one key. The schema matches a list item and the key is still masked.

Response fields data — 11
idintId of the credential.
namestringThe key's name. For you only, so you remember where it is used.
typestringWho made it: admin for staff, client for a customer. Customer keys are managed from the client area.
ownerobjectThe account the key belongs to, as id and name. Its privileges cap what the key can reach.
token_previewstringThe key, masked. The full value cannot be recovered; only a digest is stored.
permissionsstring[]The scopes allowed. A single operation, or a wildcard for a whole resource.
ipsstring[]The addresses allowed. Empty means it works from anywhere.
rate_limitintThe per-minute request limit for this key. Zero uses the general default.
created_atstring | nullWhen it was created.
updated_atstring | nullWhen it last changed.
last_accessstring | nullWhen it was last used. Empty means it never has been.
Errors 2
not_found404No such credential.
insufficient_scope403The key lacks the required scope.
Request
curl 'https://panel.example.com/api/v1/admin/settings/api-credentials/12' \
  -H "Authorization: Bearer $API_KEY"
const res  = await fetch('https://panel.example.com/api/v1/admin/settings/api-credentials/12', {
  headers: { Authorization: `Bearer ${apiKey}` },
});
const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/settings/api-credentials/12');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . $apiKey],
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
// The detail does NOT give the full key either: a lost key is not recovered, it is replaced.
$cred = Api::Settings()->GetApiCredential(['cid' => 12])['data'];

Updating a Key

patch/api/v1/admin/settings/api-credentials/{cid}
Settings/UpdateApiCredential admin permissions are written whole

Applies the fields you send and leaves the rest as they were: the name, the permissions, the address list and the request limit. The key itself is untouched.

Body 4
namestringThe key's name. Leave it out and the current name stays.
permissionsstring[]The scopes to allow. Leave them out and the current list stays; sending them replaces the set whole, and an empty list is refused. A wildcard opens every operation on a resource.
ipsstring[] | stringThe addresses to allow. Either an array or text, one per line.
rate_limitintThe per-minute request limit. Zero goes back to the general default.
Response fields data — 11
dataobjectThe credential as it now stands. Same shape as an item in the key list; the key itself is unchanged and still masked.
Errors 7
not_found404No such credential.
not_credential_owner403The key belongs to another staff account. A key owned by the root privilege group reaches every credential.
client_credential422The key belongs to a customer and is managed from the client area.
name_required422The name you sent was empty.
permissions_required422The permission list you sent was empty.
permissions_exceed_owner422None of the scopes you asked for are covered by the owning account's privileges.
insufficient_scope403The key lacks the required scope.
Request
curl -X PATCH 'https://panel.example.com/api/v1/admin/settings/api-credentials/12' \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"permissions":["Clients/*","Invoices/*"],"ips":["203.0.113.10"]}'
const res = await fetch('https://panel.example.com/api/v1/admin/settings/api-credentials/12', {
  method: 'PATCH',
  headers: {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    permissions: ['Clients/*', 'Invoices/*'],
    ips: ['203.0.113.10'],
  }),
});

const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/settings/api-credentials/12');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST  => 'PATCH',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'permissions' => ['Clients/*', 'Invoices/*'],
        'ips'         => ['203.0.113.10'],
    ]),
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
// To ADD a permission send the existing list too: the set you send replaces the old one.
$cred  = Api::Settings()->GetApiCredential(['cid' => 12])['data'];
$scope = $cred['permissions'];

$scope[] = 'Invoices/*';

Api::Settings()->UpdateApiCredential(['cid' => 12, 'permissions' => $scope]);

Deleting a Key

delete/api/v1/admin/settings/api-credentials/{cid}
Settings/DeleteApiCredential admin access stops at once

Revokes the key. Every request using it starts being refused immediately.

Response fields data — 2
deletedboolWhether the delete succeeded.
idintId of the deleted key.
Errors 4
not_found404No such credential.
not_credential_owner403The key belongs to another staff account. A key owned by the root privilege group reaches every credential.
client_credential422The key belongs to a customer and is managed from the client area.
insufficient_scope403The key lacks the required scope.
Request
curl -X DELETE 'https://panel.example.com/api/v1/admin/settings/api-credentials/12' \
  -H "Authorization: Bearer $API_KEY"
const res = await fetch('https://panel.example.com/api/v1/admin/settings/api-credentials/12', {
  method: 'DELETE',
  headers: { Authorization: `Bearer ${apiKey}` },
});

const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/settings/api-credentials/12');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST  => 'DELETE',
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . $apiKey],
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
// Deleting your own key cuts THIS session's access too; take care not to lock yourself out.
Api::Settings()->DeleteApiCredential(['cid' => 12]);

Listing the Request Log

get/api/v1/admin/settings/api-logs
Settings/GetApiLogs admin paged

Returns the record of requests that reached the API.

Query parameters 3
pageintDefaults to 1.
limitintDefaults to 25, maximum 100.
searchstringSearches the records.
Response fields data[] — 6
idintId of the record.
credentialobjectThe key that made the request: its id and name.
methodstringThe request method.
actionstringThe operation called.
ipstringThe address the request came from.
created_atstring | nullWhen the request arrived.
Errors 1
insufficient_scope403The key lacks the required scope.
Request
curl 'https://panel.example.com/api/v1/admin/settings/api-logs' \
  -H "Authorization: Bearer $API_KEY"
const res  = await fetch('https://panel.example.com/api/v1/admin/settings/api-logs', {
  headers: { Authorization: `Bearer ${apiKey}` },
});
const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/settings/api-logs');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . $apiKey],
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
// The record does not keep the DATA sent: you see which key called what, not what it sent.
$logs = Api::Settings()->GetApiLogs()['data'];

Clearing the Request Log

delete/api/v1/admin/settings/api-logs
Settings/ClearApiLogs admin takes no date

Deletes every API request record.

Response fields data — 1
clearedboolWhether the clear ran.
Errors 1
insufficient_scope403The key lacks the required scope.
Request
curl -X DELETE 'https://panel.example.com/api/v1/admin/settings/api-logs' \
  -H "Authorization: Bearer $API_KEY"
const res = await fetch('https://panel.example.com/api/v1/admin/settings/api-logs', {
  method: 'DELETE',
  headers: { Authorization: `Bearer ${apiKey}` },
});

const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/settings/api-logs');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST  => 'DELETE',
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . $apiKey],
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
// This endpoint takes NO date: every record goes, there is no selective clear.
Api::Settings()->ClearApiLogs();

Pitfalls

A key follows its owner

Scopes are intersected with the owner's privileges on every request. Take a permission away from that staff account and the key loses it too, with no edit. If the account is deleted or blocked, the key stops working and returns owner_inactive. A key left with nothing its owner may still do returns owner_scope_revoked.

The key is shown once

The full key comes back on the creation response only. The list and detail endpoints give it masked. Since a digest is what gets stored, the server does not know the raw value either. Miss that response and the only move left is to delete the key and make a new one.

The permission list is written whole

The scope list you send on an update replaces the old one. Adding a single permission means reading the current list and appending to it. Otherwise the key quietly loses its access and your integration stops working.

Deleting your own key cuts you off too

The delete takes effect at once, and that includes the key you are using. When tidying the keys of an integration, watch which one your requests carry. Lock yourself out and the only way back is producing a new key from the panel.

An empty address list opens everywhere

A key with an empty address list works from anywhere in the world. On a server-to-server integration, naming that one address stops a leaked key being used at all. That is the cheapest protection after narrowing the scope.

The request log does not keep what was sent

The log shows which key called which operation from which address; it keeps neither the body nor the response. Investigating what a request changed needs the affected record's own history, not this. The clear takes no date either: all of it or none.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.