# Sending events (/docs/api/log)

Every event reaches numbers through one endpoint. The script and the SDK call it for you; call it yourself to send events from a server.

```
POST https://numbers.obelism.studio/api/log
Content-Type: text/plain;charset=UTF-8
```

One event or exposure per request, at most 8 KB. The body is JSON either way: the script and SDK send it as `text/plain` because that is CORS-safelisted, so the browser skips the preflight and an event costs one request. A JSON `Content-Type` also works; it costs a preflight, which is cached for a day.

## Who may send [#who-may-send]

- **A browser**, from an origin allowed for the Space and environment (on the Space's page on [www.obelism.studio](https://www.obelism.studio), under **Allowed origins**). `develop` allows `localhost` on any port.
- **A server**, with one of the environment's API tokens in a `token` header. The tokens are numbers' own: make one under **API tokens** on the [setup page](https://numbers.obelism.studio/setup), for the Space and environment.

## The body [#the-body]

```ts
type Common = {
	organizationId: string
	// The Space the event belongs to. Without it, the Organization's first.
	spaceId?: string
	environment: 'develop' | 'staging' | 'production'
	visitorId: string // the obelism_visitor_id cookie: `visi_` and 26 characters

	pointer: 'coarse' | 'fine'
	device:
		| 'wearable'
		| 'mobile'
		| 'tablet'
		| 'console'
		| 'smarttv'
		| 'embedded'
		| 'desktop'
	browser:
		| 'chrome'
		| 'safari'
		| 'firefox'
		| 'edge'
		| 'ie'
		| 'samsung internet'
		| 'social'
		| 'other'
	os: 'mac os' | 'ios' | 'android' | 'windows' | 'unix'
	screen: 'small' | 'medium' | 'large' | 'huge' | '' // '' from a server, which doesn't know it
	visitor: 'recurring' | 'new'
}

type Event = Common & {
	type: 'event'
	event: string // `page_view`, `click`, `sign_up`, …
	scope: string // required, not empty
	value?: number
	currency?: string // at most 8 characters
	params?: Record<string, unknown>

	// The page it happened on (all optional).
	path?: string // starts with "/"; the query string is dropped
	referrer?: string // a URL; kept without its query string
	utmSource?: string
	utmMedium?: string
	utmCampaign?: string
}

// A visitor's variant of an Improve test or flag, sent by the Improve SDK.
type Exposure = Common & {
	type: 'exposure'
	subjectKind: 'test' | 'flag'
	subjectId: string
	variant: string
}
```

Strings are at most 256 characters, `path` 1024.

## Example [#example]

```bash
curl https://numbers.obelism.studio/api/log \
  -H 'Content-Type: application/json' \
  -H 'token: <API token>' \
  -d '{
    "type": "event",
    "event": "purchase",
    "scope": "checkout",
    "value": 59.95,
    "currency": "EUR",
    "organizationId": "org_…",
    "spaceId": "space_…",
    "environment": "production",
    "visitorId": "visi_…",
    "pointer": "fine",
    "device": "desktop",
    "browser": "other",
    "os": "unix",
    "screen": "large",
    "visitor": "recurring"
  }'
```

Pass the visitor's `obelism_visitor_id` cookie along from the request, so the event counts for the same visitor as their page views. The old `visitorId` cookie is read too, for visitors from before the rename.

## From a storefront [#from-a-storefront]

A React Router loader or action on [Shopify Oxygen](https://content.obelism.studio/docs/getting-started/oxygen) runs on the server, so it sends like any other server: the environment's API token in `token`, the visitor's `obelism_visitor_id` cookie, and no `Origin` header. The token belongs to one Space and environment, so name the same Space in `spaceId`.

The device fields come from the user agent: `detectDevice()` in `@obelism/numbers-sdk` gives `pointer`, `device`, `browser` and `os`. `screen` is a browser width bucket, and a server doesn't know it, so send `''`.

```ts title="app/lib/numbers.server.ts"
import { detectDevice } from '@obelism/numbers-sdk'

const readCookie = (header: string | null, name: string) =>
	header
		?.split('; ')
		.find((row) => row.split('=')[0] === name)
		?.split('=')[1] ?? null

type Order = { id: string; total: number; currency: string }

export const sendOrder = async (request: Request, env: Env, order: Order) => {
	const visitorId =
		readCookie(request.headers.get('cookie'), 'obelism_visitor_id') ??
		readCookie(request.headers.get('cookie'), 'visitorId')
	if (!visitorId) return

	await fetch('https://numbers.obelism.studio/api/log', {
		method: 'POST',
		headers: {
			'Content-Type': 'application/json',
			token: env.NUMBERS_API_TOKEN,
		},
		body: JSON.stringify({
			type: 'event',
			event: 'purchase',
			scope: 'checkout',
			organizationId: env.NUMBERS_ORGANIZATION_ID,
			spaceId: env.NUMBERS_SPACE_ID,
			environment: env.NUMBERS_ENVIRONMENT,
			visitorId,
			...detectDevice(request.headers.get('user-agent') ?? ''),
			screen: '',
			visitor: 'recurring',
			value: order.total,
			currency: order.currency,
			params: { order_id: order.id },
			path: '/checkout',
		}),
	})
}
```

Send it once per order: from the order's `orders/paid` webhook, or from the confirmation route guarded by the order id, not on every render of it.

Two things a server request doesn't have. **The visitor's IP**: the rate limit of 300 a minute keys on it, and a request from Oxygen has none, so all server events share one bucket per Organization. Send one event per order, and retry after a `429`'s `Retry-After`. **The country**: numbers reads it from the request's edge headers, which a Worker's subrequest usually doesn't carry, so the event may have no country. Browser events still do.

## The answer [#the-answer]

`200` with `{ "success": true }` once the event is queued; it is stored within seconds. Anything else is an [error](/docs/api/errors) in problem JSON. The `Api-Version` header is optional; this API is version `1`.
