Skip to content

Protocol

Working draft, frozen at Phase 1 of the roadmap. SDK and server must agree on this document before either implements changes. Any field added/removed/renamed in this file requires the matching edit in server/src/event.rs and sdk/react-native/src/types.ts in the same PR.

This protocol is intentionally without legacy. It does not maintain compatibility with Sentry, OpenTelemetry, or any other prior system. Notable choices:

  • camelCase field names on the wire (idiomatic for JS/Swift/Kotlin clients; Rust server uses serde rename_all = "camelCase").
  • Full words, never abbreviationstimestamp not ts, message not msg, function not fn.
  • Single JSON event — no envelope, no multipart, no streaming. One request = one or many events, all JSON.
  • Flat top-level structure — no contexts.{runtime, os, device, app, ...} nesting tax.
  • Nested cause for error chains, not exceptions[] arrays.
  • uuid-v7 for all client-generated IDs (RFC 9562, includes timestamp; sortable; modern).
  • ISO 8601 UTC, millisecond precision for all timestamps.
  • Reserved extension slots for traceId / spanId (distributed tracing, not implemented in v0.1).

API version lives in the URL path: /v1/.... Breaking changes ship as /v2/. Within a major version all changes are additive (new optional fields, new enum variants — clients ignore unknown).

Single event ingestion.

Batched ingestion. Recommended for any SDK that buffers (which all of ours do).

Trailing slashes are not significant.

HeaderRequiredExample
Authorization: Bearer <token>yesBearer st_pk_01j5y9z3vk8x4rmt2pcqjf7nw9
Sentori-Sdkyesreact-native/0.1.0
Content-Type: application/jsonyes(multipart and form-encoded are not accepted)
Content-Encoding: gzipnogzip body supported (recommended for batch)
Idempotency-Keynoreserved; in v0.1 the event’s id field acts as idempotency key

The Sentori-Sdk header identifies the reporting client. Format: <sdk-name>/<sdk-version>. The server uses this for compatibility shimming; unknown SDKs are accepted unless a hard incompatibility is detected.

st_pk_<26 chars Crockford base32 of uuid-v7>

  • st_ — Sentori product namespace
  • pk_ — project public key (may be embedded in client builds). The sk_ prefix is reserved for server-only admin secret keys (post-v0.1).
  • 26 chars — Crockford base32 (lowercase, no padding) of the underlying 16-byte uuid-v7. Crockford base32 avoids visually ambiguous characters (0/O, 1/I/L).

Example: st_pk_01j5y9z3vk8x4rmt2pcqjf7nw9

The token alone identifies a project — there is no separate project ID in URLs or headers.

The SDK takes two independent configuration fields, never combined into a single URL:

sentori.init({
token: 'st_pk_01j5y9z3vk8x4rmt2pcqjf7nw9',
release: 'myapp@1.2.3+456',
ingestUrl: 'https://ingest.sentori.golia.jp', // optional, this is the default
});

Self-hosted users override ingestUrl:

sentori.init({
token: 'st_pk_...',
release: 'myapp@1.2.3+456',
ingestUrl: 'https://sentori.your-company.com',
});

Environment variables: SENTORI_TOKEN, SENTORI_INGEST_URL.

Sentori does not use Sentry’s https://<key>@<host>/<id> DSN format:

  1. URL-embedded tokens leak whenever a logging framework records request URLs.
  2. Token rotation should be independent of host change.
  3. Two .env variables are clearer than parsing a DSN string.

Documentation must not use the term “DSN”. Always say “token + ingest URL”.

CodeMeaningBody
202 AcceptedEvent(s) accepted (not necessarily persisted yet){}
400 Bad RequestSchema validation failedsee below
401 UnauthorizedMissing, malformed, or unknown token{ "error": "unauthorized" }
413 Payload Too LargeEvent > 1 MB or batch > 1 MB{ "error": "payloadTooLarge" }
429 Too Many RequestsRate limit hitsee below; Retry-After header set
500 Internal Server ErrorServer fault; SDK should retry with backoff{ "error": "internal" }

400 body shape:

{
"error": "validationFailed",
"details": [
{ "field": "error.type", "message": "required" },
{ "field": "device.os", "message": "must be one of: ios, android, web, other" }
]
}

429 body shape:

{ "error": "rateLimited", "retryAfterMs": 12000 }

The SDK MUST honor retryAfterMs (no retry sooner). On 5xx, the SDK SHOULD use exponential backoff: 1s, 2s, 4s; max 3 retries; then drop the batch.

A single event is a JSON object with these top-level fields:

