Client Endpoints

16 vues Markdown

The seven endpoints of the client resource: read, create, update, delete, plus credential checks and sign-in. Each one lists the fields you send, the fields you get back and a working sample.

Overview

The client resource is the API side of the panel's Clients screen. It applies the same business rules. The password length and the email uniqueness you meet here are the ones the panel enforces, because the endpoint reuses the handler the panel calls.

All seven endpoints belong to the admin audience and expect the key to carry the matching scope. Scope names are written on each endpoint's identity line below.

The same endpoints are callable from inside WISECP

Writing a module or an addon? You do not have to go out over HTTP. Api::Clients()->GetClients() runs the same endpoint in process and returns the same envelope. The first tab of every sample shows that call.

Reference

Listing Clients

get/api/v1/admin/clients
Clients/GetClients admin model users::list

Lists clients with filtering and pagination. Page size caps at 100; a larger value is quietly reduced.

Query parameters 5
searchstringSearches name and email.
statusstringactive or blocked. Full list: reference/statuses?entity=client.
group_idintClient group id. List: clients/groups.
pageintDefaults to 1.
limitintDefaults to 25, maximum 100.
Response fields data[] — one client
idintClient id.
full_namestringFull name.
company_namestringCompany name; empty for an individual.
emailstringEmail address.
phonestringPhone; digits only are stored.
statusstringactive or blocked.
groupobjectThe group this client belongs to.
idintGroup id.
namestringDisplay name.
languagestringLanguage code.
country_codestringISO country code, for example US.
currency_codestringCurrency code, for example USD.
email_verifiedboolWhether the email is verified.
phone_verifiedboolWhether the phone is verified.
active_servicesintNumber of services in use.
created_atstringCreation time.
last_login_atstringLast sign-in time.
Pagination meta
metaobjectIdentical on every list endpoint.
totalintTotal record count.
pageintCurrent page.
limitintPage size.
next_pageintNext page, or 0.
Errors 1
insufficient_scope403The key lacks the required scope.
Request
curl -G 'https://panel.example.com/api/v1/admin/clients' \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Accept: application/json' \
  -d status=active \
  -d limit=25
const url = new URL('https://panel.example.com/api/v1/admin/clients');
url.searchParams.set('status', 'active');
url.searchParams.set('limit', '25');

const res  = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } });
const body = await res.json();

for (const c of body.data) console.log(c.id, c.full_name);
$url = 'https://panel.example.com/api/v1/admin/clients?' . http_build_query([
    'status' => 'active',
    'limit'  => 25,
]);

$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Accept: application/json',
    ],
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);

foreach ($body['data'] as $client) {
    echo $client['id'], ' ', $client['full_name'], PHP_EOL;
}
// From inside WISECP: no HTTP, same envelope.
$response = Api::Clients()->GetClients([], [
    'status' => 'active',
    'limit'  => 25,
]);

if (isset($response['error'])) {
    Logger::error($response['error']['message']);
    return;
}

foreach ($response['data'] as $client) {
    echo $client['id'], ' ', $client['full_name'], PHP_EOL;
}
Response
{
  "data": [
    {
      "id": 42,
      "full_name": "John Doe",
      "company_name": "",
      "email": "[email protected]",
      "phone": "5550100",
      "status": "active",
      "group": { "id": 1, "name": "Standard" },
      "email_verified": true,
      "phone_verified": false,
      "active_services": 3,
      "created_at": "2026-01-01 10:00:00",
      "last_login_at": "2026-06-20 09:00:00"
    }
  ],
  "meta": { "total": 128, "page": 1, "limit": 25, "next_page": 2 }
}
{
  "error": {
    "code": "insufficient_scope",
    "message": "API key lacks the required scope."
  }
}

Client Detail

get/api/v1/admin/clients/{id}
Clients/GetClient admin model users::get

Returns one client's full profile. Fields the list does not carry arrive here: first and last name separately, the balance, and the country and currency ids.

