Server-side events API

When and how to send events from your backend instead of (or in addition to) the browser pixel.

Server-side events API

The pixel handles 80% of tracking, but some events should always come from the server:

  • Orders / purchases — the user may close the tab before the thank-you page fires.
  • Refunds / cancellations — never happen in the browser.
  • Anything billing-related — accuracy beats convenience.
  • Headless / mobile clients that don't ship the pixel.

This is the same architectural shape as Meta's Conversions API + Pixel: send the same event_id from both channels, Skryx dedupes, each event is counted once.

# Endpoint

POST https://api.skryx.io/v1/events
Authorization: Bearer sk_live_…
Content-Type: application/json

The key needs the track:write scope. Admin keys (sk_admin_…) have it via the index:admin super-scope. To issue a narrower ingestion-only key, create one with type=search and explicit scopes=["track:write"].

# Batch shape

{
  "events": [
    {
      "event_id": "ord_42",
      "event_name": "order.completed",
      "occurred_at": "2026-05-26T14:42:00Z",
      "session_id": "sess_abc123",
      "user_id": "u_99",
      "source": "server",
      "properties": {
        "order_id": "42",
        "total": 199.99,
        "currency": "EUR",
        "products": [
          { "id": "SKU-123", "quantity": 1, "price": 199.99 }
        ]
      }
    },
    { "event_id": "…", "event_name": "cart.add", … }
  ]
}

Cap: 200 events per request. For larger batches, split client-side and post in parallel.

# Response shape

{
  "accepted": 198,
  "rejected": 2,
  "errors": [
    {
      "index": 5,
      "errors": [
        { "field": "properties.total", "error": "required for order.completed" }
      ]
    },
    { "index": 17, "errors": [{ "field": "event_name", "error": "unknown event type: cart.checkout" }] }
  ]
}
  • HTTP 200 when every event passed.
  • HTTP 207 (Multi-Status) when some passed, some failed.
  • HTTP 400 / 401 / 403 / 429 for whole-batch failures.

Validation errors are per-row so you can fix the bad rows and resend without touching the good ones.

# Idempotency: event_id

event_id is the dedup key. The same event_id from the same tenant gets persisted once regardless of how many times you send it. Use this to:

  • Retry safely: if your background job dies mid-batch, just resend.
  • Pixel + server combo: if you also fire the same event via the pixel (highly recommended for orders), use the same event_id from both sides. Skryx counts once.

A typical pattern for orders:

event_id = "ord_" + order.id    // stable across pixel + server

Pixel: skryx.track('order.completed', { event_id_override: 'ord_42', … }) (pixel pulls event_id from the event_id_override property when set, otherwise generates evt_… internally)

Server: send { "event_id": "ord_42", … } directly.

# Rate limits

  • 60 req/sec/IP burst cap (same as the rest of /v1).
  • Daily event cap per plan (see Plans page). Free = 10k/day, Starter = 100k, Growth = 1M, Business = 10M, Enterprise unlimited.
  • Soft warning at 80% of daily cap — events still flow, dashboard shows a banner.
  • Hard 429 at 100% on plans without overage; plans with overage_per_1k_… continue at billable overage.

# Code samples

# PHP / Laravel

use Illuminate\Support\Facades\Http;

$response = Http::withToken(env('SKRYX_API_KEY'))
    ->post('https://api.skryx.io/v1/events', [
        'events' => [[
            'event_id'    => 'ord_'.$order->id,
            'event_name'  => 'order.completed',
            'occurred_at' => now()->toIso8601String(),
            'session_id'  => $session->id,
            'user_id'     => $user->id,
            'source'      => 'server',
            'properties'  => [
                'order_id' => (string) $order->id,
                'total'    => (float) $order->total,
                'currency' => $order->currency,
                'products' => $order->items->map(fn ($i) => [
                    'id'       => $i->product_id,
                    'quantity' => $i->quantity,
                    'price'    => (float) $i->price,
                ])->all(),
            ],
        ]],
    ]);

# Python

import requests

requests.post(
    "https://api.skryx.io/v1/events",
    headers={"Authorization": f"Bearer {SKRYX_API_KEY}"},
    json={
        "events": [{
            "event_id": f"ord_{order.id}",
            "event_name": "order.completed",
            "occurred_at": order.completed_at.isoformat() + "Z",
            "session_id": session.id,
            "source": "server",
            "properties": {"order_id": str(order.id), "total": float(order.total)},
        }]
    },
    timeout=5,
)

# Node

await fetch("https://api.skryx.io/v1/events", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.SKRYX_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    events: [{
      event_id: `ord_${order.id}`,
      event_name: "order.completed",
      occurred_at: new Date().toISOString(),
      session_id: session.id,
      source: "server",
      properties: { order_id: String(order.id), total: order.total },
    }],
  }),
});

# Ruby

require 'net/http'
require 'json'

Net::HTTP.post(
  URI('https://api.skryx.io/v1/events'),
  {
    events: [{
      event_id: "ord_#{order.id}",
      event_name: 'order.completed',
      occurred_at: Time.now.utc.iso8601,
      session_id: session.id,
      source: 'server',
      properties: { order_id: order.id.to_s, total: order.total.to_f }
    }]
  }.to_json,
  'Content-Type' => 'application/json',
  'Authorization' => "Bearer #{ENV['SKRYX_API_KEY']}"
)
esc