FieldTypeRequiredNotes
idstring (uuid-v7)yesclient-generated; server uses as idempotency key
timestampstring (ISO 8601, UTC, ms precision)yeswhen the error occurred (not when reported)
kindenumyes"error" (the only kind in v0.1)
platformenumyes"javascript" / "ios" / "android" (v0.2 may add "web", "node")
releasestringyesformat: <app-name>@<version>+<build> (e.g. myapp@1.2.3+456)
environmentstringyestypically "prod", "staging", "dev"
deviceDeviceyesphysical device info
appAppyesapplication info
userUser | nullnoomit or null if no user; SDK never auto-collects PII
tagsobject<string, string>noflat key-value, max 50 keys
breadcrumbsarraynoup to 100 entries
errorErroryesthe actual error
fingerprintarraynoclient-suggested grouping; server may override per project rules
traceIdstring | nullnoreserved for distributed tracing (v0.1 always null/omitted)
spanIdstring | nullnoreserved for distributed tracing (v0.1 always null/omitted)

Physical device / runtime host info.

FieldTypeRequiredNotes
osenumyes"ios" / "android" / "web" / "other"
osVersionstringyese.g. "17.4", "14"
modelstringnoe.g. "iPhone15,2", "Pixel 8"
localestring (BCP-47)noe.g. "ja-JP"
FieldTypeRequiredNotes
versionstringyese.g. "1.2.3"
buildstringnoe.g. "456"
frameworkFramework | nullnonon-null for cross-platform runtimes
FieldTypeRequiredNotes
namestringyese.g. "react-native", "flutter", "capacitor"
versionstringyesframework version
FieldTypeRequiredNotes
idstringnoapplication-defined
anonymousbooleannohint for the dashboard

The SDK must not auto-collect email, phone, IP, device IDs, or any other PII. Only what the application explicitly sets via sentori.setUser(...).

FieldTypeRequiredNotes
typestringyese.g. "TypeError", "NSInvalidArgumentException", "java.lang.RuntimeException"
messagestringyeshuman-readable message
stackarrayyestop-of-stack first
causeError | nullnonested cause; recursive (max depth 10)

Sentry uses exceptions[] to express cause chains. Sentori uses nested cause, which matches the natural structure of JS / Swift / Kotlin throwable causes.

FieldTypeRequiredNotes
functionstringnofunction or method name; may be "<anonymous>"
filestringyesrelative path or filename
lineintyes1-indexed
columnintno1-indexed
inAppbooleanyestrue for application code, false for vendor/runtime
absolutePathstringnoabsolute file path (used by iOS / Android frames)
preContextarraynosource lines before, max 5
postContextarraynosource lines after, max 5
FieldTypeRequiredNotes
timestampstring (ISO 8601)yesbreadcrumb timestamp
typeenumyes"nav" / "net" / "log" / "user" / "custom"
dataobjectyesshape depends on type, see below

nav — navigation events:

{ "from": "Home", "to": "Checkout" }

net — network requests:

{ "method": "POST", "url": "https://api.example.com/x", "status": 500, "durationMs": 234 }

(SDKs SHOULD strip query strings of well-known auth params: token, key, password, secret.)

log — log statements:

{ "level": "warn", "message": "deprecated API used" }

user — user interaction:

{ "action": "tap", "target": "submit_button" }

custom — application-defined:

{ "anything": "user-defined" }

POST /v1/events:batch body:

{ "events": [ /* up to 100 Event objects */ ] }

Constraints:

  • batch body ≤ 1 MB (after gzip decode)
  • ≤ 100 events per batch
  • All events MUST belong to the same project (single Authorization header)
  • Mixed platform values are allowed within one batch

If any single event fails validation, only that event is rejected (the batch is not failed wholesale). Response body lists per-event status:

{
"accepted": 97,
"rejected": 3,
"errors": [
{ "index": 4, "error": "validationFailed", "details": [...] },
{ "index": 22, "error": "validationFailed", "details": [...] },
{ "index": 81, "error": "validationFailed", "details": [...] }
]
}

Single-event endpoint always returns 202 with empty body, or one of the error codes above.

ItemLimit
single event payload (decoded)1 MB
batch payload (decoded)1 MB
breadcrumbs per event100
stack frames per error100
cause chain depth10
tag keys per event50
tag value length200 chars
tag key length64 chars

Events exceeding any of these are rejected with 400 listing the violated limit in details.

Per-token sliding window. Default: 5000 requests/min, configurable per project. Counts requests, not events (a batch of 100 counts as 1 request).

When hit:

  • HTTP 429
  • Body: { "error": "rateLimited", "retryAfterMs": <ms> }
  • Retry-After: <seconds> header (rounded up)
  • SDK MUST exponential-backoff and not retry sooner than retryAfterMs

Example 1: JS TypeError (React Native, JS layer)