Path parameter 1
idintrequiredClient id.
Response fields 15
idintClient id.
full_namestringFull name.
namestringFirst name. The list carries only full_name.
surnamestringLast name.
statusstringactive, blocked or cancelled. The list never returns cancelled.
country_idintCountry id. Resolve with reference/countries.
currency_idintCurrency id.
group_idintClient group id. Where the list returns a group object, this returns the id only.
balancefloatAccount balance.
company_namestringCompany name.
emailstringEmail address.
phonestringPhone.
languagestringLanguage code.
created_atstringCreation time.
last_login_atstringLast sign-in time.
Errors 2
not_found404No such client.
insufficient_scope403The key lacks the required scope.
Request
curl 'https://panel.example.com/api/v1/admin/clients/42' \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Accept: application/json'
const res  = await fetch('https://panel.example.com/api/v1/admin/clients/42', {
  headers: { Authorization: `Bearer ${apiKey}` },
});
const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/clients/42');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . $apiKey],
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
$response = Api::Clients()->GetClient(['id' => 42]);

if (isset($response['error'])) {
    // not_found or insufficient_scope
    return false;
}

$client = $response['data'];
echo $client['name'], ' ', $client['surname'], PHP_EOL;

Creating a Client

post/api/v1/admin/clients
Clients/CreateClient admin same handler as the panel

Creates a client and returns the new record in the detail schema. Business rules run in the panel's own handler, so a setting such as password length applies here too.

Body 15 fields, 3 required
full_namestringrequiredFull name.
emailstringrequiredValidated for format and unique across every client in the installation.
passwordstringrequiredAt least options/password-length characters; defaults to 6.
typestringindividual or corporate. Defaults to individual.
phonestringPhone. Digits are kept, formatting is dropped.
languagestringLanguage code. Defaults to general/local.
group_idintClient group. List: clients/groups.
country_codestringISO country code, for example US. Resolve with reference/countries.
currency_codestringCurrency code, for example USD.
companyobjectTax details for a corporate client.
namestringTrading name.
tax_numberstringTax number.
tax_officestringTax office.
identitystringIdentity or tax number.
marketing_notificationsboolTurns marketing notifications on.
verify_emailboolMarks the email as verified.
verify_phoneboolMarks the phone as verified.
send_welcome_emailboolSends the welcome email.
Response fields data — 15
dataobjectThe client that was created, returned with 201. Same shape as the detail endpoint.
Errors 6
full_name_required422Full name was empty.
email_invalid422Email format is not valid.
email_exists422That email is already registered.
password_required422Password was empty.
create_failed500The record could not be created.
insufficient_scope403The key lacks the required scope.
Request
curl -X POST 'https://panel.example.com/api/v1/admin/clients' \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"full_name":"John Doe","email":"[email protected]","password":"Str0ngP@ssw0rd"}'
const res = await fetch('https://panel.example.com/api/v1/admin/clients', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    full_name: 'John Doe',
    email: '[email protected]',
    password: 'Str0ngP@ssw0rd',
  }),
});

const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/clients');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'full_name' => 'John Doe',
        'email'     => '[email protected]',
        'password'  => 'Str0ngP@ssw0rd',
    ]),
]);

$created = json_decode(curl_exec($ch), true);
curl_close($ch);
$response = Api::Clients()->CreateClient([
    'full_name'     => 'John Doe',
    'email'         => '[email protected]',
    'password'      => 'Str0ngP@ssw0rd',
    'type'          => 'individual',
    'country_code'  => 'US',
    'currency_code' => 'USD',
]);

if (isset($response['error'])) {
    // email_exists is the one you will meet most often.
    throw new Exception($response['error']['message']);
}

$clientId = $response['data']['id'];
Response
{
  "data": {
    "id": 43,
    "full_name": "John Doe",
    "name": "John",
    "surname": "Doe",
    "email": "[email protected]",
    "status": "active",
    "country_id": 840,
    "currency_id": 2,
    "group_id": 0,
    "balance": 0.00
  }
}
{
  "error": {
    "code": "email_exists",
    "message": "A client with this email already exists."
  }
}

