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.
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
| What | Method and URL | Who calls it |
|---|---|---|
| Datafile (your configuration) | GET https://cdn.avsb.cloud/{sdkKey}/datafile.json | Every SDK |
| Preview snapshot (one per experiment) | GET https://cdn.avsb.cloud/{sdkKey}/preview/{experimentId}.json | Snippet, preview links |
| Snippet and editor assets | GET https://cdn.avsb.cloud/snippet.js and sibling files | Browser tag |
| Exposure batch | POST https://ingest.avsb.cloud/v1/collect/batch | Every SDK, snippet |
| Single event | POST https://ingest.avsb.cloud/v1/collect | Snippet beacon fallback |
| Conversion and metric batch | POST https://ingest.avsb.cloud/v1/collect/metric-batch | Every SDK, snippet |
| Purchases | POST https://ingest.avsb.cloud/v1/collect/orders | Server SDKs, snippet |
| Client error reports | POST https://ingest.avsb.cloud/v1/collect/errors | Snippet |
| Install-detection ping | POST https://ingest.avsb.cloud/v1/ping | Snippet, once per session |
| Coarse geo lookup | GET https://ingest.avsb.cloud/v1/geo | Snippet, geo targeting only |
| SDK liveness (heartbeat) | POST https://app.avsb.cloud/api/sdk/heartbeat | Every SDK |
| Realtime stream ticket | GET https://app.avsb.cloud/api/sdk/stream | SDKs 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.
import { AvsbClient } from '@avsbhq/browser'// This is all the configuration a working SDK needs.const client = new AvsbClient({ sdkKey: 'sdk_production_xxxxxxxxxxxxxxxx' })The rules are the same in every language:
- The datafile URL is
{cdnHost}/{sdkKey}/datafile.json. The defaultcdnHostishttps://cdn.avsb.cloud. Your key is a path segment, so each environment has its own cacheable object. - Event URLs come from the datafile, not from a constant. The datafile carries a
collectEndpointvalue, 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. - Liveness and streaming come from the datafile too, as
heartbeatEndpointandstreamEndpoint. 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.
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.
| Request | How the key travels | Why |
|---|---|---|
| Datafile read | In the URL path | The path is the cache key, so the CDN can serve it globally with no origin round-trip |
| Preview read | In the URL path | Same object layout as the datafile |
| Static asset read | Not sent at all | The files are identical for every customer |
Any POST /v1/collect/* | In the JSON body, as sdkKey | One path serves every customer, so the body has to say who is sending |
| Heartbeat | In the JSON body, as sdkKey | Same reason |
| Stream ticket | In the query string, as ?sdkKey= | Browser stream clients cannot set request headers |
Two consequences worth stating plainly:
- There is no
Authorizationheader 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-Keyheader 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 acceptsX-Avsb-Sdk-Keyon 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:
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 EditorThis 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:
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;What each part is for:
https://cdn.avsb.cloudinscript-src: the snippet bundle and its on-demand pieces load from our CDN.'unsafe-inline'inscript-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, addnonce="..."to BOTH halves of the install tag (the inline stub and the loader tag) and usescript-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'inscript-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. Ifscript-srcallows the snippet butstyle-srcblocks 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.cloudreceives events and error reports, andapp.avsb.cloudanswers preview links and editor sessions (and the SDK heartbeat and stream ticket where you use them).stream.avsb.cloudcarries 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:
{ "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": "" } ]}Notes that save debugging time:
eventIdis yours to set. It is a UUID the SDK stamps once, so a retried batch is deduplicated instead of double counted.timestampis milliseconds since the Unix epoch, as a number.pageUrlis 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_exposureandflag_holdout_exposureare reserved event names. Everything else ineventNameis 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.
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:
{ "accepted": 2, "rejected": [ { "index": 1, "code": "missing_field", "message": "visitorId is required" } ]}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:
| Code | What it means |
|---|---|
invalid_shape | The event was not an object, so nothing could be read from it. |
missing_field | A required field was absent. |
bad_type | A field was present with the wrong type, for example a timestamp sent as a string. |
invalid_value | The type was right but the value was not allowed, for example a negative quantity. |
too_large | A field was longer or larger than its limit. |
unknown_event | The 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/collectPOST /v1/collect/batchPOST /v1/collect/metric-batchPOST /v1/collect/errorsPOST /v1/collect/ordersPOST /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 on429, and on the503queue-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:
Ask for a ticket
The SDK calls GET {streamEndpoint}?sdkKey=..., where streamEndpoint comes from the datafile. The response is { url, token, expiresInMs }.
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.
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:
{ "sdkKey": "sdk_production_xxxxxxxxxxxxxxxx", "sdkType": "@avsbhq/node", "sdkVersion": "1.8.0"}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.