Protocol
Sentori Protocol v0
Section titled “Sentori Protocol v0”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.rsandsdk/react-native/src/types.tsin the same PR.
Design principles
Section titled “Design principles”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 abbreviations —
timestampnotts,messagenotmsg,functionnotfn. - 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
causefor error chains, notexceptions[]arrays. - uuid-v7 for all client-generated IDs (RFC 9562, includes timestamp; sortable; modern).
- ISO 8601 UTC, millisecond precision for all timestamps.
- Distributed tracing (v0.4) —
traceId/spanIdare used. Every observation is a single span; there is no separate transaction object. Root vs child isparentSpanId == null. No nestedtransactions[], nomeasurements, no separate envelopes. See Span schema andPOST /v1/spans.
Versioning
Section titled “Versioning”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).
Endpoints
Section titled “Endpoints”POST /v1/events
Section titled “POST /v1/events”Single event ingestion.
POST /v1/events:batch
Section titled “POST /v1/events:batch”Batched ingestion. Recommended for any SDK that buffers (which all of ours do).
POST /v1/sessions
Section titled “POST /v1/sessions”Session ping (Phase 26 sub-A). Sent by the SDK once per session, at close
time — when the app moves to background, exits cleanly, or crashes. Auth
shares the public ingest token; the call counts against the same per-token
rate-limit as /v1/events.
Body:
{ "id": "01j5y9z3vk8x4rmt2pcqjf7nw9", "userId": "u_abc123", "release": "myapp@1.2.3+456", "environment": "prod", "status": "ok", "startedAt": "2026-05-10T12:30:00Z", "durationMs": 4500}id— uuid v7 generated by the SDK; the server treats duplicates as no-ops viaON CONFLICT DO NOTHING, so retries from a flaky network don’t double-count the session.userId— application-defined identifier (same value passed tosentori.setUser({ id })). Omit /nullfor anonymous sessions — they still count toward session-rate but not user-rate.status— one ofok/errored/crashed/exited.- healthy:
ok,exited— session closed without unrecoverable failure. - unhealthy:
errored(had a captured error but session continued),crashed(process died, native or unhandled JS).
- healthy:
startedAt— RFC 3339 wall-clock at session start.durationMs— wall-clock duration of the session in milliseconds; capped server-side at 7 days to discard clock-skew bugs.
Response: 202 Accepted, { "id": "<uuid>" }.
There is no batch endpoint yet — pings are tiny and per-session rate is
low. v0.3 may add /v1/sessions:batch if backlogs become a problem.
This endpoint does not track active or in-progress sessions: the SDK fires exactly once at close. Active-session UI lives in a future phase that adds a heartbeat/init ping if we ever need it.
POST /v1/spans
Section titled “POST /v1/spans”Single span ingestion (Phase 34, v0.4). Used by SDK auto-instrumentation
(fetch wrapper, react-navigation hook, server middleware) and by manual
sentori.startSpan(...) callers. Auth shares the public ingest token;
the call counts against the same per-token rate limit as /v1/events.
Body — see Span schema for the full field list. Minimum required example:
{ "id": "019e1f00-0000-7300-8000-000000000001", "traceId": "019e1f00-0000-7100-8000-000000000099", "parentSpanId": null, "op": "http.client", "name": "GET /v1/users/me", "startedAt": "2026-05-11T12:30:00.123Z", "durationMs": 142, "status": "ok", "tags": { "http.method": "GET", "http.status": "200" }}Response: 202 Accepted, { "id": "<uuid>" }.
POST /v1/spans:batch
Section titled “POST /v1/spans:batch”Batched span ingestion. Body:
{ "spans": [ /* up to 200 Span objects */ ] }SDKs flush every N spans or every M seconds. Each batch must come from
one project (single Authorization header). Mixed traceId values are
allowed in one batch — spans from different traces ride the same HTTP
trip if they happen to flush together. Response shape matches
/v1/events:batch (per-span accepted/rejected counts).
POST /v1/deploys
Section titled “POST /v1/deploys”Deploy hook (Phase 23 sub-C). Called by CI right after a build reaches users so the dashboard’s release timeline knows when each version went live. Auth uses the same public token as ingest; rate-limit shares the ingest budget.
Body:
{ "release": "myapp@1.2.3+456", "environment": "prod", "deployedAt": "2026-05-10T18:30:00Z"}release— required, ≤ 200 chars; should match the SDK’sreleaseconfig exactly.environment— optional, ≤ 64 chars; mirrors the runtime field but is not enforced.deployedAt— optional RFC 3339 timestamp. Defaults to servernow(). Use this for backfilling historical deploys or pinning to the CI step time.
Response (201 Created):
{ "release": "myapp@1.2.3+456", "deployAt": "2026-05-10T18:30:00Z", "releaseId": "019e10..."}Idempotent — re-calling with the same release refreshes deployAt on the existing
releases row instead of creating a duplicate. The created_at column stays at the
row’s first-touch moment (often when the first event for that release arrived).
Audit row release.deployed is recorded with payload {project_id, release, environment, deploy_at}. CI is the actor — there is no user attribution because
token auth has no user identity.
Trailing slashes are not significant.
Authentication and headers
Section titled “Authentication and headers”| Header | Required | Example |
|---|---|---|
Authorization: Bearer <token> | yes | Bearer st_pk_01j5y9z3vk8x4rmt2pcqjf7nw9 |
Sentori-Sdk | yes | react-native/0.1.0 |
Content-Type: application/json | yes | (multipart and form-encoded are not accepted) |
Content-Encoding: gzip | no | gzip body supported (recommended for batch) |
Idempotency-Key | no | reserved; 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.
Token and ingest URL
Section titled “Token and ingest URL”Token format
Section titled “Token format”st_pk_<26 chars Crockford base32 of uuid-v7>
st_— Sentori product namespacepk_— project public key (may be embedded in client builds). Thesk_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.
Ingest URL
Section titled “Ingest URL”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.
No DSN URL
Section titled “No DSN URL”Sentori does not use Sentry’s https://<key>@<host>/<id> DSN format:
- URL-embedded tokens leak whenever a logging framework records request URLs.
- Token rotation should be independent of host change.
- Two
.envvariables are clearer than parsing a DSN string.
Documentation must not use the term “DSN”. Always say “token + ingest URL”.
Response codes
Section titled “Response codes”| Code | Meaning | Body |
|---|---|---|
202 Accepted | Event(s) accepted (not necessarily persisted yet) | {} |
400 Bad Request | Schema validation failed | see below |
401 Unauthorized | Missing, malformed, or unknown token | { "error": "unauthorized", "hint": "<what's likely wrong>" } — the hint distinguishes “no Authorization: Bearer header”, “token has the wrong prefix (not st_pk_/sk_)”, and “right shape but unrecognized (revoked / wrong project)“ |
413 Payload Too Large | Event > 1 MB or batch > 1 MB | { "error": "payloadTooLarge" } |
429 Too Many Requests | Rate limit hit | see below; Retry-After header set |
500 Internal Server Error | Server 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.
Event schema
Section titled “Event schema”A single event is a JSON object with these top-level fields:
| Field | Type | Required | Notes |
|---|---|---|---|
id | string (uuid-v7) | yes | client-generated; server uses as idempotency key |
timestamp | string (ISO 8601, UTC, ms precision) | yes | when the error occurred (not when reported) |
kind | enum | yes | "error" for any throwable; "anr" for Android ANR / iOS hang reports (Phase 22 sub-D / sub-E). New variants are additive — receivers MUST treat unknown values as "error" for grouping purposes. |
platform | enum | yes | "javascript" / "ios" / "android" (v0.2 may add "web", "node") |
release | string | yes | format: <app-name>@<version>+<build> (e.g. myapp@1.2.3+456) |
environment | string | yes | typically "prod", "staging", "dev" |
device | Device | yes | physical device info |
app | App | yes | application info |
user | User | null | no | omit or null if no user; SDK never auto-collects PII |
tags | object<string, string> | no | flat key-value, max 50 keys |
breadcrumbs | array | no | up to 100 entries |
error | Error | yes | the actual error |
fingerprint | array | no | client-suggested grouping; server may override per project rules |
traceId | string | null | no | uuid v7 of the surrounding trace. Set by SDK when the error fires inside an active span; links back to a row on /admin/api/traces/<traceId>. v0.4+. |
spanId | string | null | no | uuid v7 of the span the error happened inside (often the innermost active span). The dashboard renders an “In trace →” pill on the issue page that jumps to the trace detail view, scrolled to this span. v0.4+. |
symbolication | object | absent | server-set | { "releaseHasMap": bool } — set at ingest. true means a kind: sourcemap artifact exists for this release; if frames are still raw despite true, the uploaded map likely doesn’t match this build (or the frames fall outside it). Lets the dashboard say why a stack is unsymbolicated. Clients never send this. v0.5+. |
Device
Section titled “Device”Physical device / runtime host info.
| Field | Type | Required | Notes |
|---|---|---|---|
os | enum | yes | "ios" / "android" / "web" / "other" |
osVersion | string | yes | e.g. "17.4", "14" |
model | string | no | e.g. "iPhone15,2", "Pixel 8" |
locale | string (BCP-47) | no | e.g. "ja-JP" |
| Field | Type | Required | Notes |
|---|---|---|---|
version | string | yes | e.g. "1.2.3" |
build | string | no | e.g. "456" |
framework | Framework | null | no | non-null for cross-platform runtimes |
Framework
Section titled “Framework”| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | e.g. "react-native", "flutter", "capacitor" |
version | string | yes | framework version |
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | no | application-defined |
anonymous | boolean | no | hint 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(...).
Error schema
Section titled “Error schema”| Field | Type | Required | Notes |
|---|---|---|---|
type | string | yes | e.g. "TypeError", "NSInvalidArgumentException", "java.lang.RuntimeException" |
message | string | yes | human-readable message |
stack | array | yes | top-of-stack first |
cause | Error | null | no | nested 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.
Frame schema
Section titled “Frame schema”| Field | Type | Required | Notes |
|---|---|---|---|
function | string | no | function or method name; may be "<anonymous>" |
file | string | yes | relative path or filename |
line | int | yes | 1-indexed |
column | int | no | 1-indexed |
inApp | boolean | yes | true for application code, false for vendor/runtime |
absolutePath | string | no | absolute file path (used by iOS / Android frames) |
preContext | array | no | source lines before, max 5 |
postContext | array | no | source lines after, max 5 |
debugId | string | no | LC_UUID of the binary (32 hex, dashes optional). When present together with instructionAddress, the server symbolicates this frame against the matching uploaded dSYM. Phase 22 sub-B. |
arch | string | no | atos arch family — arm64 / arm64e / x86_64 / arm64_32 / armv7 / armv7s / armv7k / x86_64h / i386. Required if debugId is set. |
instructionAddress | int or string | no | PC at crash time. Decimal int or "0x..." hex. |
imageAddress | int or string | no | base address the binary was loaded at (ASLR slide). Same encoding as instructionAddress. The server resolves instructionAddress - imageAddress against the DWARF tables. |
rawLine / rawColumn | int | no | server-set. When a JS frame is symbolicated against an uploaded source map at ingest, file/line/column/function are overwritten with the original-source position and the pre-symbolication bundle position is preserved here (so the “show source” lookup, which reverse-maps through the same map, still has its starting point). Clients never send these. |
When the server resolves a native frame it overwrites function,
file, and line with the DWARF lookup result and flips inApp to
true; the native fields stay on the frame for re-symbolication after
later dSYM uploads.
When a JS frame is symbolicated at ingest against an uploaded
source map (kind: sourcemap for the event’s release), function /
file / line / column are likewise overwritten with the original
source position, inApp is flipped to true, and rawLine / rawColumn
keep the bundle position. Issue grouping then keys on the symbolicated
top in-app frame — so uploading a source map for a release that already
has events will start grouping its new events under src/Foo.tsx:42
rather than index.bundle:1:288432 (existing issues are not
re-fingerprinted; the old one simply goes quiet). Releases with no
source map uploaded are unaffected.
Breadcrumb schema
Section titled “Breadcrumb schema”| Field | Type | Required | Notes |
|---|---|---|---|
timestamp | string (ISO 8601) | yes | breadcrumb timestamp |
type | enum | yes | "nav" / "net" / "log" / "user" / "custom" |
data | object | yes | shape depends on type, see below |
Breadcrumb data by type
Section titled “Breadcrumb data by type”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" }Span schema
Section titled “Span schema”A span is one observed unit of work. Every observation — an HTTP
request, a DB query, a React render, a navigation transition, a server
handler — is a single span, never a “transaction wrapping
spans” (Sentry) and never a “transaction with measurements”
(OpenTelemetry envelopes). Root vs child is determined by
parentSpanId == null.
| Field | Type | Required | Notes |
|---|---|---|---|
id | string (uuid v7) | yes | this span’s id. Client-generated. Server treats duplicates as no-ops via ON CONFLICT DO NOTHING. |
traceId | string (uuid v7) | yes | shared by every span in the same trace. SDKs generate one trace id per root span, propagate via W3C traceparent header. |
parentSpanId | string (uuid v7) or null | yes | null marks the root span of a trace. Non-null must point to another span with the same traceId. |
op | string | yes | machine-readable category. Convention: <domain>.<verb> — http.client, http.server, db.query, db.transaction, cache.get, react.render, react.navigation, app.cold-start. Free-form for app-specific ops (checkout.charge). ≤ 64 chars. |
name | string | yes | human-readable label, displayed in the trace detail view. Often the URL / SQL / route name. ≤ 200 chars. |
startedAt | string (ISO 8601, ms) | yes | when this unit of work began. Server uses for time-bucket queries and partition routing. |
durationMs | integer (u32) | yes | duration in milliseconds. Capped at 24 * 3600 * 1000 server-side to discard clock-skew bugs. 0 is legal (instantaneous spans). |
status | enum | yes | "ok" / "error" / "cancelled". cancelled is for user-aborted work (a cancelled fetch AbortController), not for failed work. |
tags | object | no | flat Record<string, string>. Same limits as event tags (≤ 50 keys, ≤ 200 chars value, ≤ 64 chars key). Used for dashboard filtering (op:http.client status:error tag:http.method=POST). |
data | object | no | nested Record<string, unknown> for span-specific fields that don’t make sense to filter on (request body sketch, response headers, query plan). Max 16 KB after JSON encode. |
traceparent | string | no | the original W3C TraceContext header value, if this span inherited a trace context across a process boundary. Server keeps it for cross-system correlation; not displayed in the dashboard. |
Conventions for op
Section titled “Conventions for op”The dashboard’s filter chips key off op, so consistency matters.
Recommended namespace:
op | When |
|---|---|
http.client | outbound HTTP request from this process |
http.server | inbound HTTP request handled by this process (server side) |
db.query | one SQL statement |
db.transaction | one BEGIN ... COMMIT block |
cache.get / cache.set | Redis / Valkey / memcached |
react.render | manual <TraceRender op=...> for a sub-tree |
react.navigation | route change in react-router / @react-navigation |
app.cold-start | from process start until first frame |
SDKs auto-instrument the first six; the last two are wrappers consumers opt in to.
What we deliberately don’t do
Section titled “What we deliberately don’t do”- No
transactions[]— a “transaction” in Sentry is just the root span; we model that asparentSpanId == null. Removes one layer of schema. - No
measurements— Sentry’smeasurements: { fp: { value: 1234, unit: 'ms' } }map for vitals is replaced by ordinarytags/data. If you want web vitals, send them asop: 'web.vital'spans. - No separate
transaction.namevsspan.description— there is onenamefield. Apps that want to expose route vs URL keep them intags.routeandname. - No
op: 'navigation'automatic flush — RN nav span ends when the route is mounted; web nav span ends after first paint of the new route. No magic continuation across routes.
Batch wrapper
Section titled “Batch wrapper”Events batch
Section titled “Events batch”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
Authorizationheader) - Mixed
platformvalues are allowed within one batch
Spans batch
Section titled “Spans batch”POST /v1/spans:batch body:
{ "spans": [ /* up to 200 Span objects */ ] }Constraints:
- batch body ≤ 1 MB (after gzip decode)
- ≤ 200 spans per batch (spans are smaller than events — typically 200–400 bytes vs an event’s 1–10 KB — so the per-batch cap is higher)
- All spans MUST belong to the same project
- Mixed
traceIdvalues are allowed; mixedopvalues are allowed
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.
Size limits
Section titled “Size limits”| Item | Limit |
|---|---|
| single event payload (decoded) | 1 MB |
| batch payload (decoded) | 1 MB |
| breadcrumbs per event | 100 |
| stack frames per error | 100 |
| cause chain depth | 10 |
| tag keys per event | 50 |
| tag value length | 200 chars |
| tag key length | 64 chars |
| single span payload (decoded) | 64 KB |
| spans per batch | 200 |
span data blob size (after JSON encode) | 16 KB |
span op length | 64 chars |
span name length | 200 chars |
span durationMs | ≤ 86 400 000 (24 h) — anything past this is a clock-skew bug |
Events exceeding any of these are rejected with 400 listing the violated limit in details.
Rate limits
Section titled “Rate limits”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
Examples
Section titled “Examples”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 } ] } }}Audit-event webhook payload (forward-looking, Phase 27)
Section titled “Audit-event webhook payload (forward-looking, Phase 27)”Sentori does not deliver webhooks today. This section locks the contract so the audit trail and the eventual rule engine agree on the wire format before either side ships its half. Phase 27 implements delivery + signing; Phase 20 records the schema.
Endpoint: configured per-rule in the dashboard — anything that accepts
POST application/json. Sentori sends an HTTPS POST with a 5-second
connection timeout, 10-second read timeout, and at-least-once delivery
backed by an at_* retry queue (linear: 1m / 5m / 30m / 2h / give up
after 6 attempts).
Headers
Section titled “Headers”content-type: application/jsonsentori-event: audit.org.transfer.accepted # the action codesentori-delivery-id: 019e0ea2-fe14-7451-9441-a22d34e0fbaasentori-timestamp: 1768502431 # unix seconds, UTCsentori-signature: t=1768502431,v1=<hex-hmac-sha256>user-agent: sentori/<version>The signature covers <timestamp>.<raw-body> with HMAC-SHA-256 keyed
by the per-rule signing secret (revealed once at rule creation, like a
public token). t= prevents replay — receivers MUST reject deliveries
where the timestamp is older than 5 minutes from server time. The
v1= prefix exists so we can rotate to v2=<eddsa-...> later without
breaking existing receivers.
Body shape
Section titled “Body shape”{ "id": "019e0ea2-fe14-7451-9441-a22d34e0fbaa", "action": "org.transfer.accepted", "actionLabel": "Ownership transfer accepted", "occurredAt": "2026-05-09T22:00:31Z", "actor": { "id": "019e0e92-b22c-7302-a109-e30e00738b9c", "email": "old-owner@example.com" }, "org": { "id": "019e0e92-b4c3-7860-9b09-452d3704f90f", "slug": "acme", "name": "Acme Inc" }, "target": { "type": "transfer", "id": "019e0ea2-fe14-7451-9441-a22d34e0fbaa" }, "payload": { "from_user_id": "019e0e92-b22c-7302-a109-e30e00738b9c", "to_user_id": "019e0ea0-1111-7000-8000-aaaaaaaaaaaa" }}actionis the canonical code fromserver/src/audit.rs::actions, identical to what the audit log endpoint returns.actionLabelis the English-only human label fromaudit::label_for; localised receivers should ignore it.occurredAtis RFC 3339 in UTC, same as event timestamps elsewhere.actoris null when the action came from system code (none today).orgis null when the org has been deleted between the action and webhook delivery — receivers should display “deleted org” or drop on the floor.target.typeis one oforg / member / team / team_member / project / project_team / token / transfer— the same enum the dashboard’s audit log displays.payloadis opaque JSON; its exact keys depend onaction(see the table below). New keys may be added without bumping the wire version — receivers MUST ignore unknown keys.
Payload contracts per action
Section titled “Payload contracts per action”| Action | Required keys |
|---|---|
org.created | slug, name |
org.patched | name |
org.deleted | slug, name |
org.transfer.requested | to_user_id |
org.transfer.accepted | from_user_id, to_user_id |
member.role_patched | role |
member.removed | self_leave: bool |
team.created | slug, name |
team.deleted | slug |
team.member.added | team_slug, role |
team.member.removed | team_slug, self_leave: bool |
project.created | name |
project.team.bound | team_slug |
project.team.unbound | team_slug |
token.created | project_id, kind, last4 |
token.revoked | project_id |
release.deployed | project_id, release, environment, deploy_at |
Delivery semantics
Section titled “Delivery semantics”- Order: best-effort timestamp order; not strict. Receivers that
need ordering should sort by
occurredAtafter dedup. - Dedup key:
id(uuid v7) is unique per audit row; safe to use as the natural key. - Retry: a non-2xx response counts as a failure. Sentori retries
up to 6 times with the schedule above; after that the delivery is
marked
failedand surfaces in the rule’s recent-deliveries pane. - Body size: payload is bounded by
audit_logs.payload(jsonb), so practically < 4 KB per delivery.
Open questions deferred
Section titled “Open questions deferred”These are intentionally not specified in v0.1:
- Source map upload format and
POST /admin/api/releases/:r/sourcemapsendpoint 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— locked above (Phase 20); delivery / signing implementation in Phase 27- 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
Compatibility promises
Section titled “Compatibility promises”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/.
Document history
Section titled “Document history”- v0 — 2026-05-09 — initial draft (Phase 1 of ROADMAP).
- v0.1 — 2026-05-10 — locked the audit-event webhook payload (Phase 20 sub-D).
- v0.1.1 — 2026-05-10 — added
POST /v1/deploys(Phase 23 sub-C). - v0.1.2 — 2026-05-10 — added
POST /v1/sessions(Phase 26 sub-A).