MenuEvents API

API reference

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). It needs an API key with the events scope; see Authentication and API keys.

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" } ] }
FieldRequiredRules
syesThe site id. It must be the site the key belongs to.
eyespageview, $identify, or a custom name of 1 to 64 letters, digits, spaces, underscores or hyphens
uyesAbsolute http or https URL of the page, at most 2,048 bytes. Its host must be one of the site's hostnames.
rnoReferrer URL, at most 2,048 bytes
tnoPage title; longer than 512 bytes is truncated
wnoViewport width, 0 to 32,767
lnoLanguage, such as en-US; truncated at 35 bytes
uidfor $identifyYour user id, at most 128 bytes. Required when e is $identify.
pnoA JSON object of properties, at most 2,048 bytes
tsnoUnix time in milliseconds. Times more than an hour in the past or a minute in the future are replaced by the time of receipt.
ipnoThe visitor's IP address, used for the visitor hash and country
uanoThe 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.

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:

StatusCodeWhen
400invalid_inputMalformed JSON, an invalid field, more than 50 events, a host not on the site, or events for different sites in one batch
401unauthorizedMissing, malformed or revoked key
403forbiddenThe key lacks the events scope, or s is another site
404not_foundUnknown site
413invalid_inputBody larger than 8 KB
429quota_exceededThe account is over its monthly event allowance; Retry-After gives the seconds to the next UTC month
503rate_limitedThe 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.

Examples

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)"}'
// 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());
}
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)
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.

    ↑ ↓ to move, Enter to open