# Events API

POST /api/v1/events records pageviews, custom events and identify calls from your server, with the visitor's IP and user agent so they match the browser.

`POST https://api.productlense.app/api/v1/events` takes the same body as the browser script, from your own server. Use it for actions that only your backend sees, such as a trial that starts after email confirmation, or to forward events through your own domain (see [Proxy the script through your own domain](/docs/proxy/overview)). It needs an API key with the `events` scope; see [Authentication and API keys](/docs/api/authentication).

## Request

```http
POST /api/v1/events HTTP/1.1
Host: api.productlense.app
Authorization: Bearer mk_live_...
Content-Type: application/json

{"s":"SITE_ID","e":"Trial started","u":"https://example.com/app","uid":"user_123","p":{"plan":"growth"},"ip":"203.0.113.7","ua":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15"}
```

One event, or a batch of up to 50 sharing the same site:

```json
{ "s": "SITE_ID", "b": [ { "e": "pageview", "u": "https://example.com/" }, { "e": "Signup", "u": "https://example.com/welcome", "uid": "user_123" } ] }
```

| Field | Required | Rules |
|---|---|---|
| `s` | yes | The site id. It must be the site the key belongs to. |
| `e` | yes | `pageview`, `$identify`, or a custom name of 1 to 64 letters, digits, spaces, underscores or hyphens |
| `u` | yes | Absolute `http` or `https` URL of the page, at most 2,048 bytes. Its host must be one of the site's hostnames. |
| `r` | no | Referrer URL, at most 2,048 bytes |
| `t` | no | Page title; longer than 512 bytes is truncated |
| `w` | no | Viewport width, 0 to 32,767 |
| `l` | no | Language, such as `en-US`; truncated at 35 bytes |
| `uid` | for `$identify` | Your user id, at most 128 bytes. Required when `e` is `$identify`. |
| `p` | no | A JSON object of properties, at most 2,048 bytes |
| `ts` | no | Unix time in milliseconds. Times more than an hour in the past or a minute in the future are replaced by the time of receipt. |
| `ip` | no | The visitor's IP address, used for the visitor hash and country |
| `ua` | no | The visitor's user agent, used for the visitor hash, device, browser and bot filter |

The whole body is limited to 8 KB. Unknown fields are rejected.

> [!IMPORTANT]
> Always send `ip` and `ua` from the original browser request. Without them ProductLense uses your server's address and HTTP client, so every event looks like one visitor, and clients such as `curl`, `python-requests` and `Go-http-client` are dropped as bots.

## Response

`204 No Content` with no body when the events are accepted. Events whose user agent looks automated are dropped silently and still answer 204. Errors use the [error envelope](/docs/api/errors):

| Status | Code | When |
|---|---|---|
| 400 | `invalid_input` | Malformed JSON, an invalid field, more than 50 events, a host not on the site, or events for different sites in one batch |
| 401 | `unauthorized` | Missing, malformed or revoked key |
| 403 | `forbidden` | The key lacks the `events` scope, or `s` is another site |
| 404 | `not_found` | Unknown site |
| 413 | `invalid_input` | Body larger than 8 KB |
| 429 | `quota_exceeded` | The account is over its monthly event allowance; `Retry-After` gives the seconds to the next UTC month |
| 503 | `rate_limited` | The ingest queue is full; retry after the `Retry-After` seconds (2) |

## Quota

Every accepted event counts toward the account's monthly events, except `$identify`. See [Plans and limits](/docs/account/plans-and-limits).

## Examples

```bash tab="cURL"
curl -X POST https://api.productlense.app/api/v1/events \
  -H "Authorization: Bearer $PL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"s":"SITE_ID","e":"Trial started","u":"https://example.com/app","uid":"user_123","p":{"plan":"growth"},"ip":"203.0.113.7","ua":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"}'
```

```js tab="Node"
// In a request handler: forward the visitor's IP and user agent.
export async function trackTrialStarted(req, userId) {
  const res = await fetch('https://api.productlense.app/api/v1/events', {
    method: 'POST',
    headers: { Authorization: `Bearer ${process.env.PL_API_KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      s: process.env.PL_SITE_ID,
      e: 'Trial started',
      u: 'https://example.com/app',
      uid: userId,
      p: { plan: 'growth' },
      ip: req.ip,
      ua: req.headers['user-agent'] ?? '',
    }),
  });
  if (res.status !== 204) console.error('productlense events', res.status, await res.text());
}
```

```python tab="Python"
import os
import requests

def track_trial_started(request, user_id):
    res = requests.post(
        "https://api.productlense.app/api/v1/events",
        json={
            "s": os.environ["PL_SITE_ID"],
            "e": "Trial started",
            "u": "https://example.com/app",
            "uid": user_id,
            "p": {"plan": "growth"},
            "ip": request.remote_addr,
            "ua": request.headers.get("User-Agent", ""),
        },
        headers={"Authorization": f"Bearer {os.environ['PL_API_KEY']}"},
        timeout=5,
    )
    if res.status_code != 204:
        print("productlense events", res.status_code, res.text)
```

```go tab="Go"
func trackTrialStarted(r *http.Request, userID string) error {
	ip, _, _ := net.SplitHostPort(r.RemoteAddr)
	body, _ := json.Marshal(map[string]any{
		"s":   os.Getenv("PL_SITE_ID"),
		"e":   "Trial started",
		"u":   "https://example.com/app",
		"uid": userID,
		"p":   map[string]string{"plan": "growth"},
		"ip":  ip,
		"ua":  r.UserAgent(),
	})
	req, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, "https://api.productlense.app/api/v1/events", bytes.NewReader(body))
	req.Header.Set("Authorization", "Bearer "+os.Getenv("PL_API_KEY"))
	req.Header.Set("Content-Type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer res.Body.Close()
	if res.StatusCode != http.StatusNoContent {
		return fmt.Errorf("productlense events: %d", res.StatusCode)
	}
	return nil
}
```

Behind a load balancer, take the visitor's IP from the header your proxy sets instead of the socket address.
