Skip to main content
Network failures and temporary server errors happen. The server events API is designed to handle retries safely — as long as you follow the event_id pattern.

How idempotency works

Every event accepts an event_id field. When the same event_id arrives more than once, the pipeline marks the second occurrence as a duplicate and excludes it from analytics. Your conversion counts stay accurate even if you retry a request multiple times. The rule: generate the event_id once, before your first attempt, and reuse the same value for every retry of that event.

Implementation pattern

// Node.js example
import crypto from 'node:crypto';

async function sendWithRetry(eventPayload, maxAttempts = 5) {
  // Generate event_id once — do not regenerate on retry
  const payload = {
    ...eventPayload,
    event_id: eventPayload.event_id ?? crypto.randomUUID(),
  };

  let delay = 1000; // start at 1 second

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      const response = await fetch('https://track.scanova.io/server-events', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-API-Key': process.env.SCANOVA_API_KEY,
        },
        body: JSON.stringify(payload),
        signal: AbortSignal.timeout(10_000), // 10s timeout
      });

      // Do not retry on client errors — fix the payload or auth first
      if (response.status >= 400 && response.status < 500) {
        const error = await response.json();
        throw new Error(`Permanent error ${response.status}: ${JSON.stringify(error)}`);
      }

      if (response.ok) return; // success

      // Server error (5xx) — retry
    } catch (err) {
      if (attempt === maxAttempts) throw err;
    }

    await new Promise(resolve => setTimeout(resolve, delay));
    delay = Math.min(delay * 2, 30_000); // cap at 30 seconds
  }
}
# Python example
import os
import time
import uuid
import requests

def send_with_retry(event_payload: dict, max_attempts: int = 5) -> None:
    payload = {**event_payload, "event_id": event_payload.get("event_id") or str(uuid.uuid4())}
    delay = 1.0

    for attempt in range(1, max_attempts + 1):
        try:
            response = requests.post(
                "https://track.scanova.io/server-events",
                headers={
                    "Content-Type": "application/json",
                    "X-API-Key": os.environ["SCANOVA_API_KEY"],
                },
                json=payload,
                timeout=10,
            )

            if 400 <= response.status_code < 500:
                raise ValueError(f"Permanent error {response.status_code}: {response.text}")

            if response.ok:
                return

        except ValueError:
            raise  # do not retry permanent errors
        except Exception:
            if attempt == max_attempts:
                raise

        time.sleep(delay)
        delay = min(delay * 2, 30)  # cap at 30 seconds

Retry schedule

AttemptDelay before attempt
1Immediate
21 second
32 seconds
44 seconds
58 seconds
Cap your delay at 30–60 seconds. After 5 failed attempts, log the event and alert — do not retry indefinitely.

When to retry vs when to stop

StatusAction
Network timeout / connection errorRetry with backoff
429 Too Many RequestsRetry after the Retry-After header value (or 60 seconds)
500, 502, 503, 504Retry with backoff
400 Bad RequestStop — fix the payload
401 UnauthorizedStop — check your API key
403 ForbiddenStop — check key/site_id match
422 Unprocessable EntityStop — fix the field validation error

Persisting event_id for critical events

For high-value conversions (purchases, subscriptions), generate and persist the event_id before the network call — so you can retry even after a process restart:
// Generate and store before the API call
const eventId = crypto.randomUUID();
await db.trackingEvents.create({ eventId, status: 'pending', orderId });

try {
  await sendWithRetry({ event_id: eventId, event_name: 'purchase', ... });
  await db.trackingEvents.update({ eventId, status: 'sent' });
} catch (err) {
  await db.trackingEvents.update({ eventId, status: 'failed' });
  // Retry from your job queue
}