Batching & idempotency

Ingestion is designed around one assumption: your delivery will be imperfect. Timeouts, duplicated webhooks, corrections, replayed files — all of it is expected, and none of it should cost you an event or create a phantom one.

Limits

  • Name
    500
    Type
    events per call
    Description

    Batches larger than that are refused with 400. The official SDKs split automatically and merge the results, so you can hand them 50 000 events.

  • Name
    256 KB
    Type
    per event payload
    Description

    Exceeded, the event alone is rejected with payload_too_large; the rest of the batch is unaffected.

  • Name
    10 MB
    Type
    per request body
    Description

    A request body over 10 MB never reaches validation — it comes back as 413, with nothing accepted. Split the batch and send again.

The idempotency key

An event is identified by:

Idempotency key

(project, source, source_event_id)

Each version of it that sinio.ai stores is a numbered revision. What decides between "same event" and "new version" is a content hash, computed over exactly:

  • payload, with its keys sorted — key order does not matter;
  • occurred_at, as the raw string you sent — 2026-08-28T07:58:12+02:00 and 2026-08-28T05:58:12Z are the same moment but different content;
  • the raw identifiers in roles — the external ids you sent, never internal UUIDs;
  • count, when you send it.

type, label and source_version are not part of it: changing only one of them is not a correction, and the resend is counted as a duplicate. Because the hash uses your external ids, qualifying a badge from undeclared to declared does not create a phantom revision of every event that ever referenced it.

batch_id is not part of it either. sinio.ai generates one per call and returns it — you cannot send your own. It is a purely technical correlation handle for support and logs: two identical events are deduplicated whatever call they arrived in.

Three outcomes for a known source_event_id

  • Name
    Content identical to the latest revision
    Type
    no-op
    Description

    Nothing is written. Counted in duplicates.

  • Name
    Content identical to an earlier, superseded revision
    Type
    no-op
    Description

    A stale replay. Nothing is re-applied. Counted in duplicates.

  • Name
    Content matching no revision
    Type
    new revision
    Description

    Revision n+1 is written, the previous one is marked superseded, and a recomputation is queued. Counted in revisions.

The second case is the one that saves reports. Revision 1 arrives, correction 2 arrives, then a webhook replays revision 1. Without the rule, the event would revert to its old value and the recomputation would produce a false report.

Stale revision replay

revision 1 received  → applied
revision 2 received  → applied, revision 1 marked superseded
revision 1 replayed  → superseded → NO-OP, counted in duplicates

Staleness is judged on content alone: a resend identical to any revision already stored is a no-op. source_version plays no part — content never seen before is always a new revision, even if its source_version is older than the latest one. A replay is never an error: it is counted and returned, not rejected.

Corrections

A correction is just the same source_event_id with different content — a different payload, occurred_at, roles or count. Send it exactly like the original — there is no dedicated endpoint, no flag to set:

A correction

{
  "events": [
    {
      "source_event_id": "evt_88213",
      "source_version": "4",
      "type": "badge_in",
      "occurred_at": "2026-08-28T07:48:12+02:00",
      "roles": { "device": "BORNE-A12", "subject": "P-4582" },
      "payload": { "badge": "04A2B9C1" }
    }
  ]
}

The previous revision is never deleted or modified. A new row is written, the old one is marked superseded, and the affected business keys are queued for recomputation. source_version is optional: it is stored with the revision for your own tracing, and never used to decide between duplicate and correction.

Partial acceptance

A 200 means the batch was received, not derived. Inside a batch, valid events are accepted even when others are rejected — so the response is the only place that tells you what actually happened:

A partially accepted batch

{
  "batch_id": "0192f3a4-7b1c-7e2d-9a10-4c5b6d7e8f90",
  "accepted": 497,
  "pending_classification": 1,
  "duplicates": 1,
  "revisions": 0,
  "rejected": 1,
  "rejections": [
    {
      "source_event_id": "evt_88240",
      "code": "invalid_occurred_at",
      "message": "occurred_at must be ISO 8601 with an offset, got: \"2026-08-28T07:58:12\""
    }
  ],
  "test_mode": false
}

Always read rejections

const result = await sinio.sendEvents(events)

for (const rejection of result.rejections) {
  console.warn(rejection.sourceEventId, rejection.code, rejection.message)
}

duplicates, revisions and pending_classification are successes. Only rejected is a failure, and it always comes with a code you can act on.

Retrying safely

Because ingestion is idempotent, retrying is free of consequences. Retry 429, 5xx and dropped connections with exponential backoff and jitter, honouring Retry-After if a response carries one — the ingestion routes are not rate-limited today. Do not retry 400, 401, 403, 404 or 413 — nothing about them will change on a second attempt.

The official SDKs already implement exactly that policy.

Was this page helpful?