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" }
]
} | Field | Notes |
|---|---|
id | Your UUID; the idempotency key. Optional, but set it — retries without it are new events. |
occurred_at | When it happened, ISO 8601. |
platform | Your slug, lowercase [a-z0-9_-]. No allow-list; keep it stable — it keys cross-platform identity. |
event_type | Open 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. |
audience | Required; the one closed enum. Who could see it: direct · group · public · presence · moderation · system. |
surface | Optional channel label for display. |
actor | platform_id (stable id), display_name, optional roles (enables role-based screening bypass). |
content.text | The screenable body. Null = stored, not screened. |
content.attributes | Free-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. |
recipients | Optional list of {kind, platform_id, display_name}; conventional kinds are user and channel. |
POST /v1/ingest — one event
| Response | Meaning |
|---|---|
201 accepted | Stored. Screening runs asynchronously; results appear in the queue, never in this response. |
200 duplicate | You already sent this id — idempotent no-op. |
401 | Missing, invalid, or revoked key. |
409 | This id belongs to a different server. Mint fresh UUIDs per event. |
413 | Body over the 50 MB request limit. |
422 | Malformed envelope, attributes over 64 KB, or platform doesn't match the key's server. |
429 | Rate 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
- Validated against the envelope schema.
- Persisted;
content.textis normalized for screening, the full content block preserved. - Screened asynchronously — lists first, then the AI tiers; typically seconds. Matches appear in the moderation queue.
- 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.