Go SDK reference

This page describes the Go SDK 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.

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.

OptionDefaultMeaning
APIKey, ProjectIDRequiredYour server API key and project ID; see Authentication.
BaseURL, CDNBaseURLhttps://api.calmlens.com/v1, https://cdn.calmlens.com/v1API and CDN endpoints.
Timeoutcalmlens.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.
UploadTimeoutNoneEach attempt of a binary upload (file content and multipart parts). Zero leaves large files uncut.
ConnectTimeoutcalmlens.DefaultConnectTimeout (10 seconds)Dialling and the TLS handshake, with the default transport only.
ResponseHeaderTimeoutcalmlens.DefaultResponseHeaderTimeout (120 seconds)Waiting for response headers after the request is sent, with the default transport only.
MaxRetriescalmlens.DefaultMaxRetries (2)Retries for retryable failures; calmlens.Ptr(0) turns them off.
MaxResponseBytescalmlens.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.
HTTPClientA client with the timeouts aboveSends 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:

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.
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 report decisions without polling.

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:

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 for other upload limits.

Multipart uploads

Start a multipart upload 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:

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:

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.

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.

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:

FieldTypeContents
UploadAssetUploadStatus (uploading or completed), PartsReceived while multipart parts arrive, and UpdatedAt.
ClassificationAssetClassificationStatus (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.
ContentAssetContentStatus (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.

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 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 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.

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. 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.

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:

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

See 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:

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:

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 for every event type and its facts.

Upload tokens

Sign a single-use upload preset 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:

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:

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 {
	// ...
}
FieldMeaning
CodeAn API code (constants such as calmlens.CodeAssetNotFound, generated from the API's error list) or an sdk/ code below.
StatusThe HTTP status, or 0 when no response arrived.
RequestIDThe X-Request-Id response header, or the error body's requestId without it. Include it when contacting support.
Header, RetryAfterThe response headers and the parsed Retry-After delay (a *time.Duration, nil without the header).
DetailsStructured API details, such as validation issues, or SDK details such as cursor; nil for transport failures.
CauseThe 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 codeWhen
sdk/transport-errorConnection refused or reset, TLS, malformed HTTP, decompression or a truncated body.
sdk/timeout, sdk/cancelledA timeout or context deadline expired, or the context was cancelled.
sdk/invalid-responseA success body was empty, not JSON or unexpected.
sdk/response-too-largeA response body was larger than MaxResponseBytes.
sdk/http-errorA non-2xx response without a CalmLens error body, or a redirect.
sdk/invalid-argumentAn argument (such as an empty, . or .. ID) was rejected, or could not be encoded, before any request.
sdk/invalid-configNewServerClient rejected an option.
sdk/invalid-webhook-signature, sdk/invalid-webhook-payloadWebhook 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. See 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. 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:

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:

HeaderConstantValue
x-calmlens-signaturecalmlens.WebhookSignatureHeaderv1=<hex>, the HMAC-SHA256 of <timestamp>.<raw body>; a comma-separated list is allowed
x-calmlens-timestampcalmlens.WebhookTimestampHeaderUnix seconds
x-calmlens-deliverycalmlens.WebhookDeliveryHeaderA 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:

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)
})
FunctionUse
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 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:

// 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:

  • 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 and Viewer region.

Last updated

Signing in

Signing out

Cookies on CalmLens

We use essential cookies for sign-in and security. With your permission, functional cookies remember your preferences and Google Analytics measures website use and signups. Optional ad measurement cookies link Google ad clicks to signups. Personalised advertising stays off. Manage cookies