# Server-side events

Send events from your backend to POST /api/v1/events with an API key, when an action happens on the server rather than in the browser.

Some events happen on your server: a trial that converts from a background job, a signup through your API, an action in a mobile app backend. Send these to `POST https://api.productlense.app/api/v1/events` with an API key. The body is the same as the browser script sends, plus the visitor's IP address and user agent so the event joins the same visitor as their browser activity.

## Before you start

- Server-side events need an API key, which is part of the Scale plan. An owner or admin creates it in Settings → API keys with the `events` scope. The key starts with `mk_live_` and is shown once; store it as a secret, never in browser code.
- The browser script is still the main source of pageviews. Server-side events add to it.

## Send an event

```bash tab="cURL" {2,6-7}
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 converted","u":"https://example.com/app/billing",
       "uid":"user_123","p":{"plan":"growth"},
       "ip":"203.0.113.7",
       "ua":"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15"}'
```

```js tab="Node" {10-11}
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 converted',
    ip: req.ip, // the visitor's address, from the original request
    ua: req.get('user-agent'),
    u: 'https://example.com/app/billing',
    uid: user.id,
    p: { plan: 'growth' },
  }),
});
if (res.status !== 204) {
  const body = await res.json();
  throw new Error(`ProductLense rejected the event: ${body.error.code}: ${body.error.message}`);
}
```

```python tab="Python" {11-12}
import os
import requests

res = requests.post(
    "https://api.productlense.app/api/v1/events",
    headers={"Authorization": f"Bearer {os.environ['PL_API_KEY']}"},
    json={
        "s": os.environ["PL_SITE_ID"],
        "e": "Trial converted",
        "u": "https://example.com/app/billing",
        "ip": request.remote_addr,  # the visitor's address
        "ua": request.headers.get("User-Agent", ""),
        "uid": user.id,
        "p": {"plan": "growth"},
    },
    timeout=10,
)
if res.status_code != 204:
    err = res.json()["error"]
    raise RuntimeError(f"ProductLense rejected the event: {err['code']}: {err['message']}")
```

```go tab="Go" {17-18}
package productlense

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"time"
)

type Event struct {
	Site   string            `json:"s"`
	Name   string            `json:"e"`
	URL    string            `json:"u"`
	UserID string            `json:"uid,omitempty"`
	IP     string            `json:"ip,omitempty"`
	UA     string            `json:"ua,omitempty"`
	Props  map[string]string `json:"p,omitempty"`
}

var client = &http.Client{Timeout: 10 * time.Second}

func Send(ev Event) error {
	body, err := json.Marshal(ev)
	if err != nil {
		return err
	}
	req, err := http.NewRequest(http.MethodPost, "https://api.productlense.app/api/v1/events", bytes.NewReader(body))
	if err != nil {
		return err
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("PL_API_KEY"))
	req.Header.Set("Content-Type", "application/json")
	res, err := client.Do(req)
	if err != nil {
		return err
	}
	defer res.Body.Close()
	if res.StatusCode != http.StatusNoContent {
		return fmt.Errorf("productlense: events API returned %s", res.Status)
	}
	return nil
}
```

A successful request returns `204 No Content` with an empty body. Errors use the JSON envelope described in [errors](/docs/api/errors).

## Rules

- `s` must be the site the API key belongs to; any other site id is refused with `403 forbidden`.
- `e` is `pageview`, `$identify`, or a custom name of 1 to 64 letters, digits, spaces, underscores or hyphens.
- `u` is required: an absolute `http` or `https` URL whose hostname is one of the site's hostnames. Use the page the action relates to.
- `ip` and `ua` are optional and accepted only on this endpoint. Without them, the event is counted against your server's own address and user agent, which joins it to none of your visitors and may be dropped as a bot (user agents such as `curl/`, `python-requests` or `Go-http-client` are filtered). Pass the visitor's values from the original request.
- `uid` attaches your user id, up to 128 bytes. `p` is a JSON object of up to 2 KB.
- `ts` is optional, in milliseconds. A time more than an hour in the past or more than a minute in the future is replaced by the time ProductLense receives the event.
- Send up to 50 events in one request with `{ "s": "SITE_ID", "b": [ {...}, {...} ] }`. Every event in a batch belongs to the same site. The whole body is limited to 8 KB.
- Every event except `$identify` counts toward your plan's monthly events.

See the [Events API reference](/docs/api/events) for every field and status code, and the [Payments API](/docs/revenue/payments-api) for sending payments.

## Verify your installation

1. Send one test event as above and check that the response status is `204`.
2. Open the Live page in the ProductLense dashboard. The event appears within a few seconds, and under Goals once you add its name as a goal.
3. If you get `400`, read the message: it names the field that failed, for example `hostname not allowed for this site`.

Requests without `ip` and `ua` from a server user agent are dropped as bots without an error. If events do not appear, follow [Verify your installation](/docs/install/verify).
