Tracking
Server-side Tracking
Send events from your backend using the Measurement Protocol. Track payment webhooks, CRM actions, IoT signals, and any server-side activity via a simple REST API.
1. Overview
Server-side tracking (also called the Measurement Protocol) lets you send events to Web Analyzer App from your backend instead of — or in addition to — the client-side JavaScript tracker. This is essential for tracking actions that happen outside the browser.
Common use cases include:
- Payment webhooks — record purchases when Stripe, PayPal, or another payment provider sends a webhook to your server
- Backend events — track user registrations, subscription changes, or account deletions that happen server-side
- CRM integrations — log new leads, deal closures, or support ticket events from your CRM
- IoT / hardware events — send data from devices, sensors, or kiosks that have no browser
- Email campaign tracking — record email opens, link clicks, or unsubscribes from your email platform
- Offline conversions — import phone orders, in-store purchases, or manual entries
2. Authentication
All server-side tracking endpoints require authentication via an API key sent as a Bearer token in the Authorization header.
Base URL
https://webanalyzerapp.com/api/v1
Authorization Header
Authorization: Bearer YOUR_API_KEY
To create an API key:
- Go to your dashboard and click Settings → API Keys
- Click Create API Key and give it a descriptive name
- Copy the key immediately — it is only shown once
- Store it securely in your server's environment variables
3. Single Event Tracking
Send a single event to a specific website.
Endpoint
POST /api/v1/websites/{website_id}/track
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | Yes | Event name (e.g. purchase, signup). Max 128 characters. |
| payload | object | No | Key-value object with event metadata. Max 10 keys, values max 255 characters. |
| visitor_id | string (UUID) | No | Link this event to an existing visitor. Use the ot_vid value from the client-side tracker. |
| url | string | No | The URL associated with the event (e.g. the page where the action originated). |
| referrer | string | No | The referrer URL for attribution purposes. |
Example Request
cURL
curl -X POST https://webanalyzerapp.com/api/v1/websites/1/track \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "purchase",
"payload": {
"order_id": "ORD-1234",
"amount": "49.99",
"currency": "USD",
"plan": "pro"
},
"visitor_id": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://example.com/checkout"
}'
Response
200 OK
{
"ok": true,
"event_id": 4521
}
4. Batch Event Tracking
Send up to 25 events in a single request. Ideal for reducing API calls when processing queues, importing historical data, or handling webhook bursts.
Endpoint
POST /api/v1/websites/{website_id}/track/batch
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
| events | array | Yes | Array of event objects (max 25). Each object has the same fields as the single event endpoint (name, payload, visitor_id, url, referrer). |
Example Request
cURL
curl -X POST https://webanalyzerapp.com/api/v1/websites/1/track/batch \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"events": [
{
"name": "purchase",
"payload": { "order_id": "ORD-1001", "amount": "29.99" },
"visitor_id": "550e8400-e29b-41d4-a716-446655440000"
},
{
"name": "purchase",
"payload": { "order_id": "ORD-1002", "amount": "59.99" },
"visitor_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
},
{
"name": "signup",
"payload": { "source": "landing_page", "plan": "free" }
}
]
}'
Response
200 OK
{
"ok": true,
"processed": 3,
"event_ids": [4522, 4523, 4524]
}
5. Use Cases with Examples
Payment Webhook (Stripe)
Track purchases from Stripe webhook events. The visitor's browser session is long gone by the time the webhook fires, so server-side tracking is the only reliable way to capture this.
PHP (Laravel webhook handler)
// In your Stripe webhook controller
public function handleCheckoutCompleted(Request $request)
{
$session = $request->input('data.object');
Http::withToken(config('services.webanalyzer.api_key'))
->post('https://webanalyzerapp.com/api/v1/websites/1/track', [
'name' => 'purchase',
'visitor_id' => $session['metadata']['visitor_id'] ?? null,
'payload' => [
'order_id' => $session['id'],
'amount' => number_format($session['amount_total'] / 100, 2),
'currency' => strtoupper($session['currency']),
'customer' => $session['customer_email'],
],
'url' => $session['metadata']['page_url'] ?? null,
]);
}
Form Submission (PHP Backend)
Track form submissions from your server after processing the data.
PHP (Laravel controller)
public function submitContactForm(Request $request)
{
// Process the form...
ContactMessage::create($request->validated());
// Track the event server-side
Http::withToken(config('services.webanalyzer.api_key'))
->post('https://webanalyzerapp.com/api/v1/websites/1/track', [
'name' => 'form_submit',
'visitor_id' => $request->input('visitor_id'),
'payload' => [
'form' => 'contact',
'subject' => $request->input('subject'),
],
]);
}
CRM Integration (New Lead Created)
Fire an event when a new lead is created in your CRM system.
Node.js
// When a new lead is created in your CRM
async function onLeadCreated(lead) {
await fetch('https://webanalyzerapp.com/api/v1/websites/1/track', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.WEBANALYZER_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'lead_created',
payload: {
lead_id: lead.id,
source: lead.source,
company: lead.company,
value: String(lead.estimatedValue),
},
}),
});
}
Email Campaign Tracking
Track email opens and clicks from your email service provider's webhooks.
Node.js (Webhook handler)
// Mailgun / SendGrid webhook handler
app.post('/webhooks/email', async (req, res) => {
const { event, recipient, campaign_id } = req.body;
await fetch('https://webanalyzerapp.com/api/v1/websites/1/track', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.WEBANALYZER_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: `email_${event}`, // email_open, email_click, email_unsubscribe
payload: {
campaign_id: campaign_id,
recipient: recipient,
},
}),
});
res.sendStatus(200);
});
IoT / Hardware Events
Track events from physical devices, sensors, or kiosks that have no browser.
Python
import requests
import os
def track_sensor_reading(sensor_id, temperature, humidity):
requests.post(
'https://webanalyzerapp.com/api/v1/websites/1/track',
headers={
'Authorization': f'Bearer {os.environ["WEBANALYZER_API_KEY"]}',
'Content-Type': 'application/json',
},
json={
'name': 'sensor_reading',
'payload': {
'sensor_id': sensor_id,
'temperature': str(temperature),
'humidity': str(humidity),
},
},
)
6. Linking Server Events to Visitors
To connect server-side events with a specific visitor's browser session, you need to pass the visitor ID from the frontend to your backend. The client-side tracker stores the visitor UUID in localStorage under the key ot_vid.
Step 1: Read the visitor ID on the frontend
JavaScript
// Get the visitor ID set by the tracker
const visitorId = localStorage.getItem('ot_vid');
Step 2: Include it in forms or API calls
Hidden form field
<form action="/checkout" method="POST">
<input type="hidden" name="visitor_id" id="wa-visitor-id">
<!-- other fields -->
<button type="submit">Purchase</button>
</form>
<script>
document.getElementById('wa-visitor-id').value =
localStorage.getItem('ot_vid') || '';
</script>
Step 3: Pass it in your Stripe metadata (or similar)
PHP (Creating a Stripe Checkout session)
$session = \Stripe\Checkout\Session::create([
'line_items' => [/* ... */],
'mode' => 'payment',
'success_url' => route('checkout.success'),
'cancel_url' => route('checkout.cancel'),
'metadata' => [
'visitor_id' => $request->input('visitor_id'),
'page_url' => $request->header('Referer'),
],
]);
Step 4: Use the visitor_id in your server-side event
When the webhook fires, extract the visitor ID from the metadata and include it in your tracking call. This links the server event to the correct visitor profile in your dashboard.
Http::withToken(config('services.webanalyzer.api_key'))
->post('https://webanalyzerapp.com/api/v1/websites/1/track', [
'name' => 'purchase',
'visitor_id' => $webhookPayload['metadata']['visitor_id'],
'payload' => ['order_id' => $webhookPayload['id']],
]);
visitor_id, the event will still be recorded and appear on the Events dashboard, but it will not be linked to a specific visitor's journey.
7. Language Examples
Here are complete examples for sending server-side events in popular languages.
PHP (cURL)
<?php
$apiKey = getenv('WEBANALYZER_API_KEY');
$websiteId = 1;
$data = [
'name' => 'server_action',
'payload' => [
'action' => 'user_registered',
'email' => 'user@example.com',
],
];
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => "https://webanalyzerapp.com/api/v1/websites/{$websiteId}/track",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer {$apiKey}",
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode($data),
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200) {
echo "Event tracked successfully\n";
} else {
echo "Error: HTTP {$httpCode} - {$response}\n";
}
Node.js (fetch)
const API_KEY = process.env.WEBANALYZER_API_KEY;
const WEBSITE_ID = 1;
async function trackEvent(name, payload = {}, visitorId = null) {
const body = { name, payload };
if (visitorId) body.visitor_id = visitorId;
const res = await fetch(
`https://webanalyzerapp.com/api/v1/websites/${WEBSITE_ID}/track`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
}
);
if (!res.ok) {
const error = await res.json();
throw new Error(`Tracking failed: ${error.message}`);
}
return res.json();
}
// Usage
await trackEvent('signup', { plan: 'free', source: 'organic' });
Python (requests)
import requests
import os
API_KEY = os.environ['WEBANALYZER_API_KEY']
WEBSITE_ID = 1
BASE_URL = 'https://webanalyzerapp.com/api/v1'
def track_event(name, payload=None, visitor_id=None):
data = {'name': name}
if payload:
data['payload'] = payload
if visitor_id:
data['visitor_id'] = visitor_id
response = requests.post(
f'{BASE_URL}/websites/{WEBSITE_ID}/track',
headers={
'Authorization': f'Bearer {API_KEY}',
'Content-Type': 'application/json',
},
json=data,
timeout=10,
)
response.raise_for_status()
return response.json()
# Usage
track_event('invoice_paid', {
'invoice_id': 'INV-2024-001',
'amount': '149.99',
'currency': 'USD',
})
Ruby (net/http)
require 'net/http'
require 'json'
require 'uri'
API_KEY = ENV['WEBANALYZER_API_KEY']
WEBSITE_ID = 1
def track_event(name, payload: {}, visitor_id: nil)
uri = URI("https://webanalyzerapp.com/api/v1/websites/#{WEBSITE_ID}/track")
body = { name: name, payload: payload }
body[:visitor_id] = visitor_id if visitor_id
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
request = Net::HTTP::Post.new(uri.path)
request['Authorization'] = "Bearer #{API_KEY}"
request['Content-Type'] = 'application/json'
request.body = body.to_json
response = http.request(request)
unless response.is_a?(Net::HTTPSuccess)
raise "Tracking failed: #{response.code} #{response.body}"
end
JSON.parse(response.body)
end
# Usage
track_event('subscription_renewed', payload: {
'plan' => 'pro',
'amount' => '14.99',
})
8. Rate Limits
Server-side tracking endpoints are rate limited to ensure fair usage and platform stability.
| Limit | Value | Scope |
|---|---|---|
| Requests per minute | 60 | Per API key |
| Events per batch | 25 | Per request |
| Monthly event quota | Unlimited (Pro) | Per account |
When you exceed the rate limit, the API returns a 429 Too Many Requests response with a Retry-After header indicating how many seconds to wait before retrying.
429 Response
HTTP/1.1 429 Too Many Requests
Retry-After: 30
{
"ok": false,
"error": "rate_limit_exceeded",
"retry_after": 30
}
9. Error Handling
The API uses standard HTTP status codes. All error responses return a JSON body with an error field.
| Status | Error Code | Description | Action |
|---|---|---|---|
| 200 | - | Success | Event recorded |
| 400 | validation_error | Invalid request body (missing name, too many events, etc.) | Fix the request and retry |
| 401 | unauthorized | Missing or invalid API key | Check your API key |
| 402 | monthly_limit_reached | Monthly event quota exceeded | Upgrade to Pro or wait until next month |
| 403 | plan_required | Pro plan required for server-side tracking | Upgrade to Pro |
| 404 | website_not_found | Website ID does not exist or does not belong to your account | Verify the website ID |
| 429 | rate_limit_exceeded | Too many requests | Wait and retry (see Retry-After header) |
| 500 | server_error | Internal server error | Retry with exponential backoff |
Retry Strategy
For transient errors (429, 500, 502, 503), implement exponential backoff with jitter:
Python — Retry with exponential backoff
import time
import random
import requests
def track_with_retry(name, payload, max_retries=3):
for attempt in range(max_retries):
response = requests.post(
f'{BASE_URL}/websites/{WEBSITE_ID}/track',
headers={
'Authorization': f'Bearer {API_KEY}',
'Content-Type': 'application/json',
},
json={'name': name, 'payload': payload},
timeout=10,
)
if response.status_code == 200:
return response.json()
if response.status_code in (429, 500, 502, 503):
wait = (2 ** attempt) + random.uniform(0, 1)
retry_after = response.headers.get('Retry-After')
if retry_after:
wait = max(wait, int(retry_after))
time.sleep(wait)
continue
# Non-retryable error
response.raise_for_status()
raise Exception(f'Failed after {max_retries} retries')
400, 401, 403, or 404 errors. These indicate a problem with your request that will not be resolved by retrying.
10. Best Practices
Naming Conventions
Use consistent, descriptive event names with a clear naming scheme.
| Pattern | Example | Notes |
|---|---|---|
| object_action | order_completed | Recommended. Groups well in the Events dashboard. |
| category_action | email_opened | Good for grouping by category. |
| snake_case | user_signed_up | Preferred over camelCase or kebab-case for consistency. |
Payload Structure
- Keep payloads flat — no nested objects
- Use string values for all fields (numbers and booleans will be cast to strings)
- Include an identifier when possible (e.g.
order_id,invoice_id) for cross-referencing - Limit payloads to 10 keys maximum
- Avoid sensitive data (passwords, full credit card numbers, personal health info)
Batching for Efficiency
- Use the batch endpoint whenever you have multiple events to send
- Queue events in your application and flush them periodically (e.g. every 5 seconds or every 25 events)
- A batch of 25 events counts as 1 API call, giving you 25x more throughput
- Process webhook bursts (e.g. many Stripe events at once) with the batch endpoint
Idempotency
Webhooks can be delivered more than once. To avoid duplicate events, implement idempotency in your webhook handler:
PHP — Idempotent webhook handler
public function handleWebhook(Request $request)
{
$eventId = $request->input('id');
// Check if we've already processed this webhook
if (Cache::has("webhook_processed:{$eventId}")) {
return response()->json(['ok' => true, 'skipped' => true]);
}
// Process the webhook and track the event
Http::withToken(config('services.webanalyzer.api_key'))
->post('https://webanalyzerapp.com/api/v1/websites/1/track', [
'name' => 'purchase',
'payload' => ['order_id' => $request->input('data.object.id')],
]);
// Mark as processed (TTL: 48 hours)
Cache::put("webhook_processed:{$eventId}", true, 172800);
return response()->json(['ok' => true]);
}
General Tips
- Store your API key in environment variables, never in code
- Set a timeout on HTTP requests (10 seconds recommended) so tracking calls don't block your main flow
- Send tracking calls asynchronously (fire-and-forget) when possible, so they don't slow down your user-facing responses
- Log tracking failures for debugging, but do not let them crash your application
- Use
visitor_idwherever possible to connect server events to browser sessions - Prefix server-side event names with a namespace (e.g.
server_) if you want to distinguish them from client-side events in your dashboard