Section titled “Example 1: JS TypeError (React Native, JS layer)”
{
"id": "01j5y9z3vk8x4rmt2pcqjf7nw9",
"timestamp": "2026-05-09T12:34:56.789Z",
"kind": "error",
"platform": "javascript",
"release": "myapp@1.2.3+456",
"environment": "prod",
"device": {
"os": "ios",
"osVersion": "17.4",
"model": "iPhone15,2",
"locale": "ja-JP"
},
"app": {
"version": "1.2.3",
"build": "456",
"framework": { "name": "react-native", "version": "0.74.1" }
},
"user": {
"id": "u_abc123",
"anonymous": false
},
"tags": {
"screen": "Checkout",
"feature_flag.new_pay": "on"
},
"breadcrumbs": [
{
"timestamp": "2026-05-09T12:34:50.000Z",
"type": "nav",
"data": { "from": "Home", "to": "Checkout" }
},
{
"timestamp": "2026-05-09T12:34:55.000Z",
"type": "net",
"data": {
"method": "POST",
"url": "https://api.example.com/checkout",
"status": 500,
"durationMs": 1200
}
}
],
"error": {
"type": "TypeError",
"message": "Cannot read property 'foo' of undefined",
"stack": [
{
"function": "handleSubmit",
"file": "src/screens/Checkout.tsx",
"line": 42,
"column": 10,
"inApp": true
},
{
"function": "onPress",
"file": "src/components/Button.tsx",
"line": 15,
"column": 5,
"inApp": true
}
]
}
}

Example 2: iOS NSException (React Native, iOS native layer)

Section titled “Example 2: iOS NSException (React Native, iOS native layer)”
{
"id": "01j5y9z47vke3hxh8x9k2r4gpz",
"timestamp": "2026-05-09T12:35:01.234Z",
"kind": "error",
"platform": "ios",
"release": "myapp@1.2.3+456",
"environment": "prod",
"device": {
"os": "ios",
"osVersion": "17.4",
"model": "iPhone15,2",
"locale": "en-US"
},
"app": {
"version": "1.2.3",
"build": "456",
"framework": { "name": "react-native", "version": "0.74.1" }
},
"user": null,
"tags": {},
"breadcrumbs": [],
"error": {
"type": "NSInvalidArgumentException",
"message": "*** -[__NSArrayM objectAtIndex:]: index 5 beyond bounds [0 .. 2]",
"stack": [
{
"function": "-[CheckoutViewController submitOrder]",
"file": "CheckoutViewController.m",
"line": 87,
"inApp": true,
"absolutePath": "/Users/dev/myapp/ios/MyApp/CheckoutViewController.m"
},
{
"function": "-[UIControl _sendActionsForEvents:withEvent:]",
"file": "UIControl.m",
"line": 0,
"inApp": false
}
]
}
}

Example 3: Android RuntimeException with cause chain

Section titled “Example 3: Android RuntimeException with cause chain”
{
"id": "01j5y9z4hp8mqr3kxc9p5tnz4w",
"timestamp": "2026-05-09T12:35:08.456Z",
"kind": "error",
"platform": "android",
"release": "myapp@1.2.3+456",
"environment": "prod",
"device": {
"os": "android",
"osVersion": "14",
"model": "Pixel 8",
"locale": "ja-JP"
},
"app": {
"version": "1.2.3",
"build": "456",
"framework": { "name": "react-native", "version": "0.74.1" }
},
"user": { "id": "u_xyz", "anonymous": false },
"tags": { "screen": "Checkout" },
"breadcrumbs": [],
"error": {
"type": "java.lang.RuntimeException",
"message": "Failed to submit order",
"stack": [
{
"function": "com.myapp.checkout.CheckoutViewModel.submit",
"file": "CheckoutViewModel.kt",
"line": 42,
"inApp": true
}
],
"cause": {
"type": "java.io.IOException",
"message": "Connection reset by peer",
"stack": [
{
"function": "okhttp3.internal.http.RetryAndFollowUpInterceptor.intercept",
"file": "RetryAndFollowUpInterceptor.kt",
"line": 87,
"inApp": false
},
{
"function": "okhttp3.RealCall.execute",
"file": "RealCall.kt",
"line": 154,
"inApp": false
}
]
}
}
}

These are intentionally not specified in v0.1:

  • Source map upload format and POST /admin/api/releases/:r/sourcemaps endpoint shape — Phase 8
  • dSYM / ProGuard mapping upload format — post-v0.1 per ROADMAP “explicitly out”
  • Server-side fingerprint override rules / per-project grouping config — Phase 5 (initial), refined later
  • Webhook payload format for alerting — Phase 9
  • Live event tail (WebSocket / SSE) for dashboard — not in v0.1
  • gRPC ingestion — not in v0.1 (HTTP/JSON only)
  • Replay / profiling / native crash signal handler payloads — explicitly out per ROADMAP
  • Distributed tracing semantics for traceId / spanId — slot reserved, OTel-compatible meaning to be defined when first needed

Within /v1/:

  • The server SHALL NOT remove existing fields nor change their types.
  • The server MAY add new optional fields; SDKs MUST ignore unknown fields.
  • The server MAY add new enum variants; SDKs MUST treat unknown variants as "other" (or equivalent fallback).
  • The SDK MAY omit any field marked “required: no”.
  • Breaking changes ship under /v2/ with a 12-month overlap with /v1/.
  • v0 — 2026-05-09 — initial draft (Phase 1 of ROADMAP).