> ## Documentation Index
> Fetch the complete documentation index at: https://docs.scanova.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Idempotency & Retries

> How to safely retry failed server event requests without creating duplicate conversions. Use a stable event_id and exponential backoff.

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

```javascript theme={null}
// 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 theme={null}
# 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

| Attempt | Delay before attempt |
| ------- | -------------------- |
| 1       | Immediate            |
| 2       | 1 second             |
| 3       | 2 seconds            |
| 4       | 4 seconds            |
| 5       | 8 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

| Status                             | Action                                                     |
| ---------------------------------- | ---------------------------------------------------------- |
| Network timeout / connection error | Retry with backoff                                         |
| `429` Too Many Requests            | Retry after the `Retry-After` header value (or 60 seconds) |
| `500`, `502`, `503`, `504`         | Retry with backoff                                         |
| `400` Bad Request                  | Stop — fix the payload                                     |
| `401` Unauthorized                 | Stop — check your API key                                  |
| `403` Forbidden                    | Stop — check key/site\_id match                            |
| `422` Unprocessable Entity         | Stop — 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:

```javascript theme={null}
// 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
}
```
