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:00and2026-08-28T05:58:12Zare 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+1is written, the previous one is marked superseded, and a recomputation is queued. Counted inrevisions.
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.