Data-plane endpoints and credentials

The data plane is the small set of URLs that serve your configuration and receive your events. It is separate from the management API, which is what you use to create and change things.

Use this page when you need to add A vs B to a firewall allowlist, a Content Security Policy, or a corporate proxy. It's also the page to check that an SDK is talking to the right place.

Five hosts, that is all

Reads come from cdn.avsb.cloud. Events go to ingest.avsb.cloud. Liveness and the realtime handshake go to app.avsb.cloud, and an SDK with streaming on then holds its live connection to stream.avsb.cloud. Images you upload in the Visual Editor are served from assets.avsb.cloud. Nothing else is contacted.

The endpoint map

WhatMethod and URLWho calls it
Datafile (your configuration)GET https://cdn.avsb.cloud/{sdkKey}/datafile.jsonEvery SDK
Preview snapshot (one per experiment)GET https://cdn.avsb.cloud/{sdkKey}/preview/{experimentId}.jsonSnippet, preview links
Snippet and editor assetsGET https://cdn.avsb.cloud/snippet.js and sibling filesBrowser tag
Exposure batchPOST https://ingest.avsb.cloud/v1/collect/batchEvery SDK, snippet
Single eventPOST https://ingest.avsb.cloud/v1/collectSnippet beacon fallback
Conversion and metric batchPOST https://ingest.avsb.cloud/v1/collect/metric-batchEvery SDK, snippet
PurchasesPOST https://ingest.avsb.cloud/v1/collect/ordersServer SDKs, snippet
Client error reportsPOST https://ingest.avsb.cloud/v1/collect/errorsSnippet
Install-detection pingPOST https://ingest.avsb.cloud/v1/pingSnippet, once per session
Coarse geo lookupGET https://ingest.avsb.cloud/v1/geoSnippet, geo targeting only
SDK liveness (heartbeat)POST https://app.avsb.cloud/api/sdk/heartbeatEvery SDK
Realtime stream ticketGET https://app.avsb.cloud/api/sdk/streamSDKs with streaming on

The realtime stream itself runs against the URL the ticket hands back, https://stream.avsb.cloud/subscribe. See Realtime updates below.

How SDKs derive these URLs

You give an SDK one value: your environment SDK key. Everything else is derived.

TypeScript
import { AvsbClient } from '@avsbhq/browser'// This is all the configuration a working SDK needs.const client = new AvsbClient({ sdkKey: 'sdk_production_xxxxxxxxxxxxxxxx' })
TypeScript4 lines

The rules are the same in every language:

  1. The datafile URL is {cdnHost}/{sdkKey}/datafile.json. The default cdnHost is https://cdn.avsb.cloud. Your key is a path segment, so each environment has its own cacheable object.
  2. Event URLs come from the datafile, not from a constant. The datafile carries a collectEndpoint value, and the SDK appends the collect path it needs. That is what lets us move ingestion without shipping a new SDK version, and what lets a self-hosted proxy redirect events by republishing.
  3. Liveness and streaming come from the datafile too, as heartbeatEndpoint and streamEndpoint. If a field is absent, that feature simply stays off. No SDK guesses a host.

Every SDK reads these from one endpoints module rather than building strings inline. Every URL above is also locked by a shared conformance fixture. That is what stops the Python SDK and the Node SDK from drifting into contacting different addresses.

Overriding hosts

Each SDK exposes a cdnHost option (cdn_host in Python and Ruby) for local development and for proxying. Point it at an origin, never at a full file path: the SDK appends /{sdkKey}/datafile.json itself.

Which credential travels where

There is one credential on the data plane, the SDK key, and it moves in exactly two ways.

