> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Web SDK and the public-key identify/track model

> Why the web personalization SDK mounts at /sdk/* on the root host under a browser-safe public key (pk_), how /sdk/identify stitches an anonymous visitor to a known contact, and how the security envelope differs from both the signed pixels and the authenticated API.

# Web SDK and the public-key identify/track model

Orbit exposes a second public surface next to the signed pixels and redirects: the **web personalization SDK** endpoints, mounted at `/sdk/*` on the root host — deliberately outside the `/api/v1` version prefix the authenticated API uses. The `@devotel-orbit/web` browser SDK calls them from your site's JavaScript with your tenant's **public API key** (`dv_live_pk_…`), so visitors can be identified and their behavior tracked without any server round-trip of your own.

This page explains why the public key is safe to ship in a browser bundle, how `/sdk/identify` reconciles an anonymous visitor into a known contact, and where the security envelope differs from the [public pixels and redirects](/concepts/public-pixels-redirects) family.

## Two public surfaces, two different contracts

Orbit has exactly two bands of endpoints reachable without a secret credential, and they solve different problems:

|              | Pixels / redirects (`/p`, `/g`, `/r`, `/l`, `/lp`) | Web SDK (`/sdk/*`)                        |
| ------------ | -------------------------------------------------- | ----------------------------------------- |
| Caller       | An email client or browser following a link        | Your site JavaScript via the SDK          |
| Auth         | HMAC-SHA256 embedded in each URL                   | `X-API-Key: dv_live_pk_…` header          |
| Writes       | None — engagement markers only                     | Contacts + event analytics, tenant-scoped |
| Failure mode | Always render a GIF / branded error page           | JSON validation envelope                  |

A pixel URL is self-authenticating: the signature travels inside the URL, so no key is needed and there is nothing to revoke. The SDK surface does real writes — creating and updating contacts, recording behavior — so it needs to know *which tenant* it is writing for. That is what the public key resolves: one hashed lookup binds the request to exactly one tenant schema before any body is parsed. Both families are fail-closed on auth and fail-neutral on response shape; their abuse defenses are per-IP rate limits, shared with the rest of the public band.

For the HMAC-signed families, see [Public pixels, redirects, and short links](/concepts/public-pixels-redirects).

## Why a public key (pk\_) is safe to ship in browser JS

The `dv_live_pk_` key is designed to be visible. It will appear in the source of every page that embeds the SDK, and that is fine, for four reasons:

1. **Read-only scopes.** Public keys are created with read scopes only; the platform rejects a write or administrative scope on a `pk` at creation. A leaked public key can never reach the messaging, billing, or admin surfaces that a secret key (`dv_live_sk_`) unlocks. See [Authentication and session model](/concepts/authentication-model).
2. **Writes confined to a narrow band.** The only write endpoints a public key can reach are `POST /sdk/identify`, `POST /sdk/track`, and `POST /sdk/in-app/events` — and all three write into the key's own tenant schema, nothing else. A leaked `pk` lets someone push behavior events or identification calls *into your own audience data*, which is spam-class abuse bounded by rate limits (below), not a credential for reading or mutating your account.
3. **Hash-stored, revocation-checked.** The platform stores `sha256(key)`, never the plaintext, and every request re-checks that the key is active, unrevoked, unexpired, and — critically — of type `public`. A secret key with an altered prefix cannot authenticate the SDK surface; a key belonging to a deleted organization stops resolving immediately.
4. **No version prefix in the URL.** The `/sdk/*` routes mount on the root host, not under `/api/v1`, so your embed URL is stable across API versions: `https://api.orbit.devotel.io/sdk/track` never needs re-embedding when `/api/v2` arrives. Pixel and redirect URLs live at the root for the same reason — the path a customer site embeds is a contract, and version churn belongs to the authenticated API.

```http theme={null}
POST /sdk/track HTTP/1.1
Host: api.orbit.devotel.io
X-API-Key: dv_live_pk_8dQ…
Content-Type: application/json

{
  "anonymous_id": "anon_wK3p2m",
  "events": [
    { "name": "checkout_started", "properties": { "cart_total_cents": 14999 } }
  ]
}
```

## Stitching: how identify reconciles an anonymous visitor

Every SDK session starts anonymous. The SDK mints an `anonymous_id` and tracks events under that id alone — no contact exists yet, so rows land with a null contact and the anonymous id as the only join key.

Stitching happens when the site learns *who* the visitor is — a login, a checkout form, a phone or email capture — and calls `POST /sdk/identify` with that strong identifier:

```http theme={null}
POST /sdk/identify HTTP/1.1
X-API-Key: dv_live_pk_8dQ…

{ "anonymous_id": "anon_wK3p2m", "email": "ada@example.com", "display_name": "Ada Lovelace" }
```

```
HTTP/1.1 200 OK

{ "data": { "contact_id": "contact_9fMRA", "anonymous_id": "anon_wK3p2m" } }
```

What the call does, in order:

1. **Resolve or create the contact.** Phone is the preferred arbiter (a phone can unlock messaging and voice); email is the fallback. An existing live contact with the identifier is updated in place; otherwise a new contact row is created. Idempotent by consequence: re-calling with the same pair returns the same contact.
2. **Backfill the pre-identify funnel.** Every event row captured under this `anonymous_id` gets its contact stamped, so the contact timeline in the dashboard shows the full journey the person actually traveled — anonymous browsing included — not just "first seen after they logged in."
3. **Record the surfaces the stitch unlocks.** Web tracking is the immediate backfill. Messaging, voice, and video self-identify on the same phone or email the next time the person arrives through those channels — no further stitching call is needed, because each inbound channel keys resolution on the identifier it carries.

When the anonymous session and the known person already sit on **two separate contact rows** (the browser stitched late to a contact an inbound call created earlier), the browser-side stitch stops at attribution — the operator-side merge is the [CDP identity resolution](/concepts/cdp-identity-resolution) surface, which collapses the two rows under audit.

### In-app events vs pixel events

`POST /sdk/in-app/events` ingests engagement receipts for the in-app message and content-card surfaces the SDK renders — one call per `impression`, `click`, or `dismiss`. Three properties distinguish it from both `/sdk/track` and the pixels:

* It is **anonymous by design**: the payload carries an `anonymous_id` (plus your optional host-app user id) and lands with a null contact, then rides the same identify backfill as track events.
* It writes to the **same analytics sink** as `/sdk/track`, so card engagement appears on the contact timeline next to site behavior rather than in a silo.
* Unlike a pixel, it is **not attribution against a message send** — an in-app surface is an owned channel with no carrier leg. "Delivery" is the SDK pulling the feed; the events endpoint only reports what the visitor did with it. See [In-app channel model](/concepts/in-app-channel-model).

## Security envelope

The SDK band shares the perimeter posture of the pixels but replaces URL-signing with key resolution:

* **CORS is global.** The shared application plugin answers cross-origin requests from arbitrary origins — your visitors' browsers call from your domain, not Orbit's, so an origin allow-list would break the model. Origin is therefore *not* an authentication signal here; the key is.
* **Per-IP rate limits.** Both the public-keyed endpoints and the anonymous beacon fallback carry their own per-IP budgets, so a leaked `pk` degrades to bounded event-spam, not a flood.
* **No PKCE, no Clerk.** Those belong to dashboard sessions and OAuth flows. Browser embeds never perform a login handshake; identity arrives only through the explicit `/sdk/identify` call your site chooses to make.
* **Payload discipline.** Event property payloads are size-bounded and the whole body is schema-validated before a single write, so malformed or bloated batches get a `422`, not a database row.

The anonymous beacon fallback (`POST /sdk/track/anonymous`) deserves a note: old browsers that cannot keep a request alive across page unload send events with **no key at all** into a staging holding area tied to nothing. A reconcile pass attributes staged rows to a tenant only after a later identify call binds the same `anonymous_id` — until then no tenant association exists, so there is nothing to leak cross-tenant. Modern browsers carry the unload flush with the keyed fetch path and never touch the fallback.

## Common pitfalls

* **Shipping a secret key in the bundle.** A `dv_live_sk_` key in client-side JavaScript hands account-level access to anyone who views source. Use the public key for all browser surfaces; secret keys stay on your server.
* **Identifying on every page load.** Identify once per session, when a strong identifier appears. Re-identification is idempotent, but it is wasted bandwidth; the backfill is a one-way stamp.
* **Expecting stitching to merge two contact rows.** Identify attributes the anonymous funnel to a *resolved* contact. When an operator later discovers the person duplicated across two rows, that consolidation belongs to the identity-resolution merge, under audit — the browser surface never collapses rows.
* **Reading "no contact yet" as "no data yet".** Pre-identify events are stored with the anonymous id alone and get attached on identify. Reports filtered to known contacts simply don't show the anonymous tail until stitching happens.

## Cross-links

* [Public pixels, redirects, and short links](/concepts/public-pixels-redirects) — the HMAC-signed public band this surface complements.
* [Authentication and session model](/concepts/authentication-model) — public vs secret key forms and how resolution is decided.
* [CDP event model](/concepts/cdp-event-model) — how tracked events flow into profiles, segments, and activation.
* [CDP identity resolution](/concepts/cdp-identity-resolution) — the operator-side merge for contacts already split across rows.
* [In-app channel model](/concepts/in-app-channel-model) — the owned channel behind `/sdk/in-app/feed` and its engagement events.
* [Web SDK](/sdks/web) — installation, modules, and usage of `@devotel-orbit/web`.