Updating a Client

patch/api/v1/admin/clients/{id}
Clients/UpdateClient admin partial update

Changes only the fields you send and leaves the rest alone. Every body field is optional.

Body 17 fields, all optional
full_namestringFull name. If sent, it cannot be empty.
emailstringValid and unique email.
statusstringactive, blocked or cancelled.
phonestringMobile phone. Sending it empty clears it.
landline_phonestringLandline. Sending it empty clears it.
typestringindividual or corporate.
identitystringIdentity or tax number.
birthdaystringDate of birth. An empty or invalid value clears the field.
languagestringLanguage code.
group_idintClient group.
currency_codestringCurrency code, for example USD.
companyobjectCorporate details.
namestringTrading name.
tax_numberstringTax number.
tax_officestringTax office.
billing flagsbooltrue sets the flag, false clears it, leaving the field out changes nothing.
tax_exemptionboolExempts the client from tax.
never_suspendboolNever suspends the services.
never_cancelboolNever cancels the services.
separate_invoicesboolInvoices each service separately.
never_late_feeboolApplies no late fee.
Response fields data — 15
dataobjectThe client as it now stands. Same shape as the detail endpoint.
Errors 6
not_found404No such client.
full_name_required422Full name was sent empty.
email_invalid422Email format is not valid.
email_exists422That email is already in use.
phone_invalid422The phone number is not valid.
insufficient_scope403The key lacks the required scope.
Request
curl -X PATCH 'https://panel.example.com/api/v1/admin/clients/42' \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"status":"blocked","never_suspend":false}'
const res = await fetch('https://panel.example.com/api/v1/admin/clients/42', {
  method: 'PATCH',
  headers: {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ status: 'blocked', never_suspend: false }),
});

const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/clients/42');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST  => 'PATCH',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'status'        => 'blocked',
        'never_suspend' => false,
    ]),
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
// Only two fields are sent; the rest of the profile stays as it was.
$response = Api::Clients()->UpdateClient([
    'id'            => 42,
    'status'        => 'blocked',
    'never_suspend' => false,
]);

Deleting a Client

delete/api/v1/admin/clients/{id}
Clients/DeleteClient admin cannot be undone

Deletes the client and returns the id that was removed.

Response fields data
deletedboolWhether the delete succeeded.
idintId of the deleted client.
Errors 2
not_found404No such client.
insufficient_scope403The key lacks the required scope.
Request
curl -X DELETE 'https://panel.example.com/api/v1/admin/clients/42' \
  -H "Authorization: Bearer $API_KEY"
const res = await fetch('https://panel.example.com/api/v1/admin/clients/42', {
  method: 'DELETE',
  headers: { Authorization: `Bearer ${apiKey}` },
});

const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/clients/42');
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);
$response = Api::Clients()->DeleteClient(['id' => 42]);

if (($response['data']['deleted'] ?? false) === true) {
    // The record is gone; clear your own data that pointed at it.
}

Validating Client Credentials

post/api/v1/admin/clients/validate
Clients/ValidateClient admin writes nothing

Answers one question: do this email and password belong to a live member account? Nothing is created or changed. Use it when your own front end holds the credentials and needs the client id behind them.

The stored password never comes back

It is a bcrypt digest wrapped in the installation's own encryption. Returning it would let any holder of an admin key verify guesses offline. The response carries the client id and nothing else.

Body 2 fields, both required
emailstringrequiredThe account's email address.
passwordstringrequiredThe password to check.
Response fields data — 2
validboolAlways true. A mismatch comes back as an error, not as valid: false.
user_idintThe client the credentials belong to.
Errors 3
credentials_required422email or password is missing.
invalid_credentials422No live member account matches this pair.
insufficient_scope403The key lacks the required scope.
Request
curl -X POST 'https://panel.example.com/api/v1/admin/clients/validate' \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]","password":"correct horse battery staple"}'
const res = await fetch('https://panel.example.com/api/v1/admin/clients/validate', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ email: '[email protected]', password }),
});