RequestHow the key travelsWhy
Datafile readIn the URL pathThe path is the cache key, so the CDN can serve it globally with no origin round-trip
Preview readIn the URL pathSame object layout as the datafile
Static asset readNot sent at allThe files are identical for every customer
Any POST /v1/collect/*In the JSON body, as sdkKeyOne path serves every customer, so the body has to say who is sending
HeartbeatIn the JSON body, as sdkKeySame reason
Stream ticketIn the query string, as ?sdkKey=Browser stream clients cannot set request headers

Two consequences worth stating plainly:

  • There is no Authorization header anywhere on the data plane. If you see one, something is misconfigured. Bearer tokens belong to the management API only.
  • There is no custom key header either. Earlier SDK builds sent an X-SDK-Key header on datafile reads. It was never checked and it has been removed. The one place a key header exists is inbound to the self-hosted agent, which accepts X-Avsb-Sdk-Key on its own API.

Is the SDK key a secret?

No. The SDK key is a public identifier, like a Google Analytics measurement ID. It is safe in browser code, in a mobile app bundle, and in a public repository. It grants read access to that environment's configuration and the ability to send events to that environment, nothing else. It cannot read results, change flags, or reach any other project.

Your personal access tokens and service tokens are secrets. Those are management API credentials and must stay server-side. See Personal access tokens.

Allowlisting and CSP

For a browser-side install, allow these origins:

Plain text
https://cdn.avsb.cloud     read: snippet, datafile, previewhttps://ingest.avsb.cloud  write: eventshttps://app.avsb.cloud     write: heartbeat, read: stream ticket (only with streaming on)https://stream.avsb.cloud  read: live flag updates (only with streaming on)https://assets.avsb.cloud  read: images you upload in the Visual Editor
Plain text5 lines

This is the canonical Content Security Policy for a browser-side install. It is the complete policy a page needs to run experiments, not a starting point:

Plain text
script-src  'self' https://cdn.avsb.cloud 'unsafe-inline' 'unsafe-eval';style-src   'self' 'unsafe-inline';connect-src 'self' https://cdn.avsb.cloud https://ingest.avsb.cloud https://app.avsb.cloud https://stream.avsb.cloud;img-src     'self' https://assets.avsb.cloud;
Plain text4 lines

What each part is for:

  • https://cdn.avsb.cloud in script-src: the snippet bundle and its on-demand pieces load from our CDN.
  • 'unsafe-inline' in script-src: the first half of the install tag is a small inline script that hides the page before the first paint; an external file would arrive too late to prevent the flash it exists to prevent. Nonce variant: if your platform can stamp a per-request nonce, add nonce="..." to BOTH halves of the install tag (the inline stub and the loader tag) and use script-src 'self' 'nonce-...' https://cdn.avsb.cloud 'unsafe-eval' instead; browsers ignore 'unsafe-inline' when a nonce is present, so this is the stricter policy.
  • 'unsafe-eval' in script-src: the code you write in the experiment builder (variation JavaScript, project JavaScript, triggers, Custom JavaScript audiences) is compiled in the visitor's browser; without this directive every experiment that carries custom code stops running.
  • style-src 'unsafe-inline': variation CSS is applied as an inline style element. There is no nonce workaround for styles the runtime injects. If script-src allows the snippet but style-src blocks inline styles, visitors are bucketed and counted while seeing the control's styling; the snippet reports this to your experiment's error log as an apply failure, but the fix is the directive.
  • connect-src: the CDN serves your configuration, ingest.avsb.cloud receives events and error reports, and app.avsb.cloud answers preview links and editor sessions (and the SDK heartbeat and stream ticket where you use them). stream.avsb.cloud carries live flag updates to a browser SDK with streaming on; without it the stream is blocked and the SDK falls back to polling.
  • img-src https://assets.avsb.cloud: images you upload in the editor are served from there.

For a server-side install, the cdn, ingest and app origins (and stream, with streaming on) apply as outbound HTTPS from your servers. Server SDKs make no inbound connections, so nothing needs to be opened up in the other direction, and no Content Security Policy applies.

Event bodies

Every ingestion request is POST with Content-Type: application/json. The envelope names the key and carries an array:

JSON
{  "sdkKey": "sdk_production_xxxxxxxxxxxxxxxx",  "events": [    {      "eventId": "8f14e45f-ceea-467a-9c1e-8b1d4f0b2b11",      "visitorId": "user_42",      "experimentId": "clx1rule9k0001s8fh2a1b3c4d",      "variationId": "clx1var7p0002s8fh5e6f7g8h",      "eventName": "flag_exposure",      "revenue": null,      "timestamp": 1780000000000,      "pageUrl": ""    }  ]}
JSON15 lines

Notes that save debugging time:

  • eventId is yours to set. It is a UUID the SDK stamps once, so a retried batch is deduplicated instead of double counted.
  • timestamp is milliseconds since the Unix epoch, as a number.
  • pageUrl is an empty string on a server, which is expected and valid.
  • Batches hold at most 100 items. Over that, the request is rejected as a whole, so SDKs chunk.
  • flag_exposure and flag_holdout_exposure are reserved event names. Everything else in eventName is a metric key you defined.
  • Attributes must be registered in the dashboard to be queryable. Unregistered keys are accepted and dropped, which is what makes a typo look like an empty attribute rather than an error.

The metric batch uses metricEvents and the orders batch uses orders in place of events. Both take the same envelope and the same sdkKey field.

An order retries differently than a regular event

A regular event is deduplicated by eventId, described above. An order is different: it is stored keyed by orderId, and whichever copy has the newest updatedAt wins. A webhook is an automatic request your store platform sends us when an order changes, and it can replay the same delivery more than once. So can your own retry after a network error. Either way, you can safely resend the same order: the newer send overwrites the older one instead of creating a duplicate.

What a batch answers

A batch is accepted event by event, not all or nothing. Every valid event is stored, and anything rejected is named by its position in the array you sent:

JSON
{  "accepted": 2,  "rejected": [    { "index": 1, "code": "missing_field", "message": "visitorId is required" }  ]}
JSON6 lines

The status is 200 whenever the envelope itself made sense, including when every event was rejected (accepted: 0). A 400 means the body was not the shape this endpoint takes at all, so there were no individual events to judge.

code comes from a fixed set, safe to switch on:

CodeWhat it means
invalid_shapeThe event was not an object, so nothing could be read from it.
missing_fieldA required field was absent.
bad_typeA field was present with the wrong type, for example a timestamp sent as a string.
invalid_valueThe type was right but the value was not allowed, for example a negative quantity.
too_largeA field was longer or larger than its limit.
unknown_eventThe event names a kind this endpoint does not handle (commerce events only).

A rejected event is not retried for you. It was understood and refused, so sending it again unchanged produces the same answer. Fix the payload instead.

Two answers are worth retrying, and both carry a Retry-After header. 429 means the rate limit refused the batch (see below). 503 means the queue behind the endpoint would not take it. In both cases nothing was stored, so resend the whole batch, unchanged, after the stated number of seconds.

Ingest rate limits

Every collect endpoint allows 120 requests per minute per SDK key, per visitor IP, counted over a one-minute window:

  • POST /v1/collect
  • POST /v1/collect/batch
  • POST /v1/collect/metric-batch
  • POST /v1/collect/errors
  • POST /v1/collect/orders
  • POST /v1/collect/commerce

The allowance is per visitor, not per site. The snippet in one browser sends at most a few dozen requests a minute, so a busy site is never slowed down by all of its visitors sharing one allowance; only a single visitor, or a single server, sending far more than the snippet ever would is refused.

Two endpoints are counted differently:

  • POST /v1/ping, the install-detection ping, allows 60 requests per minute per visitor IP. It fires about once per session, so a normal visitor uses one or two.
  • Commerce platform webhooks (Shopify, WooCommerce, BigCommerce) allow 600 requests per minute per IP.

Because the limit counts requests and not events, batching is what keeps you inside it: 120 batches of 100 events is 12,000 events a minute from one visitor.

A refused batch is not lost. The snippet keeps it and sends it again once the seconds in Retry-After have passed (it never waits longer than 30 seconds), with the same events, so nothing is double-counted. If you send from your own code, do the same: hold the batch and resend it after Retry-After.

Every response carries the current state of your bucket, on success and on failure alike:

  • X-RateLimit-Limit: requests allowed per minute.
  • X-RateLimit-Remaining: requests left in this window.
  • X-RateLimit-Reset: unix time in seconds when the window resets.
  • Retry-After: seconds to wait. Present on 429, and on the 503 queue-failure case.

The three X-RateLimit-* headers are exposed through CORS, so browser code can read them from a fetch response just as a server can.

Realtime updates

Streaming is a two-step handshake, so that a browser can hold an authenticated stream without setting headers:

1

Ask for a ticket

The SDK calls GET {streamEndpoint}?sdkKey=..., where streamEndpoint comes from the datafile. The response is { url, token, expiresInMs }.

2

Open the stream

The SDK opens GET {url}?sdkKey=...&token=... and reads server-sent events. The token is valid for 60 seconds and for that one environment, so it is safe in a URL.

3

Refetch on notice

A datafile_updated message means "there is something new", not "here is the new thing". The SDK refetches the datafile from the CDN, which keeps the stream cheap and keeps the CDN as the single source of configuration.

If the datafile carries no streamEndpoint and you did not pass one explicitly, streaming stays off. The SDK logs why, and polling continues to deliver changes on its normal interval. Nothing silently retries against an address that does not exist.

Heartbeat

After a successful datafile fetch, an SDK posts a small body to the heartbeat endpoint:

JSON
{  "sdkKey": "sdk_production_xxxxxxxxxxxxxxxx",  "sdkType": "@avsbhq/node",  "sdkVersion": "1.8.0"}
JSON5 lines

sdkType and sdkVersion name the package you installed. A framework package reports its own name, such as @avsbhq/react or @avsbhq/next, rather than the client underneath it, so the Environments page shows what your app really runs.

That is what drives the Connected, Stale, and Not connected states on the Environments page. An SDK that cannot reach the heartbeat endpoint still works normally: flags evaluate, events send, and only the status badge is affected.

Was this helpful?