Observer

REST ingest API reference

One envelope, two endpoints. Any platform that can POST JSON can be a fully screened Observer integration with zero Observer-side code. New here? Start with the quickstart.

Authentication

Create a custom server in the dashboard to get a per-server API key (prefix tkp_). Send it either way:

Authorization: Bearer tkp_xxx
X-Observer-API-Key: tkp_xxx

The key identifies the server — you never send a server id. It's bound to one server of one platform; an envelope whose platform disagrees is rejected with 422. Rotate keys from the server page; rotation revokes the old key immediately.

The envelope

{
  "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "occurred_at": "2026-07-06T18:04:12Z",
  "platform": "my-game",
  "event_type": "message",
  "audience": "public",
  "surface": "global-chat",
  "actor": {
    "platform_id": "user-12345",
    "display_name": "Alice",
    "roles": ["vip"]
  },
  "content": {
    "text": "hey everyone gg",
    "attributes": { "channel_id": "c-9" }
  },
  "recipients": [
    { "kind": "channel", "platform_id": "c-9", "display_name": "global" }
  ]
}
FieldNotes
idYour UUID; the idempotency key. Optional, but set it — retries without it are new events.
occurred_atWhen it happened, ISO 8601.
platformYour slug, lowercase [a-z0-9_-]. No allow-list; keep it stable — it keys cross-platform identity.
event_typeOpen verb. The dashboard renders these best: message, message.edit, message.delete, direct_message, group_message, post, command, join/leave, voice.join/voice.leave, moderation, system.
audienceRequired; the one closed enum. Who could see it: direct · group · public · presence · moderation · system.
surfaceOptional channel label for display.
actorplatform_id (stable id), display_name, optional roles (enables role-based screening bypass).
content.textThe screenable body. Null = stored, not screened.
content.attributesFree-form JSON, stored verbatim, max 64 KB serialized. One shape is special: channel_id (a string) unlocks per-channel monitoring, conversation context, and channel-scoped batching.
recipientsOptional list of {kind, platform_id, display_name}; conventional kinds are user and channel.

POST /v1/ingest — one event

ResponseMeaning
201 acceptedStored. Screening runs asynchronously; results appear in the queue, never in this response.
200 duplicateYou already sent this id — idempotent no-op.
401Missing, invalid, or revoked key.
409This id belongs to a different server. Mint fresh UUIDs per event.
413Body over the 50 MB request limit.
422Malformed envelope, attributes over 64 KB, or platform doesn't match the key's server.
429Rate limited — 1200 requests/min per key, single + batch combined. Retry-After says when to resume.

POST /v1/ingest/batch — up to 500 events

{ "events": [ { ... }, { ... } ] }

{ "accepted": 2, "duplicates": 0, "failed": 0,
  "results": [ { "id": "...", "status": "accepted" }, ... ] }

Validation is all-or-nothing — one malformed envelope 422s the whole batch; fix and resend. Persistence is per-item idempotent — retrying a partially delivered batch never double-records; already-seen ids come back as duplicate. Anywhere near the rate limit, batch: one request carries 500 events.

What happens after the POST

  1. Validated against the envelope schema.
  2. Persisted; content.text is normalized for screening, the full content block preserved.
  3. Screened asynchronously — lists first, then the AI tiers; typically seconds. Matches appear in the moderation queue.
  4. Screened messages flow, pseudonymized, into the research corpus (see Where your data lives).

Enforcement on custom platforms

Ingest is one-directional today: events in. Flagged content from a REST platform is recorded and queued for your moderators, but Observer does not call back into your platform to execute actions. Pull-based decision delivery (polling / webhooks) is planned; the decision model is already platform-agnostic, only the transport is pending.