Authentication Guide
The EVX Software API uses OAuth 2.0 Client Credentials for server-to-server integrations. Every
request carries a short-lived access token in the Authorization header. The credentials only
identify which API client is calling; your EVX Software installation decides what that client may
do, by having it act as one of its users.
Prerequisites
1. Request an API client
API clients are created by EVX Software. Ask support for one for your installation, saying which integration it is for. You receive:
| Credential | Description |
|---|---|
client_id | The API client's identifier |
client_secret | Its secret |
| Token endpoint | The URL your integration requests access tokens from |
Treat the client_secret like a password: never expose it in client-side code, public
repositories or logs. Store it in environment variables or a secrets manager. If it leaks, ask
support for a new one; the old one stops working.
2. Grant it access in your installation
An administrator of your installation grants that client_id access in
Administration → API access, and picks the user it acts as. The client can do everything that
user can do and nothing more:
- Use a dedicated, least-privilege service user — never a real person.
- Avoid administrators: they bypass permission checks, so the client would have full access.
Revoking the client there stops its calls at once.
Base URL
The API is served by your installation:
https://<your-installation>/api/v3
Step 1: Obtain an Access Token
Send the credentials to the token endpoint as a form (application/x-www-form-urlencoded) with
grant_type=client_credentials.
Using PHP cURL
<?php
$tokenUrl = getenv('EVX_TOKEN_URL');
$clientId = getenv('EVX_CLIENT_ID');
$clientSecret = getenv('EVX_CLIENT_SECRET');
$ch = curl_init($tokenUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
CURLOPT_POSTFIELDS => http_build_query([
'grant_type' => 'client_credentials',
'client_id' => $clientId,
'client_secret' => $clientSecret,
]),
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
throw new RuntimeException("Token request failed with HTTP {$httpCode}: {$response}");
}
$tokenData = json_decode($response, true);
$accessToken = $tokenData['access_token'];
$expiresIn = $tokenData['expires_in'];
echo "Access token obtained, expires in {$expiresIn} seconds.\n";
Using PHP Guzzle
<?php
require 'vendor/autoload.php';
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
$http = new Client(['timeout' => 10]);
try {
$response = $http->post(getenv('EVX_TOKEN_URL'), [
'form_params' => [
'grant_type' => 'client_credentials',
'client_id' => getenv('EVX_CLIENT_ID'),
'client_secret' => getenv('EVX_CLIENT_SECRET'),
],
]);
$tokenData = json_decode($response->getBody(), true);
$accessToken = $tokenData['access_token'];
$expiresIn = $tokenData['expires_in'];
echo "Access token obtained, expires in {$expiresIn} seconds.\n";
} catch (ClientException $e) {
$errorBody = $e->getResponse()->getBody()->getContents();
throw new RuntimeException("Authentication failed: {$errorBody}");
}
Step 2: Use the Token in API Requests
Include the access token as a Bearer token in the Authorization header of every request:
<?php
// cURL example
$base = 'https://' . getenv('EVX_INSTALLATION') . '/api/v3';
$ch = curl_init($base . '/projects?limit=50');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $accessToken,
'Accept: application/json',
],
]);
$answer = json_decode(curl_exec($ch), true);
curl_close($ch);
$projects = $answer['data']['projects'];
<?php
// Guzzle example
$api = new Client([
'base_uri' => 'https://' . getenv('EVX_INSTALLATION') . '/api/v3/',
'timeout' => 10,
]);
$response = $api->get('projects', [
'headers' => [
'Authorization' => 'Bearer ' . $accessToken,
'Accept' => 'application/json',
],
'query' => ['limit' => 50],
]);
$projects = json_decode($response->getBody(), true)['data']['projects'];
Every answer is wrapped: { "ok": true, "data": … } on success, and
{ "ok": false, "error": { "code", "message", "details"? } } on failure.
Step 3: Request a New Token When It Expires
Access tokens are short-lived (expires_in, in seconds) and client credentials come with no
refresh token: when one expires, repeat Step 1. Cache the token and reuse it until shortly
before it expires, instead of requesting one per call:
<?php
function evxAccessToken(bool $renew = false): string
{
static $token = null;
static $expiresAt = 0;
// Renew 30 seconds before expiry, or when asked to.
if (!$renew && $token !== null && time() < $expiresAt - 30) {
return $token;
}
$http = new GuzzleHttp\Client(['timeout' => 10]);
$data = json_decode($http->post(getenv('EVX_TOKEN_URL'), [
'form_params' => [
'grant_type' => 'client_credentials',
'client_id' => getenv('EVX_CLIENT_ID'),
'client_secret' => getenv('EVX_CLIENT_SECRET'),
],
])->getBody(), true);
$token = $data['access_token'];
$expiresAt = time() + (int) $data['expires_in'];
return $token;
}
In a long-running worker, keep the token in a shared cache (Redis, APCu, a file) so every process reuses it.
Error Handling
| HTTP Code | Where | Meaning | Action |
|---|---|---|---|
400/401 | Token endpoint | Wrong grant_type, client_id or client_secret (invalid_client) | Check the credentials; ask support if they were rotated |
401 | API | Missing, invalid or expired token, or a client this installation has not granted access to | Request a new token; check Administration → API access |
403 | API | The user the client acts as may not do this | Give that user the permission, or use another endpoint |
Sending the token and retrying once on 401
<?php
use GuzzleHttp\Client;
$api = new Client([
'base_uri' => 'https://' . getenv('EVX_INSTALLATION') . '/api/v3/',
'timeout' => 10,
'http_errors' => false, // read the error envelope instead of throwing
]);
function evxRequest(Client $api, string $method, string $path, array $options = []): array
{
// The token may expire between two calls: on a 401, get a new one and retry once.
foreach ([false, true] as $renew) {
$options['headers']['Authorization'] = 'Bearer ' . evxAccessToken($renew);
$options['headers']['Accept'] = 'application/json';
$response = $api->request($method, $path, $options);
if ($response->getStatusCode() !== 401) {
break;
}
}
$answer = json_decode($response->getBody(), true);
if (!$answer['ok']) {
throw new RuntimeException($answer['error']['code'] . ': ' . $answer['error']['message']);
}
return $answer['data'];
}
$projects = evxRequest($api, 'GET', 'projects', ['query' => ['limit' => 50]])['projects'];
Optional Modules
Some resources belong to modules that not every installation runs. Read the module's bootstrap first and hide what is not there:
<?php
$config = evxRequest($api, 'GET', 'expenses/config');
if ($config['available']) {
$actualExpenses = evxRequest($api, 'GET', 'expenses/actual')['actual_expenses'];
}
GET /expenses/config always answers 200; with available: false the module is not active, and
every other /expenses/… route answers 404 with message: "expenses".
Security Best Practices
- Store credentials securely — environment variables (
getenv()) or a secrets manager. Never hardcode them. - Use HTTPS only — never send tokens over unencrypted connections.
- Least privilege — the client acts as one user of your installation: give that user only the permissions the integration needs.
- Cache tokens — reuse the access token until shortly before it expires.
- Retry safely — send an
Idempotency-Keyheader on everyPOST,PUT,PATCHandDELETE, so a retry never runs a write twice. - Log responsibly — never log access tokens or client secrets; log only non-sensitive metadata (HTTP status, request path).
Token Response Reference
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 300
}
| Field | Type | Description |
|---|---|---|
access_token | string | Bearer token for API requests |
token_type | string | Always Bearer |
expires_in | integer | Token lifetime in seconds |
The answer may carry other standard OAuth 2.0 fields; read only these three.