For events that must originate from your backend — purchases, subscription renewals, refunds, fraud-flag actions — KarmaFlow exposes a separate ingest endpoint with HMAC authentication.
The browser tracker can be blocked, can fail silently on flaky mobile networks, can be lied to (a determined attacker can replay click events for a competitor's site). For revenue-impacting events you want a path that doesn't depend on the browser cooperating.
Server-side events:
visitor_id and user_id the browser tracker would use, so a purchase from the backend joins the same funnel as the upstream browser activity.POST /api/server-events/:siteKey
Content-Type: application/json
X-Karma-Signature: sha256=<hex>
X-Karma-Timestamp: 2026-05-22T03:15:00Z
The body is the same { events: [...] } shape as /collect. The validation goes through collectorService.validateBatch like any other batch.
event_kind defaults to 's2s' for events from this endpoint regardless of what the client sends. This is what distinguishes server-side rows in BigQuery (WHERE event_kind = 's2s').
The signature is computed as:
canonical = X-Karma-Timestamp + '\n' + request_body
signature = hex(hmac_sha256(secret, canonical))
header = 'sha256=' + signature
|now - timestamp| > 5 minutes are rejected with 401 stale_signature.Tenant.analyticsHmacSecret. Rotate via the Web Analytics > Sites > Server Events UI; the previous secret stays valid for 24 h after rotation so in-flight requests don't fail.Content-Length'd bytes from the request, not a re-serialized object — JSON whitespace differences will fail the comparison.The middleware lives in src/routes/api/serverEventRoutes.js and uses a constant-time string comparison.
const crypto = require('crypto');
const fetch = require('node-fetch');
async function emitServerEvent(siteKey, events) {
const body = JSON.stringify({ events });
const ts = new Date().toISOString();
const canonical = ts + '\n' + body;
const sig = crypto
.createHmac('sha256', process.env.KARMA_ANALYTICS_SECRET)
.update(canonical)
.digest('hex');
const res = await fetch(`https://omni.karmaflow.ai/api/server-events/${siteKey}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Karma-Signature': `sha256=${sig}`,
'X-Karma-Timestamp': ts,
},
body,
});
if (!res.ok) throw new Error(`s2s ingest failed: ${await res.text()}`);
return res.json();
}
// In your purchase handler:
await emitServerEvent('16efa189f624da8c', [{
event_name: 'purchase',
event_kind: 's2s',
event_id: order.id, // dedupes against any browser-side purchase
event_ts: order.completedAt,
visitor_id: order.visitorId || null, // carried through from the browser session
user_id: order.userId,
page_url: order.checkoutPageUrl,
properties: {
amount_usd: order.totalUsd,
currency: 'USD',
items: order.items.map((it) => ({ sku: it.sku, qty: it.qty })),
payment_method: order.paymentMethod,
},
}]);
import hmac, hashlib, json, os, requests
from datetime import datetime, timezone
def emit_server_event(site_key, events):
body = json.dumps({"events": events}, separators=(',', ':'))
ts = datetime.now(timezone.utc).isoformat()
canonical = (ts + "\n" + body).encode()
sig = hmac.new(
os.environ['KARMA_ANALYTICS_SECRET'].encode(),
canonical,
hashlib.sha256,
).hexdigest()
r = requests.post(
f"https://omni.karmaflow.ai/api/server-events/{site_key}",
data=body,
headers={
"Content-Type": "application/json",
"X-Karma-Signature": f"sha256={sig}",
"X-Karma-Timestamp": ts,
},
)
r.raise_for_status()
return r.json()
For events where both the browser fires it (so the user gets immediate funnel attribution) and the server fires it (so the conversion is authoritative), use the same event_id on both:
purchase with event_id = <client-generated UUID stored alongside the order>.purchase with the same event_id.Both go into the events table. Queries that count purchases use COUNT(DISTINCT event_id) so the duplicate doesn't double-count. Server-side wins on data quality (properties.amount_usd is from the database of record, not the cart estimate), but the browser event timestamps the conversion at the moment of intent.
If you only fire from the server, the conversion still attributes to the visitor's session — as long as you pass the visitor's visitor_id (which you can get from the browser tracker via karma.q.visitorId or by reading the karma_v localStorage key on a same-origin page).
| Status | Meaning |
|---|---|
202 Accepted |
Batch validated, queued for insert. Body includes accepted and filtered counts. |
400 Bad Request |
Validation failed; body includes zod issues. |
401 stale_signature |
Timestamp drift > 5 minutes or signature mismatch. |
404 unknown_site_key |
siteKey doesn't resolve to a registered site. |
413 Payload Too Large |
Body exceeds 5 MB; split the batch. |
429 Too Many Requests |
Per-key rate limit (600/min) hit. Honor Retry-After. |
Server events share the same RATE_LIMIT_PER_KEY = 600/min window as /collect. If you push large bulk batches from a backend cron, throttle yourself — the rate limit is per-siteKey across all sources, so a runaway backend will starve the browser tracker.
For genuinely high-volume backfills (importing historical orders, etc.), open a support ticket — there's a separate bulk-import path that doesn't go through the per-request rate limiter.
click, form_submit, scroll depth) — those have to come from the tracker because the server doesn't know about them.user_id and no visitor_id, the event lands but you can't attribute it to anyone. Browser tracker handles this case better.