Server-Side Events

For events that must originate from your backend — purchases, subscription renewals, refunds, fraud-flag actions — KarmaFlow exposes a separate ingest endpoint with HMAC authentication.

Why a Separate Endpoint

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:

Endpoint

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').

HMAC Signing

The signature is computed as:

canonical = X-Karma-Timestamp + '\n' + request_body
signature = hex(hmac_sha256(secret, canonical))
header    = 'sha256=' + signature

The middleware lives in src/routes/api/serverEventRoutes.js and uses a constant-time string comparison.

Example: Node.js

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,
  },
}]);

Example: Python

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()

Browser + Server Dedupe

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:

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).

Response Codes

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.

Rate Limiting

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.

When Not to Use Server Events