# Server-Side Events

For events that *must* originate from your backend — purchases, subscription renewals, refunds, fraud-flag actions — Karmaflow.ai 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:

- Are signed with an HMAC-SHA256 over the request body + a per-tenant secret, so the receiver can verify the sender is really your backend.
- Bypass the bot filter, the origin allowlist, and the DNT/GPC consent gate (these are about *user* tracking; backend events are about *business* records).
- Can carry the same `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.

## 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 **timestamp** is part of the canonical string to defeat replays. Requests with `|now - timestamp| > 5 minutes` are rejected with `401 stale_signature`.
- The **secret** is per-tenant, stored encrypted in `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.
- The body is signed **as raw bytes**, before JSON parsing. Use the `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.

## Example: Node.js

```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

```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:

- Browser-side, when the user clicks Pay, fire `purchase` with `event_id = <client-generated UUID stored alongside the order>`.
- Server-side, when the payment processor confirms, fire `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).

## 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

- For events that are inherently client-side (`click`, `form_submit`, scroll depth) — those have to come from the tracker because the server doesn't know about them.
- For events that only matter if they were actually witnessed by the user (e.g. "saw the upsell modal") — if you fire them from the server, you can't know whether the browser actually rendered the modal.
- For events the user is anonymous for. The HMAC path is for canonical backend records; if there's no `user_id` and no `visitor_id`, the event lands but you can't attribute it to anyone. Browser tracker handles this case better.