if (res.ok) {
  const body = await res.json();
  console.log(body.data.user_id);          // the client behind the credentials
}
$ch = curl_init('https://panel.example.com/api/v1/admin/clients/validate');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'email'    => '[email protected]',
        'password' => $password,
    ]),
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
$response = Api::Clients()->ValidateClient([
    'email'    => '[email protected]',
    'password' => $password,
]);

// A wrong pair arrives as an error, so reaching this line already means they matched.
$clientId = $response['data']['user_id'] ?? 0;

Signing In as a Client

post/api/v1/admin/clients/sso
Clients/CreateClientSsoToken admin single use, 60 seconds

Issues a one-time sign-in ticket for a client and returns the URL that spends it. Send the client to that URL and they arrive already signed in. Your integration never handles their password.

Intended for a system that already knows who the visitor is: your own portal, a control panel, a desk tool. login_as_client is not exposed over the API because it swaps the PHP session in place; this endpoint is its token-based equivalent.

How the ticket behaves

Single use — it is spent the moment the URL is opened. A replayed link lands on the sign-in form with an explanatory message. Valid for 60 seconds, meant to be issued and followed in one motion. One per client: a new ticket silently retires the previous one. Confined to this installation, so a destination pointing anywhere else is dropped and the client lands on their dashboard. The login gate still applies. Account status, country blocking and module vetoes are evaluated when the ticket is spent, exactly as for a password login. The ticket proves who, not whether they may sign in right now.

Body 3 fields, 1 required
client_idintrequiredClient to issue the ticket for. user_id is still accepted as its former name.
destinationstringWhere the client lands after signing in: an absolute URL on this installation, or a route key such as services. Defaults to their dashboard.
destination_valuesarrayRoute parameters, when destination is a route key.
Response fields data — 3
tokenstringThe ticket, in the form {client_id}-{secret}.
urlstringSign-in URL carrying the ticket; send the client here.
expires_atstringExpiry, ISO 8601 with offset.
Errors 5
client_id_required422client_id is missing.
client_not_found404No client with this id.
client_not_active422The account is inactive, blocked or otherwise cannot sign in.
sso_ticket_failed422The key could not be issued.
insufficient_scope403The key lacks the required scope.
Request
curl -X POST 'https://panel.example.com/api/v1/admin/clients/sso' \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"client_id":42,"destination":"services","destination_values":[128]}'
const res = await fetch('https://panel.example.com/api/v1/admin/clients/sso', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ client_id: 42, destination: 'services', destination_values: [128] }),
});

const body = await res.json();

window.location = body.data.url;         // spend it now; it lasts 60 seconds
$ch = curl_init('https://panel.example.com/api/v1/admin/clients/sso');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'client_id'          => 42,
        'destination'        => 'services',
        'destination_values' => [128],
    ]),
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
$response = Api::Clients()->CreateClientSsoToken(['client_id' => 42]);

$link = $response['data']['url'];

Pitfalls

List and detail do not share a schema

The list returns the group as an object (group). The detail returns the id alone (group_id). The name is one field in the list and two in the detail. A mapper written against the list quietly produces empty fields when it meets the detail.

On update an empty value means delete

Sending an empty phone or birthday clears the field. To keep a field, leave it out of the body entirely; that is what a partial update is for.

A phone is not stored the way you write it

Formatting is dropped and only digits remain. Send +1 555 010 0000 and you read back 15550100000; any code that compares the two has to account for it.

Cet article vous a-t-il été utile ?

Merci pour votre retour !

Besoin d'aide supplémentaire ?

Notre équipe d'assistance est disponible 24h/24 pour tout ce que vous ne trouvez pas ci-dessus.