---
url: https://simpledatalogs.com/docs/api/sender-rules.md
description: >-
  The six rules every client follows (batching, a durable outbox, backoff with
  jitter, event_id and ts, failing open, a User-Agent), with a reference
  implementation.
---

# Writing a sender

If there is no SDK for your platform, writing your own client takes an afternoon. Follow these six rules, and your client gets the same guarantees as ours: nothing is lost, nothing counts twice, and your app never slows down.

## The six rules

1. **Batch.** Apps flush up to 50 events, or every 10 seconds (5 seconds in debug builds). Servers flush every ~5 seconds. Keep each request under 256 KB.
2. **Keep a durable outbox.** A file, a table or `localStorage`. An event leaves it only after a `202`.
3. **Retry with exponential backoff and full jitter**, from 1 second up to 5 minutes, honoring `Retry-After`. On `401`, pause and re-probe slowly. On `413`, split.
4. **Give every event a UUID `event_id` and a `ts`**, both set when the event happens, so a retry never counts twice.
5. **Fail open.** Never block, slow down or crash the host app because of analytics.
6. **Send a `User-Agent`** that names your client and its version.

## Batching

Small requests waste battery and connections; huge ones risk the 512 KB limit. Flush when either comes first:

* the batch reaches 50 events (apps) or the flush interval passes (10 s for apps, 5 s in their debug builds, ~5 s for servers);
* the request would pass 256 KB.

Apps should also flush when they move to the background or the page is hidden.

## The outbox

Write each event to durable storage **as it is tracked**, not when it is sent. Then a crash, a reload or a closed laptop loses nothing.

* Remove events only after a `202`.
* Bound it. Pick a cap that suits the platform (our browser SDK keeps 1,000 events or 2 MB; Swift 10,000 events or 10 MB) and drop the oldest only when it fills, with a warning.
* Resend whatever is left on the next launch. `event_id` dedupes anything that had in fact been stored.

## Retries

Retry everything that isn't a `202`, with exponential backoff and **full jitter**:

```js
// Waits a random time up to the backoff ceiling, never sooner than Retry-After.
function retryDelayMs(failureCount, retryAfterSeconds) {
  const ceilingMs = Math.min(300_000, 1_000 * 2 ** failureCount)
  const jitteredMs = Math.random() * ceilingMs

  return Math.max(jitteredMs, (retryAfterSeconds ?? 0) * 1_000, 250)
}
```

Three answers need special handling:

| Answer | Do this |
|---|---|
| `401 invalid_key` | Stop sending and keep the outbox. Re-probe with a small batch after a minute, then every 10 minutes, and on the next launch |
| `413 payload_too_large` | Split the batch in half and resend each half. A single event that still doesn't fit is the only thing you drop |
| `415` | The request can never succeed unchanged. Set that batch aside, log it once, and move on |

See [Responses](/api/responses) for every answer.

## A minimal client

This Node client follows all six rules in under 80 lines. It keeps the outbox in a file; swap that for whatever storage your platform has.

```js
import { appendFileSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
import { randomUUID } from 'node:crypto'

const INGEST_URL = 'https://edge.simpledatalogs.com/v1/ingest'
const INGEST_KEY = process.env.SIMPLEDATALOGS_KEY
const OUTBOX_PATH = './simpledatalogs-outbox.ndjson'

let failureCount = 0
let pausedUntilMs = 0

// Records one event; stamps its id and time now, and appends it to the outbox.
export function track(eventName, props = {}) {
  try {
    const event = { event: eventName, event_id: randomUUID(), ts: new Date().toISOString(), props }
    appendFileSync(OUTBOX_PATH, JSON.stringify(event) + '\n')
  } catch (error) {
    console.warn('simpledatalogs: could not write the outbox', error)
  }
}

// Sends up to 50 outbox lines; removes them only after a 202.
async function flush() {
  if (Date.now() < pausedUntilMs || !existsSync(OUTBOX_PATH)) return

  const outboxLines = readFileSync(OUTBOX_PATH, 'utf8').split('\n').filter(Boolean)
  const batchLines = outboxLines.slice(0, 50)
  if (batchLines.length === 0) return

  try {
    const response = await fetch(INGEST_URL, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${INGEST_KEY}`,
        'Content-Type': 'application/x-ndjson',
        'User-Agent': 'my-node-client/1.0.0',
      },
      body: batchLines.join('\n'),
      signal: AbortSignal.timeout(15_000),
    })

    // Success: re-read the outbox, since track() may have appended meanwhile, and drop only the sent lines.
    if (response.status === 202) {
      failureCount = 0
      const currentLines = readFileSync(OUTBOX_PATH, 'utf8').split('\n').filter(Boolean)
      writeFileSync(OUTBOX_PATH, currentLines.slice(batchLines.length).map((line) => line + '\n').join(''))
      return
    }

    // A bad key pauses sending for 10 minutes; everything stays in the outbox.
    if (response.status === 401) {
      pausedUntilMs = Date.now() + 600_000
      console.warn('simpledatalogs: invalid ingest key; pausing')
      return
    }

    // Anything else waits for the backoff, never sooner than Retry-After.
    const retryAfterSeconds = Number(response.headers.get('retry-after')) || 0
    failureCount += 1
    pausedUntilMs = Date.now() + retryDelayMs(failureCount, retryAfterSeconds)
  } catch {
    // A timeout or network error: unknown whether it was stored, so retry later.
    failureCount += 1
    pausedUntilMs = Date.now() + retryDelayMs(failureCount, 0)
  }
}

// Waits a random time up to the backoff ceiling, never sooner than Retry-After.
function retryDelayMs(failureCount, retryAfterSeconds) {
  const ceilingMs = Math.min(300_000, 1_000 * 2 ** failureCount)
  return Math.max(Math.random() * ceilingMs, retryAfterSeconds * 1_000, 250)
}

// Flushes every 5 seconds, the server cadence, one request at a time.
async function flushLoop() {
  await flush()
  setTimeout(flushLoop, 5_000).unref()
}

flushLoop()
```

It leaves out what a production client also needs: splitting on `413`, a size cap on the outbox, and a lock if several processes share one file. The [JavaScript SDK](/sdks/javascript) has all of that.

::: tip Let your coding agent write it
The [setup prompt](/agents/) gives an agent these same rules, so it can write a client like this one in your app's language.
:::

## Next steps

* [Responses](/api/responses): the full table of answers.
* [JavaScript](/sdks/javascript), [Swift](/sdks/swift), [Laravel](/sdks/laravel), [Flutter](/sdks/flutter), [Android](/sdks/android), [Unity](/sdks/unity), [Python](/sdks/python) and [Go](/sdks/go): SDKs that already follow these rules.
* [Vector and Fluent Bit](/integrations/log-shippers): ship log files without writing a client.
