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.
  • Distributed tracing (v0.4)traceId / spanId are used. Every observation is a single span; there is no separate transaction object. Root vs child is parentSpanId == null. No nested transactions[], no measurements, no separate envelopes. See Span schema and POST /v1/spans.

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).

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 via ON CONFLICT DO NOTHING, so retries from a flaky network don’t double-count the session.
  • userId — application-defined identifier (same value passed to sentori.setUser({ id })). Omit / null for anonymous sessions — they still count toward session-rate but not user-rate.
  • status — one of ok / 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).
  • 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.

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>" }.

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).

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’s release config exactly.
  • environment — optional, ≤ 64 chars; mirrors the runtime field but is not enforced.
  • deployedAt — optional RFC 3339 timestamp. Defaults to server now(). 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.

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", "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 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" 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.
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 | nullnouuid 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+.
spanIdstring | nullnouuid 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+.
symbolicationobject | absentserver-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+.

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
debugIdstringnoLC_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.
archstringnoatos arch family — arm64 / arm64e / x86_64 / arm64_32 / armv7 / armv7s / armv7k / x86_64h / i386. Required if debugId is set.
instructionAddressint or stringnoPC at crash time. Decimal int or "0x..." hex.
imageAddressint or stringnobase address the binary was loaded at (ASLR slide). Same encoding as instructionAddress. The server resolves instructionAddress - imageAddress against the DWARF tables.
rawLine / rawColumnintnoserver-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.

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" }

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.

FieldTypeRequiredNotes
idstring (uuid v7)yesthis span’s id. Client-generated. Server treats duplicates as no-ops via ON CONFLICT DO NOTHING.
traceIdstring (uuid v7)yesshared by every span in the same trace. SDKs generate one trace id per root span, propagate via W3C traceparent header.
parentSpanIdstring (uuid v7) or nullyesnull marks the root span of a trace. Non-null must point to another span with the same traceId.
opstringyesmachine-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.
namestringyeshuman-readable label, displayed in the trace detail view. Often the URL / SQL / route name. ≤ 200 chars.
startedAtstring (ISO 8601, ms)yeswhen this unit of work began. Server uses for time-bucket queries and partition routing.
durationMsinteger (u32)yesduration in milliseconds. Capped at 24 * 3600 * 1000 server-side to discard clock-skew bugs. 0 is legal (instantaneous spans).
statusenumyes"ok" / "error" / "cancelled". cancelled is for user-aborted work (a cancelled fetch AbortController), not for failed work.
tagsobjectnoflat 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).
dataobjectnonested 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.
traceparentstringnothe 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.

The dashboard’s filter chips key off op, so consistency matters. Recommended namespace:

opWhen
http.clientoutbound HTTP request from this process
http.serverinbound HTTP request handled by this process (server side)
db.queryone SQL statement
db.transactionone BEGIN ... COMMIT block
cache.get / cache.setRedis / Valkey / memcached
react.rendermanual <TraceRender op=...> for a sub-tree
react.navigationroute change in react-router / @react-navigation
app.cold-startfrom process start until first frame

SDKs auto-instrument the first six; the last two are wrappers consumers opt in to.

  • No transactions[] — a “transaction” in Sentry is just the root span; we model that as parentSpanId == null. Removes one layer of schema.
  • No measurements — Sentry’s measurements: { fp: { value: 1234, unit: 'ms' } } map for vitals is replaced by ordinary tags / data. If you want web vitals, send them as op: 'web.vital' spans.
  • No separate transaction.name vs span.description — there is one name field. Apps that want to expose route vs URL keep them in tags.route and name.
  • 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.

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

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 traceId values are allowed; mixed op values 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.

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
single span payload (decoded)64 KB
spans per batch200
span data blob size (after JSON encode)16 KB
span op length64 chars
span name length200 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.

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
}
]
}
}
}

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).

content-type: application/json
sentori-event: audit.org.transfer.accepted # the action code
sentori-delivery-id: 019e0ea2-fe14-7451-9441-a22d34e0fbaa
sentori-timestamp: 1768502431 # unix seconds, UTC
sentori-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.

{
"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"
}
}
  • action is the canonical code from server/src/audit.rs::actions, identical to what the audit log endpoint returns.
  • actionLabel is the English-only human label from audit::label_for; localised receivers should ignore it.
  • occurredAt is RFC 3339 in UTC, same as event timestamps elsewhere.
  • actor is null when the action came from system code (none today).
  • org is null when the org has been deleted between the action and webhook delivery — receivers should display “deleted org” or drop on the floor.
  • target.type is one of org / member / team / team_member / project / project_team / token / transfer — the same enum the dashboard’s audit log displays.
  • payload is opaque JSON; its exact keys depend on action (see the table below). New keys may be added without bumping the wire version — receivers MUST ignore unknown keys.
ActionRequired keys
org.createdslug, name
org.patchedname
org.deletedslug, name
org.transfer.requestedto_user_id
org.transfer.acceptedfrom_user_id, to_user_id
member.role_patchedrole
member.removedself_leave: bool
team.createdslug, name
team.deletedslug
team.member.addedteam_slug, role
team.member.removedteam_slug, self_leave: bool
project.createdname
project.team.boundteam_slug
project.team.unboundteam_slug
token.createdproject_id, kind, last4
token.revokedproject_id
release.deployedproject_id, release, environment, deploy_at
  • Order: best-effort timestamp order; not strict. Receivers that need ordering should sort by occurredAt after 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 failed and surfaces in the rule’s recent-deliveries pane.
  • Body size: payload is bounded by audit_logs.payload (jsonb), so practically < 4 KB per delivery.

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 — 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

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).
  • 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).