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
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:
{ "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.
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:
| 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.
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.