# Go SDK reference

This page describes the [Go SDK](/docs/sdk/go) in detail: how to configure the client, the conventions every method follows, and the behaviour of uploads, results, retries, webhooks and CDN delivery. For offline tests, see [Go testing](/docs/sdk/go/testing).

## Client options

`calmlens.NewServerClient(calmlens.Options{...})` validates every option before doing any other setup. A missing API key or project ID, a negative timeout or retry count, or a base URL that is not an absolute HTTP(S) URL returns a `*calmlens.Error` with code `sdk/invalid-config`. The client is safe for concurrent use, and `Options`, `ServerClient` and errors never print the API key.

| Option                  | Default                                                      | Meaning                                                                                                                                                                                   |
| ----------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `APIKey`, `ProjectID`   | Required                                                     | Your server API key and project ID; see [Authentication](/docs/guides/authentication).                                                                                                    |
| `BaseURL`, `CDNBaseURL` | `https://api.calmlens.com/v1`, `https://cdn.calmlens.com/v1` | API and CDN endpoints.                                                                                                                                                                    |
| `Timeout`               | `calmlens.DefaultTimeout` (120 seconds)                      | Each attempt of an API call, including the response body, and text uploads. For `CDN.Serve` it ends when the response headers arrive.                                                     |
| `UploadTimeout`         | None                                                         | Each attempt of a binary upload (file content and multipart parts). Zero leaves large files uncut.                                                                                        |
| `ConnectTimeout`        | `calmlens.DefaultConnectTimeout` (10 seconds)                | Dialling and the TLS handshake, with the default transport only.                                                                                                                          |
| `ResponseHeaderTimeout` | `calmlens.DefaultResponseHeaderTimeout` (120 seconds)        | Waiting for response headers after the request is sent, with the default transport only.                                                                                                  |
| `MaxRetries`            | `calmlens.DefaultMaxRetries` (2)                             | Retries for retryable failures; `calmlens.Ptr(0)` turns them off.                                                                                                                         |
| `MaxResponseBytes`      | `calmlens.DefaultMaxResponseBytes` (32 MiB)                  | The largest API response body the client reads; a larger one fails with `sdk/response-too-large`. `CDN.Serve` bodies aren't capped.                                                       |
| `HTTPClient`            | A client with the timeouts above                             | Sends every request. The SDK uses a copy with redirects disabled, so credentials stay on the configured endpoint: a `3xx` fails with `sdk/http-error` and its status, and is not retried. |

`client.WithRequestOptions(calmlens.RequestOptions{Timeout, UploadTimeout, MaxRetries})` returns a client with other deadlines or retries for the calls made through it. It shares the credentials, HTTP client and lens cache, and the original client is unchanged.

### Timeouts and large transfers

The default transport bounds connecting and waiting for headers, never the transfer itself, and binary uploads have no deadline unless you set `UploadTimeout`. Uploading a large file or streaming a CDN download can therefore take as long as it needs. Bound any call, including those, with a context deadline:

```go
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
asset, err := client.Assets.Create(ctx, options)
```

If you supply `HTTPClient`, keep its `Timeout` at zero: `http.Client.Timeout` covers the whole request, including upload and download bodies.

Every request sends `User-Agent: calmlens-go/<version> (go/<Go version>)`, and API requests send `Accept: application/json`. `calmlens.Version` is the SDK version.

## Calling conventions

- Every method returns `nil` (or a zero value) together with a non-nil error. `Assets.Delete`, `HashLists.Delete` and `HashLists.RemoveEntry` return only an `error`.
- Use `calmlens.Ptr(value)` for optional scalars.
- `MoveAssetOptions.FolderID == nil` and `MoveFolderOptions.ParentID == nil` move to the project root.
- Updates send only the fields you set. A nil `Name` and a zero `calmlens.Patch` are left unchanged, `calmlens.Set(value)` changes a field and `calmlens.Null[T]()` clears it.
- Cursor lists return 50 items unless `PageSize` says otherwise.
- `Assets.CreateSignedURL` signs the rendered resource for 900 seconds unless `Resource` or `ExpiresInSeconds` (1 to 86,400) says otherwise.
- `Text.Classify` supplies `text/plain` content and names the asset `text` unless you set `Name`. It returns the same `*calmlens.Asset` as `Assets.Classify`.

