# PHP SDK reference

This page covers the [PHP SDK](/docs/sdk/php)'s options and behaviour in detail. For offline tests, see [PHP testing](/docs/sdk/php/testing).

## Client settings

`CalmLens\ServerClient` takes your `apiKey` and `projectId`, and optionally:

| Setting            | Default                       | Effect                                                                                    |
| ------------------ | ----------------------------- | ----------------------------------------------------------------------------------------- |
| `baseUrl`          | `https://api.calmlens.com/v1` | The API base URL.                                                                         |
| `cdnBaseUrl`       | `https://cdn.calmlens.com/v1` | The CDN base URL.                                                                         |
| `timeout`          | 120 seconds                   | Each API request attempt, response included.                                              |
| `uploadTimeout`    | None                          | Each binary upload attempt, since files may be large. Text uploads use `timeout`.         |
| `connectTimeout`   | 10 seconds                    | Opening a connection.                                                                     |
| `maxRetries`       | 2                             | Retries after the first attempt; see [Retries and idempotency](#retries-and-idempotency). |
| `maxResponseBytes` | 32 MiB                        | The largest API response body the client reads.                                           |
| `httpClient`       | A new Guzzle client           | A Guzzle `ClientInterface` with your application's transport configuration.               |

The defaults are constants on `ServerClient`, such as `ServerClient::DEFAULT_TIMEOUT`. An invalid API key or project ID throws `ConfigurationException` with `sdk/invalid-config`. Requests send a `calmlens-php/<version> (php/<version>)` `User-Agent`.

Keep API keys on your server. Reuse a client within a request or worker: each client caches the [lens](#lenses) definitions it has ensured.

Calls are synchronous. There is no separate cancellation handle; `waitForDecision`'s `timeout` bounds the whole wait, and the other timeouts bound each attempt.

## Options and return values

Every method takes one options array whose keys match the API's field names, such as `['assetId' => 'asset_5Gm2Rk8XqT3wLp9VbN4c']`. Pass `['folderId' => null]` to `assets->move()`, or `['parentId' => null]` to `folders->move()`, to move to the project root.

`assets->delete()`, `folders->delete()`, `hashLists->delete()` and `hashLists->removeEntry()` return nothing. Other network methods return the decoded JSON response as an associative array.

## Uploads

Supply a `name` and exactly one of `content`, `part` or `url`:

- `content` holds one of `plainText`, `base64`, or `data`/`bytes`: a binary string, PHP stream resource or PSR-7 stream, with a concrete MIME `type`. Sized streams are sent from their current position, without loading them into memory.
- `part` starts a [multipart upload](/docs/guides/multipart-uploads).
- `url` submits a website, such as `['name' => 'page', 'url' => 'https://example.com']`.

```php
$file = fopen('/path/to/photo.jpg', 'rb');
try {
    $asset = $client->assets->create([
        'name' => 'photo.jpg',
        'content' => ['data' => $file, 'type' => 'image/jpeg'],
        'processingOptions' => ['keepContentAfterProcessing' => true],
        'metadata' => [['id' => 'source', 'value' => 'profile']],
    ]);
} finally {
    fclose($file);
}
```

### Multipart uploads

Start with `part` in place of `content`, then send later parts with `uploadPart()`:

```php
$client->assets->uploadPart([
    'assetId' => $id,
    'part' => ['data' => $bytes, 'partNumber' => 2, 'isFinal' => true],
]);
```

Parts need exact byte counts. Non-final parts must be at least 5 MiB, and multipart image totals must be 10–20 MiB; pass `totalSizeInBytes` on the first call when you know it. CalmLens stores whether the first part was queued (`create()`) or waits for the decision (`classify()`), so later parts can come from another process. Pass `queue` to `uploadPart()` only to override it.

### Conversation context

For a post in a conversation, pass `authorId` (your label for who wrote it) and `context`, the earlier posts oldest first. CalmLens moderates only the content, and reads the earlier posts to understand who it addresses and what it refers to:

```php
$asset = $client->text->classify([
    'text' => 'Same to you',
    'authorId' => 'user-9',
    'context' => ['items' => [
        ['text' => 'You played well today', 'authorId' => 'user-1'],
        ['text' => 'Thanks!', 'authorId' => 'user-9'],
    ]],
]);
```

The SDK sends only the posts CalmLens uses: the 5 most recent that fit in 2,000 characters (Unicode code points), always including the newest. `CalmLens\ModerationContext::select($items)` applies the same rule, with the limits in `ModerationContext::USED_ITEMS` and `ModerationContext::USED_CHARACTERS`. Assets return `authorId` and the `context` used, which becomes `null` once the content is removed. See [Threaded moderation](/docs/guides/threaded-moderation).

## Moderation results

`classification.moderation.status` is `pending`, then `allowed`, `limited`, `age-restricted`, `blocked` or `purged`. Treat any other value as not ready. A classification failure is not a decision to allow: while `classification.status` is `failed`, `classification.failureReason` says why (see [Failure reasons](/docs/guides/asset-lifecycle#failure-reasons)); otherwise it is `null`.

Manual reviews in `classification.moderation.manualReviews` record their `decision` as one of the five decided statuses. To list only allowed assets, filter with `classification.moderation.status==allowed`.

Record a reviewer's decision with `assets->decide()`; the note is required. An asset still pending moderation can only be blocked or purged, and purging deletes the stored content:

```php
$client->assets->decide(['assetId' => $asset['id'], 'decision' => 'blocked', 'note' => 'Spam reported by members']);
$client->assets->decide(['assetId' => $asset['id'], 'decision' => 'purged', 'note' => 'Illegal content', 'confirmPurge' => true]);
```

`assets->batch()` takes `assetIds` (1 to 500) and an `operation`, `Assets::BATCH_DELETE` or `Assets::BATCH_REMOVE_CONTENT`. It checks the list before sending and is not retried.

## Hash lists

[Hash lists](/docs/guides/hash-lists) hold PDQ hashes of images you never want again. Image uploads are compared with every list in the project: a `flag` list reports matches, and a `block` list also blocks close ones.

```php
$list = $client->hashLists->create(['name' => 'Removed images', 'action' => 'block']);
// Add a 64-character hex PDQ hash, or the hash of an image asset that finished processing.
$entry = $client->hashLists->addEntry(['hashListId' => $list['id'], 'assetId' => $assetId, 'label' => 'Report 1042']);
$page = $client->hashLists->listEntries(['hashListId' => $list['id'], 'pageSize' => 50]);
$client->hashLists->removeEntry(['entryId' => $entry['id']]);
```

Adding a hash the list already holds returns the existing entry. An asset without a perceptual hash fails with [`409 hash-list/asset-not-hashed`](/docs/api/hash-lists#create-hash-list-entry-error-hash-list-asset-not-hashed) (`ErrorCodes::HASH_LIST_ASSET_NOT_HASHED`). `update()` takes `name`, `description` (`null` clears it) and `action`. Deleting a list deletes its entries.

## Pagination and filters

Every list returns a cursor page with `items`, `nextCursor` and `hasMore`. Pass a page's `nextCursor` as `cursor` for the next one, and treat it as opaque. Lists take `pageSize` (1 to 100, 50 by default).

- Asset lists and child lists also take `ordering`, such as `[['field' => 'createdAt', 'direction' => 'desc']]` with lowercase `asc` or `desc`, and `filter`; see [Pagination and filtering](/docs/guides/pagination-and-filtering).
- `hashLists->listEntries()` returns entries newest first.
- `folders->list()` is ordered by `name` or `createdAt`, and returns the `ancestors` needed to show the folders' paths.
- `events->list()` without `cursor` returns no events and a `nextCursor` for the current feed position. Save it to consume later events.

```php
foreach ($client->assets->each(['pageSize' => 100]) as $asset) {
    // Process each asset.
}
```

`assets->each()` and `assets->eachChild()` stop at a page without a next cursor, with `hasMore` false or with no items; `events->eachPage()` and `events->each()` stop when `hasMore` is false. Rather than loop, they throw `InvalidResponseException` with `sdk/invalid-response` (and the repeated cursor in `details['cursor']`) when a page with more to follow returns a cursor they already sent, without yielding that page.

## Errors

Every error the SDK throws is a `CalmLens\CalmLensException` with:

- `errorCode`: the API's error code, unchanged, or one of the SDK's own codes.
- `status`: the HTTP status, or `null` without a response.
- `details`: the API error's details object, such as validation `issues`. Transport errors carry no details.
- `headers` and `requestId`: the `X-Request-Id` header, or the error body's `requestId` without it. Include it when you contact support.
- `getPrevious()`: the underlying exception.

Helpers: `retryAfter()` (seconds from `Retry-After`, or `null`), `isNotFound()`, `isRetryable()` and `header($name)`. Compare codes with the constants in `CalmLens\ErrorCodes` (with each code's HTTP status in `ErrorCodes::STATUSES`) and `CalmLens\SdkErrorCodes`, never with the message. To handle one kind of failure, catch a subclass from `CalmLens\Exception`:

| Class                                                                                                                                                                                                                                            | When                                                                                                                 |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- |
| `ApiException`, and its subclasses `BadRequestException` (400), `AuthenticationException` (401), `PermissionDeniedException` (403), `NotFoundException` (404), `ConflictException` (409), `RateLimitException` (429) and `ServerException` (5xx) | The API answered with an error status.                                                                               |
| `ConnectionException` (`sdk/transport-error`), and its subclass `TimeoutException` (`sdk/timeout`)                                                                                                                                               | No complete response arrived, or `waitForDecision` had no decision in time.                                          |
| `InvalidResponseException` (`sdk/invalid-response`, `sdk/response-too-large`)                                                                                                                                                                    | A successful response couldn't be read, or its body or declared `Content-Length` was larger than `maxResponseBytes`. |
| `InvalidArgumentException` (`sdk/invalid-argument`, or an `upload/*` code for payload checks the API also makes)                                                                                                                                 | An argument was rejected before sending.                                                                             |
| `ConfigurationException` (`sdk/invalid-config`), a subclass of `InvalidArgumentException`                                                                                                                                                        | `ServerClient` received an invalid setting.                                                                          |
| `WebhookVerificationException` (`sdk/invalid-webhook-signature`, `sdk/invalid-webhook-payload`)                                                                                                                                                  | A webhook delivery failed verification.                                                                              |

See [Error handling](/docs/guides/error-handling).

## Retries and idempotency

GET requests and uploads retry connection failures, timeouts, `429`, `502`, `503`, `504` and [`409 idempotency/request-in-progress`](/docs/api/assets#create-asset-error-idempotency-request-in-progress), up to `maxRetries` times. Each upload generates one idempotency key and reuses it on every retry. Other writes are retried only when you supply an `idempotencyKey`. Streams that can't seek are never replayed.

Retry `n` (counting from 0) waits half of `min(8, 0.5 × 2ⁿ)` seconds plus a random part of the other half. A `Retry-After` header (whole seconds or an HTTP date; `0` retries at once) replaces that wait, and one over 60 seconds returns the error to you instead. `cdn->serve()` retries the same way and returns the last response.

Redirects are never followed, to protect credentials: a 3xx fails with `sdk/http-error`, its status and request ID, and isn't retried, `cdn->serve()` included.

Pass per-call settings in a `requestOptions` key: `timeout`, `uploadTimeout`, `connectTimeout`, `maxRetries`, `maxResponseBytes` and `idempotencyKey`. The API checks the key: 1 to 255 printable characters, with no space at either end. See [Retrying requests safely](/docs/guides/error-handling#retrying-requests-safely).

## CDN URLs and transforms

```php
$url = $client->cdn->getAssetUrl([
    'assetIdentifier' => $asset['id'],
    'transform' => ['type' => 'imageTransform', 'options' => ['width' => 640, 'format' => 'webp']],
]);
$signed = $client->assets->createSignedUrl(['assetId' => $asset['id'], 'expiresInSeconds' => 900]);
$response = $client->cdn->serve(['assetIdentifier' => $asset['id']]);
// Check $response->getStatusCode(); read $response->getBody() in chunks, then close it.
```

URL builders accept `resource` (`rendered`, the default, `original` or `placeholder`), `lens`, a string or structured `transform`, and `viewerClaims`. Only `rendered` takes a lens or transform. `placeholder` is a small blurred PNG and takes neither.

A `transform` is a selector string or one or more structured steps. A selector has one path segment per step, such as `fit=cover,width=640/format=webp`, and a lens and a transform are joined with `/` (`/rendered/@thumbnail/width=320`). Structured options are sorted, nested options are dotted (`border.color`) and hex colours drop the `#`, so the example above builds `.../rendered/format=webp,width=640`. See [Lenses and transforms](/docs/guides/lenses).

`cdn->serve()` returns a streaming PSR-7 response, HTTP error responses included. Its `init` option takes `method` (`GET` or `HEAD`) and `headers`, such as `Range`.

`assets->createSignedUrl()` takes `resource` (`rendered`, the default, or `original`), `lens`, `transform`, `viewerClaims` and `expiresInSeconds` (1 to 86400, 900 by default). Signed URLs let browsers load private content without your API key; see [Signed URLs](/docs/guides/delivering-assets#signed-urls).

### Viewer claims

CDN methods and `createSignedUrl()` accept `viewerClaims` with `region` and `ageConfirmed`. Region defaults to the country of the connection reaching CalmLens, and age confirmation to false. CalmLens accepts these as your application's assertions without verifying them.

The CDN refuses age-restricted content with [`403 viewer/age-confirmation-required`](/docs/api/cdn#error-viewer-age-confirmation-required) unless `ageConfirmed` is true, even for API-key and signed-URL requests. Pass `'viewerClaims' => ['ageConfirmed' => true]` only where your app has already decided the viewer may see age-restricted content; applications that never serve it, including processing jobs, should leave it false. Private assets separately need access permission, and claims can't override blocks or safety holds.

A `limited` asset is delivered exactly like an allowed one; it is marked so your app can reduce its reach. Blocked assets aren't served, and purged assets have no content. For proxies, raw HTTP and caching, see [Viewer claims](/docs/api/cdn#viewer-claims) and [Viewer region](/docs/guides/jurisdiction-rules#viewer-region).

## Lenses

`$client->ensureLens($definition)` creates or updates one lens definition (`identifier`, `name`, `type`, `version`, `transforms` and so on). `$client->ensureLenses(['lenses' => [...]])` takes several, sends them in batches of at most 100 and caches the successful definitions on the client, so ensuring the same definition again makes no request. Updating a lens requires a higher version. See [Versioned custom lenses](/docs/guides/lenses#versioned-custom-lenses).

## Webhooks

`CalmLens\Webhooks::parseEvent($payload, $signature, $timestamp, $secret)` verifies a delivery and returns the event. It takes the exact raw body, compares the HMAC in constant time, and accepts timestamps within `Webhooks::SIGNATURE_TOLERANCE_SECONDS` (300) of your clock; pass `toleranceSeconds` to change it, where 0 accepts only the current second. `Webhooks::verify()` takes the same arguments and returns a boolean.

Pass `null` for the signature and timestamp, and `headers` (from `getallheaders()`, `$_SERVER` or a PSR-7 request's `getHeaders()`), to read them from the request. Header names match in any case, and repeated headers are joined with `,`, so a signature list split across headers verifies and a repeated timestamp doesn't.

`parseEvent` throws `sdk/invalid-webhook-payload` unless the body is an object with a string `id`, `type` and `projectId` and a numeric `createdAt`, and its other known fields (`severity`, `title`, `description`, `assetId`, `asset` and `facts`) have the API's types.

Moderation sends [`asset.classified`](/docs/api/events#event-asset-classified) when classification finishes and one event per status, for automated and reviewer decisions alike: [`asset.allowed`](/docs/api/events#event-asset-allowed), [`asset.limited`](/docs/api/events#event-asset-limited), [`asset.age-restricted`](/docs/api/events#event-asset-age-restricted), [`asset.blocked`](/docs/api/events#event-asset-blocked) and [`asset.purged`](/docs/api/events#event-asset-purged). [`asset.failed`](/docs/api/events#event-asset-failed) reports failed processing. Event asset snapshots carry `classification.moderation.status`.

Retries of one delivery repeat its `x-calmlens-delivery` ID (`Webhooks::DELIVERY_HEADER`), so use it, or the event `id`, to skip duplicates. Test deliveries carry the registered `test: true` fact, which `Webhooks::isTest($event)` recognises. To test your receiver, see [Webhook handlers](/docs/sdk/php/testing#webhook-handlers).

## Upload tokens

`$client->uploadTokens->sign(['uploadPresetId' => $presetId, 'externalId' => $id])` returns `token` and `expiresAt` (Unix milliseconds). Signing is local and makes no request. Tokens last 600 seconds by default; set `expiresInSeconds` from 1 to 3600. Issue a fresh token per upload, after authenticating and authorising the browser user. See [Upload tokens](/docs/guides/upload-presets#upload-tokens).

## Newer API versions

The SDK preserves response fields it doesn't know, unknown event types and fact keys, and structured fact values, so responses from a newer API keep working. Treat a status you don't recognise as not allowed.
