Getting started

Errors

Every failure is a typed exception carrying the stable machine-readable code from the API. Branch on the code, never on the message.

Every exception the SDK throws implements Envless\Exception\EnvlessException, so one catch (EnvlessException $error) catches all of them. A mistake in the call itself, such as an empty slug, a negative timeout: or binary data passed as plaintext, throws Envless\Exception\InvalidArgumentException before anything is sent, and since that extends PHP's own InvalidArgumentException, code that already catches the built-in one keeps working.

ExceptionThrown when
Envless\Exception\ApiExceptionThe API answered and the answer was a failure. Carries status, errorCode and the request id. Also thrown before sending when a variable name is unusable.
Envless\Exception\NetworkExceptionNothing came back to classify: connection refused, DNS failure, a TLS error, or the timeout passing.
Envless\Exception\DecryptionExceptionA value cannot be decrypted with the passphrase or key you passed, or is not an Envless envelope at all.
Envless\Exception\WebhookVerificationExceptionA webhook delivery fails signature, timestamp or payload checks in Envless::verifyWebhookSignature().
Envless\Exception\InvalidArgumentExceptionThe call itself is wrong, and nothing was sent.
php
use Envless\Envless;
use Envless\Exception\ApiException;
use Envless\Exception\NetworkException;

$envless = new Envless();

try {
    $envless->projects->create(['name' => 'Billing', 'slug' => 'billing']);
} catch (ApiException $error) {
    if ($error->needsUpgrade()) {
        echo 'Upgrade needed: ', $error->getMessage(), PHP_EOL;
    } elseif ($error->isScopeMissing()) {
        echo 'Key needs: ', $error->resource, PHP_EOL;
    } elseif ($error->isConflict()) {
        echo 'Slug already taken', PHP_EOL;
    } else {
        echo $error->errorCode, ' ', $error->requestId, PHP_EOL;
    }
} catch (NetworkException $error) {
    echo 'Could not reach the API: ', $error->getMessage(), PHP_EOL;
    echo $error->isTimeout() ? 'Timed out' : 'No answer', PHP_EOL;
}

Never branch on getMessage(). It is written for humans and can be reworded at any time. errorCode is a stable contract.

ApiException

PropertyTypeNotes
statusint or nullThe HTTP status. A variable name refused before sending carries 400, the status the API would answer with. getCode() returns the same number, or 0.
errorCodestringThe stable code, such as variable.ciphertext_invalid. Always present, falling back to server.error. It is errorCode rather than code because PHP's Exception already owns code.
getMessage()stringThe API's human readable message.
resourcestring or nullWhat the failure relates to. On a scope failure this is the exact scope needed, such as variables:write.
fieldstring or nullThe request field that caused it.
requestIdstring or nullRead from the response's _req.reqId, or its Request-Id header. null on a refusal made before anything was sent. This is what support needs. Log it.
retryAfterSecondsint, float or nullFrom the Retry-After header, on a 429 or a transient 5xx.

Every property is readonly.

Predicates

MethodTrue when
isAuth()errorCode starts with auth.
isScopeMissing()errorCode is exactly auth.scope_missing
isValidation()status is 400
needsUpgrade()status is 402
isNotFound()status is 404
isConflict()status is 409
isRateLimited()status is 429

They cover the branches most callers need. For anything finer, match on errorCode, which reads well in a match:

php
use Envless\Envless;
use Envless\Exception\ApiException;

$envless = new Envless();

try {
    $envless->variables->get('api', 'production', 'STRIPE_SECRET_KEY');
} catch (ApiException $error) {
    $advice = match ($error->errorCode) {
        'auth.scope_missing' => 'Mint a key with ' . $error->resource,
        'project.ambiguous_product' => 'Pass product: to pick the project',
        default => throw $error,
    };

    fwrite(STDERR, $advice . PHP_EOL);
}

NetworkException

MemberTypeNotes
getMessage()stringWhat went wrong, without the token or the request body.
isTimeout()booltrue when the timeout passed before the response finished.
getPrevious()Throwable or nullThe underlying failure, when the HTTP client reported one.

The codes worth handling

Full tables live on each endpoint page. These are the ones that come up when you drive the API from a script.

CodeStatusWhat to do
auth.api_key_not_found401The key is invalid, inactive or revoked. Stop, do not retry.
auth.scope_missing403Mint a key with the scope in resource.
auth.scope_ceiling_exceeded403You asked for scopes the minting key does not hold.
auth.key_project_restricted403The key is fenced to other projects.
auth.product_restricted403The key owner has no grant on a private product.
request.body_invalid400Fix the payload. field names the offender.
request.invalid_pagination400limit outside 1 to 100.
variable.ciphertext_invalid400You sent plaintext. See Encryption.
variable.baseline_drifted409Somebody edited since you read the baseline. Re-read and retry.
project.ambiguous_product409Pass product:.
plan.upgrade_required402A tier limit or a paid feature, such as an older version or a rollback on Free. An upgrade fixes it. Not retryable.
plan.limit_reached409The 1,000-variables-per-environment fair-use cap. No plan raises it; contact support. Not retryable.

Retrying safely

The client already retries transient failures on idempotent methods. What it deliberately does not do is retry a POST or a PATCH, because a create that timed out may have landed. If you want to retry a write, make it safe first.

For variables->apply() the baseline makes this genuinely safe: a retry either applies cleanly or fails with variable.baseline_drifted and writes nothing, so the loop below re-reads the environment and tries again.

php
use Envless\Envless;
use Envless\Exception\ApiException;

$envless = new Envless();

for ($attempt = 1; ; $attempt++) {
    $baseline = [];
    $deletes = [];

    foreach ($envless->variables->listAll('api', 'production') as $variable) {
        $baseline[] = [
            'uniqueId' => $variable['id'],
            'name' => $variable['name'],
            'updatedAt' => $variable['updatedAt'] ?? $variable['createdAt'],
        ];

        if ($variable['name'] === 'LEGACY_API_URL') {
            $deletes[] = ['uniqueId' => $variable['id']];
        }
    }

    try {
        $envless->variables->apply('api', 'production', ['baseline' => $baseline, 'deletes' => $deletes]);

        break;
    } catch (ApiException $error) {
        if ($error->errorCode !== 'variable.baseline_drifted' || $attempt === 3) {
            throw $error;
        }
    }
}

Logging

Log the code and the request id, never the payload. Variable values are ciphertext, but names, member emails and workspace slugs are not.

php
use Envless\Envless;
use Envless\Exception\ApiException;

$envless = new Envless();

try {
    $envless->variables->delete('api', 'production', 'LEGACY_API_URL');
} catch (ApiException $error) {
    error_log(sprintf(
        'envless request failed code=%s status=%s resource=%s field=%s request_id=%s',
        $error->errorCode,
        $error->status ?? 'none',
        $error->resource ?? 'none',
        $error->field ?? 'none',
        $error->requestId ?? 'none',
    ));

    throw $error;
}