```go
asset, err := client.Assets.Update(ctx, calmlens.UpdateAssetOptions{
	AssetID:     assetID,
	Description: calmlens.Null[string](),
	Tags:        calmlens.Set([]string{"reviewed"}),
})
signed, err := client.Assets.CreateSignedURL(ctx, calmlens.SignedURLOptions{
	AssetID: assetID, Resource: calmlens.ResourceRendered, Lens: "thumbnail",
})
```

## Assets

### Queued uploads and decisions

`Assets.Create` queues processing; `Assets.Classify` and `Text.Classify` wait for classification. `Assets.WaitForDecision` then retrieves a queued asset every few seconds until it has a decision. Its `Timeout` defaults to `calmlens.DefaultDecisionTimeout` (60 seconds), and `calmlens.Ptr(time.Duration(0))` checks once. [Webhooks](/docs/guides/webhooks) report decisions without polling.

```go
queued, err := client.Assets.Create(ctx, calmlens.SubmitAssetOptions{
	Name:    "photo.jpg",
	Content: &calmlens.Content{Type: "image/jpeg", Data: file},
})
if err != nil {
	return err
}
decided, err := client.Assets.WaitForDecision(ctx, calmlens.WaitForDecisionOptions{
	AssetID: queued.ID,
	Timeout: calmlens.Ptr(30 * time.Second),
})
if err != nil {
	return err
}
switch decided.Classification.Moderation.Status {
case calmlens.ModerationStatusAllowed, calmlens.ModerationStatusLimited:
	// Deliver normally; a limited photo is only marked so you can reduce its reach.
default:
	blockPhoto(decided.ID)
}
```

### Retry, remove content and delete

- `Assets.Retry` processes an asset whose processing failed again: its classification returns to `processing` and its moderation to `pending`.
- `Assets.DeleteContent` removes stored content but keeps the record and report. The result's `Content.Status` is `removed` with `RemovalReason` `requested`, and the CDN stops serving it.
- `Assets.Delete` removes the record and its content.
- `Assets.Batch` deletes, or removes the content of, 1 to 500 `AssetIDs` with `calmlens.AssetBatchOperationDelete` or `calmlens.AssetBatchOperationRemoveContent`. It returns a `*calmlens.BatchAssetsResponse` once the work is queued.

### Content sources

Supply exactly one of `Content`, `Part` or `URL` on `SubmitAssetOptions`. `Content` accepts exactly one of `PlainText`, `Base64` (including data URLs), `Bytes` or `Data` (an `io.Reader`). Uploads send the raw bytes with the other fields in the query string, and URL submissions use JSON. `Data` is streamed without buffering:

```go
file, err := os.Open("photo.jpg")
if err != nil {
	return err
}
defer file.Close()
info, err := file.Stat()
if err != nil {
	return err
}
asset, err := client.Assets.Create(ctx, calmlens.SubmitAssetOptions{
	Name:    "photo.jpg",
	Content: &calmlens.Content{Type: "image/jpeg", Data: file, SizeInBytes: calmlens.Ptr(info.Size())},
})
```

Direct image uploads must be below 10 MiB. See [Assets API](/docs/api/assets) for other upload limits.

### Multipart uploads

Start a [multipart upload](/docs/guides/multipart-uploads) with `Part: &calmlens.Content{Type: "image/png", Bytes: firstChunk}`, optionally with `TotalSizeInBytes` so CalmLens rejects an upload that could never complete before storing it. Continue with `Assets.UploadPart`:

```go
asset, err := client.Assets.UploadPart(ctx, calmlens.UploadAssetPartOptions{
	AssetID: upload.ID,
	Part:    calmlens.Content{Bytes: finalChunk, PartNumber: 2, IsFinal: true},
})
```

- Each part except the final one must be at least 5 MiB, and multipart images must total 10 to 20 MiB.
- Split the original bytes before base64-encoding chunks. `io.NewSectionReader` gives each part of a file without reading it into memory.
- Multipart readers need an exact `SizeInBytes`, and parts are sent one after another.
- CalmLens saves the queue mode chosen when the upload started (`Create` queues, `Classify` waits), so any client or process can send the remaining parts with only the asset ID. `UploadAssetPartOptions.Queue` overrides it for the final part.

