# Client Endpoints

https://dev.wisecp.com/es/client-endpoints

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.

statusstring`active` 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.

statusstring`active` or `blocked`.

groupobject 2 fieldsThe 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

metaobject 4 fieldsIdentical 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 JavaScript PHP (HTTP) PHP (Internal)

```bash
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
```

```javascript
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);
```

```php
$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;
}
```

```php
// 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 200 403

```json
{
  "data": [
    {
      "id": 42,
      "full_name": "John Doe",
      "company_name": "",
      "email": "john@example.com",
      "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 }
}
```

```json
{
  "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.

statusstring`active`, `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 JavaScript PHP (HTTP) PHP (Internal)

```bash
curl 'https://panel.example.com/api/v1/admin/clients/42' \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Accept: application/json'
```

```javascript
const res  = await fetch('https://panel.example.com/api/v1/admin/clients/42', {
  headers: { Authorization: `Bearer ${apiKey}` },
});
const body = await res.json();
```

```php
$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);
```

```php
$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.

typestring`individual` 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`.

companyobject 3 fieldsTax 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 JavaScript PHP (HTTP) PHP (Internal)

```bash
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":"john@example.com","password":"Str0ngP@ssw0rd"}'
```

```javascript
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: 'john@example.com',
    password: 'Str0ngP@ssw0rd',
  }),
});

const body = await res.json();
```

```php
$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'     => 'john@example.com',
        'password'  => 'Str0ngP@ssw0rd',
    ]),
]);

$created = json_decode(curl_exec($ch), true);
curl_close($ch);
```

```php
$response = Api::Clients()->CreateClient([
    'full_name'     => 'John Doe',
    'email'         => 'john@example.com',
    '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 201 422

```json
{
  "data": {
    "id": 43,
    "full_name": "John Doe",
    "name": "John",
    "surname": "Doe",
    "email": "john@example.com",
    "status": "active",
    "country_id": 840,
    "currency_id": 2,
    "group_id": 0,
    "balance": 0.00
  }
}
```

```json
{
  "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.

statusstring`active`, `blocked` or `cancelled`.

phonestringMobile phone. Sending it empty clears it.

landline_phonestringLandline. Sending it empty clears it.

typestring`individual` 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`.

companyobject 3 fieldsCorporate details.

namestringTrading name.

tax_numberstringTax number.

tax_officestringTax office.

billing flagsbool 5 fields`true` 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 JavaScript PHP (HTTP) PHP (Internal)

```bash
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}'
```

```javascript
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();
```

```php
$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);
```

```php
// 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 JavaScript PHP (HTTP) PHP (Internal)

```bash
curl -X DELETE 'https://panel.example.com/api/v1/admin/clients/42' \
  -H "Authorization: Bearer $API_KEY"
```

```javascript
const res = await fetch('https://panel.example.com/api/v1/admin/clients/42', {
  method: 'DELETE',
  headers: { Authorization: `Bearer ${apiKey}` },
});

const body = await res.json();
```

```php
$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);
```

```php
$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_required422`email` or `password` is missing.

invalid_credentials422No live member account matches this pair.

insufficient_scope403The key lacks the required scope.

Request cURL JavaScript PHP (HTTP) PHP (Internal)

```bash
curl -X POST 'https://panel.example.com/api/v1/admin/clients/validate' \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"email":"john@example.com","password":"correct horse battery staple"}'
```

```javascript
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: 'john@example.com', password }),
});

if (res.ok) {
  const body = await res.json();
  console.log(body.data.user_id);          // the client behind the credentials
}
```

```php
$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'    => 'john@example.com',
        'password' => $password,
    ]),
]);

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

```php
$response = Api::Clients()->ValidateClient([
    'email'    => 'john@example.com',
    '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_required422`client_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 JavaScript PHP (HTTP) PHP (Internal)

```bash
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]}'
```

```javascript
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
```

```php
$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);
```

```php
$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.

## Related Articles

- [API Authentication and Permissions](https://dev.wisecp.com/en/api-authentication-and-permissions)
- [Request and Response Format](https://dev.wisecp.com/en/request-and-response-format)
- [API Resource Map](https://dev.wisecp.com/en/api-resources)