### Processing options

Processing options are typed, and unset fields are not sent:

```go
asset, err := client.Assets.Classify(ctx, calmlens.SubmitAssetOptions{
	Name:    "photo.jpg",
	Content: &calmlens.Content{Data: file, Type: "image/jpeg"},
	ProcessingOptions: &calmlens.ProcessingOptions{
		KeepContentAfterProcessing: calmlens.Ptr(true),
		Features: &calmlens.ProcessingFeatures{
			OCR: &calmlens.OCRFeatureOptions{Enabled: true},
		},
	},
})
```

Image moderation reads text in the image directly; optional OCR adds a text moderation pass and stores retained text in `Content.ExtractedText`, as described in [OCR and image reports](/docs/guides/ocr).

### Conversation context

To moderate a reply in its conversation, set `AuthorID` (your identifier for who wrote it) and `Context` with the earlier posts, oldest first, on `ClassifyTextOptions` or `SubmitAssetOptions`. CalmLens moderates only the content and uses the earlier posts to read who it addresses and what it refers to. The SDK sends only the posts CalmLens uses, the 5 most recent that fit in 2,000 characters; `calmlens.SelectModerationContextItems` applies the same selection. The asset's `AuthorID` and `Context` return what was kept, and `Context` is `nil` once the content is removed. See [Threaded moderation](/docs/guides/threaded-moderation).

```go
asset, err := client.Text.Classify(ctx, calmlens.ClassifyTextOptions{
	Text:     reply,
	AuthorID: calmlens.Ptr("user-9"),
	Context: &calmlens.ModerationContext{Items: []calmlens.ModerationContextItem{
		{Text: "Anyone tried the new trail?", AuthorID: calmlens.Ptr("user-3")},
		{Text: "Yes, it was muddy.", AuthorID: calmlens.Ptr("user-7")},
	}},
})
```

## Classification results

`Assets.Classify`, `Assets.Create` and `Text.Classify` return `(*calmlens.Asset, error)`. `Asset` is a typed struct with exported Go field names (`ID`, `Classification`, `Content`, ...) that decodes from, and marshals back to, the API's camelCase JSON. Its state is in three nested structs:

| Field            | Type                  | Contents                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| ---------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Upload`         | `AssetUpload`         | `Status` (`uploading` or `completed`), `PartsReceived` while multipart parts arrive, and `UpdatedAt`.                                                                                                                                                                                                                                                                                                                                                    |
| `Classification` | `AssetClassification` | `Status` (`pending`, `processing`, `completed` or `failed`), `FailureReason` (`nil` unless failed), `UpdatedAt` and `CompletedAt`. Optional feature results, such as `AIGeneratedDetection`, `TextRegions` and `Labels` sit alongside `Moderation`, which holds `Status`, `AutomatedAction`, `FlaggedCategories`, policy-adjusted `Categories`, `ExplanationComplete`, `Raw` findings and reviewer details. Reviewer actions don't change `CompletedAt`. |
| `Content`        | `AssetContent`        | `Status` (`ContentStatusTemporary` until processing finishes, then `ContentStatusStored` or `ContentStatusRemoved` with `RemovalReason`), `Format` (the MIME type), `Extension`, `SizeInBytes`, `Checksums`, `Image`, `SafetyHold`, `ExtractedText` and `FileMetadata` (metadata embedded in an uploaded image, such as its capture time, camera and software).                                                                                          |

Fields that may be missing are pointers, maps or slices, and are `nil` when absent: `Classification.Moderation.Raw` is `nil` until classification produces findings, and `Content.SizeInBytes`, `FolderID`, `ExternalID` and `Description` are `nil` when unset. Inside `Raw`, `Categories` is keyed by category name, each `CategoryResult.Score` is a `*float64`, and category `Qualifiers` are a `map[string]QualifierResult` keyed by the `Qualifier...` constants. An absent `Score` is `nil`, not a safe zero, so check the category's `Status` before reading it.

```go
asset, err := client.Text.Classify(ctx, calmlens.ClassifyTextOptions{Text: comment})
if err != nil {
	return err
}
switch asset.Classification.Moderation.Status {
case calmlens.ModerationStatusAllowed:
	publish(comment)
case calmlens.ModerationStatusLimited:
	publishWithReducedReach(comment) // keep it out of recommendations and trending
case calmlens.ModerationStatusAgeRestricted:
	publishBehindAgeGate(comment) // only after your app checks the viewer
default:
	// pending, blocked, purged or a status added later: don't show it
}
```

Enumerations are plain strings with exported constants (`ClassificationStatus...`, `ModerationStatus...`, `ContentStatus...`, `ContentRemovalReason...`, `AssetFailureReason...`, `ModerationDecision...`, `AssetType...`, `ModerationAction...`, `Likelihood...`, `ResultEvaluated` and `Category...`), so values added to the API later still decode. Treat an unknown status as not allowed. `Asset`, reports, category results and events expose `Raw` with every field the API sent, including fields added after your SDK version. Numbers in `Raw`, metadata values and fact values decode as `json.Number`, so large integers keep their precision. See [Reading classification results](/docs/guides/classification-results) for the report itself.

## Folders

`Folders.List` returns a `FolderPage` of one folder's children (`ParentID`; omit it for the project root) in `Items`, the folders above them in `Ancestors`, and `HasMore` and `NextCursor` for the next page. `PageSize` is 1 to 100 (50 by default). `Search` keeps the children whose names contain it, and `Ordering` sorts by `name` (the default, `calmlens.OrderAscending`) or `createdAt`.

## Hash lists

A [hash list](/docs/guides/hash-lists) holds perceptual (PDQ) hashes of images the project never wants again. A `calmlens.HashListActionFlag` list records matches in `Classification.Moderation.HashMatches`, and a `calmlens.HashListActionBlock` list also blocks close matches (the policy decision's `Source` is then `calmlens.DecisionSourceHashList`). Add a 64-character hex `Hash`, with an optional `Quality`, or the `AssetID` of a processed image to use its `Content.Image.PerceptualHash`. Adding a hash already in the list returns its entry, and deleting a list deletes its entries.

```go
list, err := client.HashLists.Create(ctx, calmlens.CreateHashListOptions{Name: "Removed images", Action: calmlens.HashListActionBlock})
entry, err := client.HashLists.AddEntry(ctx, calmlens.AddHashListEntryOptions{HashListID: list.ID, AssetID: assetID, Label: calmlens.Ptr("Report 1042")})
page, err := client.HashLists.ListEntries(ctx, calmlens.ListHashListEntriesOptions{HashListID: list.ID, PageSize: 50})
list, err = client.HashLists.Update(ctx, calmlens.UpdateHashListOptions{HashListID: list.ID, Description: calmlens.Null[string](), Action: calmlens.Ptr(calmlens.HashListActionFlag)})
```

`HashLists.List` and `ListEntries` return pages newest first. `UpdateHashListOptions` sends only the fields you set, and its `Description` is a `calmlens.Patch`.

## Lenses

`EnsureLens` and `EnsureLenses` create or update [versioned lens definitions](/docs/guides/lenses#versioned-custom-lenses). The client caches full definitions, deduplicates batches and concurrent calls for the same lens, and replaces a cached definition when you ensure a new one under the same identifier.

## Pagination

`Assets.ListAll`, `Assets.ListChildrenAll` and `Events.ListAll` return iterators that fetch pages as you go. Iteration stops when a page has no next cursor or says no more follow. If a page says more follow but returns a cursor the iteration already used, the iterator fails with `sdk/invalid-response` (the cursor in `Details["cursor"]`) instead of repeating pages, and that page is not yielded.

```go
it := client.Assets.ListAll(calmlens.ListAssetsOptions{
	PageSize: 100,
	Filter:   calmlens.Ptr("classification.moderation.status==allowed"),
})
for it.Next(ctx) {
	fmt.Println(it.Asset().ID)
}
if err := it.Err(); err != nil {
	return err
}
```

On Go 1.23 or later you can range over `All`, which yields `(item, error)` pairs. A failure is yielded once and ends the loop:

```go
for asset, err := range client.Assets.ListAll(options).All(ctx) {
	if err != nil {
		return err
	}
	fmt.Println(asset.ID)
}
```

See [Pagination and filtering](/docs/guides/pagination-and-filtering) for filters and ordering.

## Events feed

`Events.List` returns a `*calmlens.EventsPage` with `Items []Event`, `NextCursor string` and `HasMore bool`. `NextCursor` is never empty: it is where to continue polling from. Without `Cursor`, the call returns no events and a cursor for the current time, so you only see events created after you connect. `Events.ListAll` follows the cursor until no more events are available; `NextPage` and `Page` walk it a page at a time, empty pages included. Save `Cursor()` and continue from it later:

```go
start, err := client.Events.List(ctx, calmlens.ListEventsOptions{})
if err != nil {
	return err
}
cursor := start.NextCursor // save this; start.Items is always empty
// Later:
it := client.Events.ListAll(calmlens.ListEventsOptions{Cursor: cursor, PageSize: 100})
for it.Next(ctx) {
	event := it.Event()
	fmt.Println(event.Type, event.ID, event.Facts)
}
cursor = it.Cursor() // store it even after an error
if err := it.Err(); err != nil {
	return err
}
```

Each `Event` has a `Type` (one of the `Event...` constants), `Facts` (`calmlens.EventFacts`, a list of `{Key, Value, URL}`) and `Asset`, a typed `*EventAsset` snapshot of the asset's `Classification.Status`, `Classification.Moderation.Status` and `Content.Status` when the event was created. A fact's `Value` depends on its key: read it with `Text`, `Int64`, `Bool` or `Strings`, or decode all of an event's facts into the typed struct for its type, such as `calmlens.AssetDecisionFacts`:

```go
switch event.Type {
case calmlens.EventAssetBlocked, calmlens.EventAssetPurged:
	var facts calmlens.AssetDecisionFacts
	if err := event.Facts.Decode(&facts); err != nil {
		return err
	}
	fmt.Println(facts.DecidedBy, facts.FlaggedCategories)
}
if event.Facts.IsTest() {
	// A test send from the portal or CLI.
}
```

See [Events](/docs/api/events) for every event type and its facts.

## Upload tokens

Sign a single-use [upload preset](/docs/guides/upload-presets) token on your server so a browser can upload through a preset that requires tokens. Signing happens locally with your API key and makes no request:

```go
signed, err := client.UploadTokens.Sign(ctx, calmlens.SignUploadTokenOptions{
	UploadPresetID:   "preset_4Lk8Qw2Xm9Rt5Vb3Nc7P",
	ExternalID:       calmlens.Ptr("post-42"), // optional: the uploaded asset gets this ID
	ExpiresInSeconds: 600,                      // optional: 1 to 3600, 600 by default
})
// signed.Token, and signed.ExpiresAt in milliseconds since the Unix epoch.
```

`UploadPresetID` also works on `SubmitAssetOptions` and `ClassifyTextOptions` for server uploads.

## Errors

Every failure, including invalid arguments and encoding errors, is a `*calmlens.Error`:

```go
var apiErr *calmlens.Error
if errors.As(err, &apiErr) {
	fmt.Println(apiErr.Code, apiErr.Status, apiErr.RequestID, apiErr.RetryAfter, apiErr.Details)
}
if calmlens.IsNotFound(err) {
	// 404
}
if calmlens.ErrorCode(err) == calmlens.CodeDuplicateExternalID {
	// ...
}
```

| Field                  | Meaning                                                                                                                    |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `Code`                 | An API code (constants such as `calmlens.CodeAssetNotFound`, generated from the API's error list) or an `sdk/` code below. |
| `Status`               | The HTTP status, or 0 when no response arrived.                                                                            |
| `RequestID`            | The `X-Request-Id` response header, or the error body's `requestId` without it. Include it when contacting support.        |
| `Header`, `RetryAfter` | The response headers and the parsed `Retry-After` delay (a `*time.Duration`, nil without the header).                      |
| `Details`              | Structured API details, such as validation issues, or SDK details such as `cursor`; nil for transport failures.            |
| `Cause`                | The underlying error; `errors.Is` and `errors.As` reach it through `Unwrap`.                                               |

`Error()` includes the code, status, message and request ID. `calmlens.IsRetryable(err)`, or `apiErr.Retryable()`, reports whether repeating the request may succeed.

| SDK code                                                       | When                                                                                                      |
| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `sdk/transport-error`                                          | Connection refused or reset, TLS, malformed HTTP, decompression or a truncated body.                      |
| `sdk/timeout`, `sdk/cancelled`                                 | A timeout or context deadline expired, or the context was cancelled.                                      |
| `sdk/invalid-response`                                         | A success body was empty, not JSON or unexpected.                                                         |
| `sdk/response-too-large`                                       | A response body was larger than `MaxResponseBytes`.                                                       |
| `sdk/http-error`                                               | A non-2xx response without a CalmLens error body, or a redirect.                                          |
| `sdk/invalid-argument`                                         | An argument (such as an empty, `.` or `..` ID) was rejected, or could not be encoded, before any request. |
| `sdk/invalid-config`                                           | `NewServerClient` rejected an option.                                                                     |
| `sdk/invalid-webhook-signature`, `sdk/invalid-webhook-payload` | Webhook verification or decoding failed.                                                                  |

Upload checks the client makes before sending that mirror the API (a missing name or content, part sizes, the MIME type) use the API's own codes, such as [`400 upload/invalid-payload`](/docs/api/assets#create-asset-error-upload-invalid-payload). See [Error handling](/docs/guides/error-handling).

## Retries and idempotency

The SDK retries up to `MaxRetries` times when it is safe to:

- `GET` and `HEAD` requests.
- `Assets.Create`, `Assets.Classify`, `Text.Classify` and `Assets.UploadPart`, which always send an `Idempotency-Key` header. The SDK generates one per call and reuses it for its own retries, and CalmLens returns the saved response instead of running the request twice.

They are retried after a connection failure or timeout, `429`, `502`, `503`, `504` and [`409 idempotency/request-in-progress`](/docs/api/assets#create-asset-error-idempotency-request-in-progress). Retry n (counting from 0) waits between half and all of min(8 seconds, 0.5 seconds × 2ⁿ), unless a `Retry-After` header (seconds or an HTTP date; `0` retries at once) says otherwise. A `Retry-After` longer than a minute is returned to you as the error instead.

Other writes (`Update`, `Move`, `Delete`, `Retry`, `Batch`, folder changes and `CreateSignedURL`) are never retried automatically. An upload from a `Data` reader is retried only when the reader implements `io.Seeker`, such as an `*os.File`; other streams are sent once. `CDN.Serve` retries the same failures and, once retries run out, returns the last response.

To retry across processes or restarts, pass your own key, for example derived from your record ID, and send the same key every time:

```go
asset, err := client.Assets.Create(ctx, calmlens.SubmitAssetOptions{
	Name: "photo.jpg", IdempotencyKey: "upload-" + photoID, Content: content,
})
```

## Webhooks

Deliveries are POSTs with three headers, exported as constants:

| Header                 | Constant                          | Value                                                                                      |
| ---------------------- | --------------------------------- | ------------------------------------------------------------------------------------------ |
| `x-calmlens-signature` | `calmlens.WebhookSignatureHeader` | `v1=<hex>`, the HMAC-SHA256 of `<timestamp>.<raw body>`; a comma-separated list is allowed |
| `x-calmlens-timestamp` | `calmlens.WebhookTimestampHeader` | Unix seconds                                                                               |
| `x-calmlens-delivery`  | `calmlens.WebhookDeliveryHeader`  | A unique delivery ID                                                                       |

Verification needs the exact raw bytes of the body: the functions take `[]byte`, never a decoded value, because re-encoding JSON changes the bytes. Any matching `v1=` entry is accepted, and timestamps more than `calmlens.DefaultWebhookTolerance` (5 minutes) from now are rejected. A verified body must be an event (a string `id`, `type` and `projectId`, a numeric `createdAt`, and the API's types for its other known fields), or parsing fails with `sdk/invalid-webhook-payload`.

`VerifyWebhookRequest(r, secret)` reads the body (up to 1 MiB), verifies it, returns the event and leaves the raw bytes in `r.Body` for later handlers. Routers built on `net/http`, such as chi, pass the same `*http.Request`. With gin, use `c.Request` before binding:

```go
router.POST("/webhooks", func(c *gin.Context) {
	event, err := calmlens.VerifyWebhookRequest(c.Request, secret)
	if err != nil {
		c.AbortWithStatus(http.StatusUnauthorized)
		return
	}
	log.Printf("Received event %s (%s)", event.ID, event.Type)
	c.Status(http.StatusNoContent)
})
```

| Function                                                                          | Use                                                                      |
| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| `VerifyWebhookRequest(r, secret, ...WebhookOption)`                               | Read, verify and decode a `*http.Request`.                               |
| `ParseWebhookEvent(rawBody, signature, timestamp, secret, ...WebhookOption)`      | Verify and decode raw bytes you already hold, such as from a queue.      |
| `VerifyWebhookHeaders(rawBody, header, secret, ...WebhookOption)`                 | Verify raw bytes against an `http.Header`; returns a boolean.            |
| `VerifyWebhookSignature(rawBody, signature, timestamp, secret, ...WebhookOption)` | Verify a signature; returns a boolean.                                   |
| `SignWebhookPayload(rawBody, timestamp, secret)`                                  | Produce the signature header value, for example to sign test deliveries. |

Options: `calmlens.WithTolerance(d)` (zero accepts only the signing second), `calmlens.WithNow(t)` and `calmlens.WithMaxBodyBytes(n)`. See [Webhooks](/docs/guides/webhooks) for the payload and delivery behaviour.

## CDN

`CDN.Serve` returns the response, including non-2xx statuses, with the body streamed, so `defer response.Body.Close()` is required. `Options.Timeout` ends when the response headers arrive, so pass a context deadline to bound a large download. A redirect fails with `sdk/http-error`.

`Resource` is `calmlens.ResourceRendered` (the default), `calmlens.ResourceOriginal` or `calmlens.ResourcePlaceholder`, and only the rendered resource takes a lens or transform. Supply a lens, a direct transform or both; when combined, the lens runs first. Deliver private content through a signed URL.

### Transforms

`CDN.GetAssetURL`, `CDN.Serve` and `Assets.CreateSignedURL` accept a transform as a selector string (`Transform`) or as structured steps (`TransformSteps`), but not both:

```go
// Selector string.
url, err := client.CDN.GetAssetURL(calmlens.CDNAssetURLOptions{
	AssetIdentifier: "asset-id",
	Transform:       "format=png,width=1024",
})

// Structured steps, serialised to the same selector.
url, err = client.CDN.GetAssetURL(calmlens.CDNAssetURLOptions{
	AssetIdentifier: "asset-id",
	TransformSteps: []calmlens.LensTransform{
		calmlens.ImageTransform(calmlens.ImageTransformOptions{Width: calmlens.Ptr(1024), Format: calmlens.ImageFormatPNG}),
	},
})
```

`ImageTransformOptions` types every [image transform option](/docs/guides/lenses#image-transform-options):

- `Gravity` and `Trim` accept the named form (`&calmlens.ImageGravity{Side: "face"}`, `&calmlens.ImageTrim{Border: &calmlens.ImageTrimBorder{}}`) and the detailed form.
- `Redact` (`&calmlens.ImageRedaction{Text: calmlens.Ptr(true)}`) blurs the text or personal-information regions found when the image was processed.
- `Quality` takes a number (`&calmlens.ImageQuality{Value: 80}`) or a level (`&calmlens.ImageQuality{Level: calmlens.ImageQualityHigh}`).

A selector has one path segment per step, such as `fit=cover,width=640/format=webp`: options are sorted `key=value` pairs, nested options are dotted (`border.color=ff0000`) and hex colours drop the `#`. A lens and a transform are joined with `/`, as in `/rendered/@thumbnail/width=320`.

### Viewer claims

`CDNAssetURLOptions` (which `CDNServeOptions` embeds) and `SignedURLOptions` take `ViewerClaims: &calmlens.ViewerClaims{Region: "GB", AgeConfirmed: calmlens.Ptr(true)}`. Both fields are optional assertions from your application. An `age-restricted` asset is served only with `AgeConfirmed` pointing to `true`, even with a signed URL or API key, so set it only where your app has already decided the viewer may see age-restricted content. `Region` defaults to the country of the connection reaching CalmLens; a proxy should forward the viewer's country. See [Age-restricted assets](/docs/guides/delivering-assets#age-restricted-assets) and [Viewer region](/docs/guides/jurisdiction-rules#viewer-region).
