Account events
Read account changes, poll for new events, and receive signed webhooks.
Account events are small change notices for accounts, contacts, sources, recording streams, transcript turns, published runs, memories, conversations, suggestions, suggestion profiles, memory context, personal access tokens, workspace settings, and workspace retention. An event identifies the affected resource and describes the kind of change. It does not contain names, transcript text, actions, context values, audio, credentials, or storage locations. Fetch the current resource through an authorized API or MCP connection when you need its contents. A deleted resource may no longer be available.
Events are recorded even when your account has no delivery endpoints. Event and delivery records become eligible for cleanup after 30 days; HTTP attempt metadata after 90 days. These are retention thresholds, not exact deletion times. Events are useful for synchronization, but they are not a complete before/after history or a compliance audit log.
Event shape and types
An event has an immutable id, schema_version, sequence, occurred_at, account_id, type, resource, and data:
{
"id": "2ac1d0ae-ef18-4c12-9faf-ae79a6a2201e",
"schema_version": 1,
"sequence": "123",
"occurred_at": "2026-09-25T12:00:00Z",
"account_id": "b602df54-eae0-49bf-ac37-d8b242201e36",
"type": "contact.updated",
"resource": { "type": "contact", "id": "d7897444-da01-4c54-b29f-cff9d6c194fe" },
"data": { "changed_fields": ["name"] }
}Event names use <resource>.<past_tense_action>: a lowercase singular resource and a past-tense fact, with snake_case inside compound names. The resource.type matches the resource prefix. Supported types are account.created, account.updated, account.deleted, account.restored, contact.created, contact.updated, contact.deleted, source.created, source.updated, source.deleted, turn.created, turn.updated, turn.deleted, run.published, memory.created, memory.updated, memory.deleted, conversation.created, conversation.updated, conversation.deleted, suggestion.created, suggestion.updated, suggestion.deleted, suggestion_profile.updated, memory_context.updated, pipeline.created, pipeline.updated, device_session.started, device_session.joined, device_session.left, device_session.updated, device_session.ended, stream.created, stream.updated, personal_access_token.created, personal_access_token.revoked, workspace.updated, workspace.retention_applied, and workspace.deleted. Schema version 1 can gain event types. Skip an event whose type your client does not recognize, and still save the page cursor so polling moves past it.
An account name change produces account.updated. Account creation events exist for new accounts; older accounts do not receive a historical creation event. Account deletion atomically disables its active endpoints, cancels pending and leased deliveries, and records account.deleted in event history. Seven days later, unless the account was restored, its erasure records one workspace.deleted for each workspace it removes (see workspace deletion). Optima does not start new delivery claims or create new deliveries while the account is deleted. An HTTP request already in flight cannot be retracted. Erasing the deleted data produces no further events: there is no turn.deleted, contact.deleted, or similar event for each erased record. account.restored records restoration. A restore within the 7 days re-enables the endpoints the deletion disabled first, so they receive account.restored; endpoints you paused or disabled stay as they were. After the erasure the endpoints are gone. No missed events are backfilled.
Deleting a source produces source.deleted and hides the source's turns. A host's first shared recorder session on a recorder produces source.created for its source for that recorder, and every session produces device_session.started; later sessions reuse the source. Extending or ending a session produces device_session.updated with changed_fields ["expires_at"] or ["end_reason", "expires_at"]. A session ending keeps the source and its turns, so it produces device_session.ended rather than source.deleted. See session events for which participants receive each device_session event. Queued runs and hidden turns for that source do not emit subsequent events. Source deletion does not emit a turn.deleted event for each hidden turn; an explicit turn deletion does. A turn's attribution change, including a detected speaker filled in after the turn was published or two detected speakers merged into one, produces turn.updated. run.published means a transcript range was replaced; refresh the turns for the source rather than treating superseded turns as user deletions. Its run_id is correlation metadata, not a public run URL. Registering a recording stream, from an app, an integration, or a device, produces stream.created. Changing its title produces stream.updated with changed_fields ["title"], and recording its end produces stream.updated with ["end_ms"]; both carry the stream's source_id. Repeating a registration or setting the same title produces no event, and streams of a deleted source produce none. Older streams do not receive a historical stream.created. Signals attached to a stream produce no events. Presence heartbeats do not produce source events; use source activity for current connection and listening state.
sequence is a decimal string so clients do not lose integer precision. Treat it as event metadata, not as a polling cursor. Event data contains bounded metadata such as changed_fields, source_id, or run_id when applicable. A missing metadata field does not imply the underlying resource is unchanged.
The REST routes below require a Optima bearer session, use X-API-Version: 1, and return the normal success or error envelope. Requests are scoped to the signed-in account. MCP clients can manage endpoints with the separate endpoints:manage OAuth scope; an MCP token does not grant REST API access. See the MCP guide.
Read history or poll for changes
GET /events requires turns:read and supports limit (default 25, maximum 100) and an optional type filter. For descending history, omit after on the first request and follow the returned cursor until it is null. That history cursor uses the event sequence and is separate from the forward polling cursor.
For a client that wants changes from now onward:
- Call
GET /events?after=latest. It returns no items and a safe starting cursor indata.cursor. If the account has no eligible events, that cursor is"0". - Call
GET /events?after=<url-encoded-cursor>&limit=25periodically. Process items in the returned order and save the returned cursor after processing the page. - Repeat with the saved cursor. An empty page preserves your position. Use
after=0instead ofafter=latestto read all retained events from the beginning.
For example:
curl 'https://api.getoptima.com/events?after=latest' \
-H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \
-H 'X-API-Version: 1'The response has the same { items, cursor } shape as other lists:
{ "success": true, "data": { "items": [], "cursor": "0" } }Pass the returned cursor as an opaque value. Never construct one from an event's sequence or timestamp. cursor for descending history and after for forward polling cannot be combined. Keep the same type filter across pages. Polling is stateless on the server: it does not register an endpoint, acknowledge events, or store your position. Keep your cursor locally and handle retention gaps by resynchronizing resources. A cursor cannot recover events that have expired.
Polling cursors belong to the current database history. After a database restore or replacement, discard saved cursors, resynchronize your resources, and start polling again with after=latest. A cursor from the previous database history can silently skip changes.
See list account events for the complete query and response schema.
Create an endpoint
Create an account endpoint with an HTTPS URL. Optima sends event webhooks to the endpoint; the webhook is the message, and the endpoint is the account object that stores its URL, topics, status, and signing secret.
curl https://api.getoptima.com/endpoints \
-H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \
-H 'X-API-Version: 1' \
-H 'Content-Type: application/json' \
-d '{"url":"https://example.com/plum-events"}'Add topics, an array of supported event types, to limit which changes are delivered. Omit topics or pass [] to receive every supported type. There are no resource filters or expression rules. Only events created while the endpoint is active create deliveries. Creating or reactivating an endpoint does not backfill event history.
account.created and account.deleted are available in event history and polling, but they do not reach endpoints under the current account lifecycle: creation precedes endpoints, and deletion disables them. account.restored reaches the endpoints a restore within the 7-day grace period re-enables, and no others. The same holds for workspace.deleted about a deleted account's own workspaces, which is recorded while the account is deleted; a member's endpoint does receive workspace.deleted when an owner or Optima deletes a shared workspace. account.updated can be delivered while an endpoint is active.
The creation response wraps { endpoint, signing_secret } in data. Store signing_secret securely when you receive it; Optima returns it only once. Later reads include the endpoint's status and delivery statistics without its secret. The URL, topics, and credential cannot be edited. To change them, create a new endpoint and disable the old one.
See create an endpoint for the exact request and response schema.
Use PATCH /endpoints/{id} with { "status": "active" }, { "status": "paused" }, or { "status": "disabled" }. Pausing stops new claims but retains outstanding deliveries. Disabling cancels them. A delivery already claimed when the status changes may still arrive. Ten consecutive failures disable an endpoint until you activate it again; activation resets the failure count. There is no replay or secret rotation operation.
GET /endpoints lists endpoints and GET /endpoints/{id} reads one. GET /endpoints/{id}/deliveries lists delivery status for troubleshooting. GET /endpoints/{id}/deliveries/{delivery_id} returns the delivery, the event sent, and its HTTP attempts. The delivery list uses opaque UUID cursors and the usual limit bounds. See the API reference for exact response fields.
DELETE /endpoints/{id} permanently removes the endpoint, its encrypted signing secret, and its delivery and attempt history. Account events remain available through /events. Endpoint configuration changes do not create account events. Disable an endpoint when you may reactivate it; delete it when you no longer want Optima to retain the destination or its delivery history.
Verify a webhook
Optima POSTs the event as a raw JSON body. It sends three Standard Webhooks headers:
| Header | Value |
|---|---|
webhook-id | A delivery UUID that stays the same across retries. |
webhook-timestamp | Unix seconds for this attempt. |
webhook-signature | v1, followed by a base64 HMAC-SHA256 signature. |
Before parsing JSON, verify the signature over webhook-id + "." + webhook-timestamp + "." + raw_body. The HMAC key is the base64-decoded part of your signing secret after whsec_. Compare in constant time and reject timestamps outside a short tolerance, such as five minutes. A Standard Webhooks verifier can handle these steps. Save accepted webhook-id values to deduplicate retries.
Node.js
Pass the request body as a Buffer. Do not run JSON body parsing before verification.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyPlumWebhook(rawBody, headers, secret) {
const id = headers.get('webhook-id');
const timestamp = headers.get('webhook-timestamp');
const signatureHeader = headers.get('webhook-signature');
if (!id || !timestamp || !signatureHeader || !secret.startsWith('whsec_')) return false;
if (!/^[0-9]+$/.test(timestamp)) return false;
const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(ageSeconds) || ageSeconds > 300) return false;
const key = Buffer.from(secret.slice(6), 'base64');
if (key.length !== 32) return false;
const signed = Buffer.concat([
Buffer.from(`${id}.${timestamp}.`, 'utf8'),
rawBody,
]);
const expected = createHmac('sha256', key).update(signed).digest();
return signatureHeader.split(/\s+/).some((item) => {
const [version, encoded] = item.split(',', 2);
if (version !== 'v1' || !encoded) return false;
const received = Buffer.from(encoded, 'base64');
return received.length === expected.length && timingSafeEqual(received, expected);
});
}For example, in a Fetch-compatible handler, read new Uint8Array(await request.arrayBuffer()), convert it to a Buffer, verify it with request.headers, and only then call JSON.parse on the body.
Python
Pass the request body as bytes. In FastAPI or Starlette, obtain it with await request.body() before parsing JSON.
import base64
import binascii
import hashlib
import hmac
import time
def verify_plum_webhook(raw_body: bytes, headers, secret: str) -> bool:
delivery_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signature_header = headers.get("webhook-signature")
if not delivery_id or not timestamp or not signature_header:
return False
if not secret.startswith("whsec_") or not timestamp.isdigit() or len(timestamp) > 20:
return False
if abs(time.time() - int(timestamp)) > 300:
return False
try:
key = base64.b64decode(secret[6:], validate=True)
except (binascii.Error, ValueError):
return False
if len(key) != 32:
return False
signed = f"{delivery_id}.{timestamp}.".encode() + raw_body
expected = base64.b64encode(
hmac.new(key, signed, hashlib.sha256).digest()
).decode()
return any(
version == "v1" and hmac.compare_digest(encoded, expected)
for item in signature_header.split()
for version, separator, encoded in [item.partition(",")]
if separator
)After successful verification, check whether webhook-id has already been processed. Record it in the same database transaction as any non-idempotent side effects, then parse and handle the JSON event. Keep the signing secret in a server-side secret store and never log it.
Return any 2xx status to accept a delivery. Optima retries network errors, 408, 429, and 5xx responses with exponential backoff and jitter; Retry-After can delay a retry by up to twelve hours. Other non-2xx statuses end that delivery. A delivery gets at most eight attempts within 24 hours of its event. Initial delivery normally begins within a minute, but webhooks can arrive out of order or more than once. Do not use arrival order as a resource version.
The Worker checks webhook URL syntax and rejects embedded credentials and fragments. It does not resolve DNS or enforce a destination allowlist. Redirects are not followed, requests time out after ten seconds, and response bodies are discarded.
Memory changes
memory.created, memory.updated, and memory.deleted identify the Memory resource.
They include bounded revision metadata and changed field names without content.
Incoming references are computed; changing a referencing memory does not mutate
the target. Source deletion requires
invalidating derived memory caches, or clearing and refetching them when dependency
information is incomplete. See Memories.
Conversation publication emits conversation.created or conversation.updated;
replacement is an update, while explicit deletion emits conversation.deleted.
Suggestion publication and user-visible lifecycle changes emit suggestion.created
or suggestion.updated; explicit deletion emits suggestion.deleted. Acceptance,
completion, withdrawal, protected edits, role changes, grouping, separation, and
factual review changes are updates with bounded changed fields and state version
metadata. suggestion_profile.updated records an explicitly saved profile revision;
the implicit revision-zero default emits no event. memory_context.updated records
a visible versioned context change. None
of these events contains conversation text, action text, names, role wording,
context values, or transcript content. See
Conversations and suggestions.
Pipeline ownership
New suggestion events include data.pipeline_id, allowing clients to refresh only
the matching feed. Older retained events may omit this field; refetch the resource
or invalidate the relevant account caches. Pipeline creation and default-selection
changes emit pipeline.created and pipeline.updated. They contain bounded change
notices, never pipeline content or transcript text. The shared declared profile uses
memory_context.updated; pipeline-local learned preferences do not change another
pipeline's feed.
Account access
Granting or revoking developer access emits
account.updated with permissions and developer_enabled in changed_fields.
Turning a content layer on or off emits
account.updated with permissions. Reading events needs the transcripts layer, so an
account whose transcripts layer is off reads these notices once it is on again.
Turning workspace sharing on or off emits
account.updated with sharing_enabled. Turning the experimental agent
on or off emits account.updated with agent_enabled.
Joining or leaving a shared workspace, a role change there, or renaming a workspace emits
account.updated with workspaces to each affected member. One change emits one
event per member; refetch GET /me or GET /workspaces for the new values. Creating a
workspace notifies its owner, and deleting one notifies every member it removes with both
account.updated and workspace.deleted. Invitations
emit no events of their own: the invitee may not have an account yet, and accepting
an invitation is a membership change, which emits account.updated to the new member.
An account's private workspace is created and deleted only with the account: creating it adds no
event beyond account.created or account.restored, and deleting it (the account's
erasure, 7 days after account.deleted) records workspace.deleted; renaming it emits
account.updated with
workspaces. Plans and other entitlement settings are
internal configuration and emit no events.
Personal access tokens
personal_access_token.created and personal_access_token.revoked record when a
personal access token was created or revoked. Their
data contains the token's last_four and expires_at (null when it never
expires); revocation adds reason: user, sign_out_everywhere, or
account_deleted. They never contain the token, its hash, or its name. Account
deletion records account.deleted before the revocations; because deletion disables
endpoints, those revocations appear only in event history and polling. Expiry is not
an event.
Workspace settings
workspace.updated records a change to a workspace: its name or its
settings. Its resource is the workspace, and each
current member of the workspace receives their own event. changed_fields names what
changed: name for a rename, or the changed retention buckets as retention.audio,
retention.transcripts, retention.insights, or retention.voice. Read
GET /workspaces/{id} for the name or GET /workspaces/{id}/settings for the new
retention values. Saving the current name or values emits nothing. A change to a plan's retention limit is staff configuration, like plans, and
emits no event.
Retention
Optima keeps each workspace's audio, transcripts, insights (memories, conversations, and
suggestions), and voice recognition data for the workspace's retention period, and removes
content a workspace does not keep once processing no longer needs it. When an hourly
retention sweep removes content, it records one workspace.retention_applied event for
the workspace; a sweep that removes nothing records none. An expired turn, memory,
conversation, or suggestion is then left out of lists, and reading it by ID returns 410
CONTENT_EXPIRED; a turn's or stream's audio, a signal's payload_state, and the
source of evidence that cited expired content say expired (see
Removed content). A memory built on an expired
transcript keeps its own text; only its quotes from that transcript are gone. Retention
does not emit a turn, memory, conversation, suggestion, or stream event for each
record, so this event is your signal to refetch or drop cached content.
{
"id": "5d0b8c65-0c52-4b1c-9d57-6f0f86b7a3c1",
"schema_version": 1,
"sequence": "456",
"occurred_at": "2026-09-29T13:00:00Z",
"account_id": "b602df54-eae0-49bf-ac37-d8b242201e36",
"type": "workspace.retention_applied",
"resource": { "type": "workspace", "id": "0f3c2d1e-5a6b-4c7d-8e9f-a0b1c2d3e4f5" },
"data": {
"changed_fields": ["audio", "transcripts"],
"buckets": {
"audio": { "cutoff": "2026-09-22T13:00:00.000000+00:00", "count": 42 },
"transcripts": { "cutoff": "2026-08-30T13:00:00.000000+00:00", "count": 310 }
}
}
}changed_fields lists the buckets the sweep removed content from: audio, transcripts,
insights, or voice. For each, buckets gives the cutoff it applied (null when the
workspace does not keep that bucket, which removes the bucket's content once processing no
longer needs it) and count, the number of records it changed across the whole sweep.
The cutoff is compared with a different time in each bucket:
| Bucket | Removed when this time is before the cutoff |
|---|---|
audio | When each audio chunk's upload began; a stream's title goes once all its audio is gone and the stream started before the cutoff |
transcripts | When each turn was published (and when each stream signal was recorded) |
insights | When each memory, conversation, or suggestion last changed, including new revisions, reviews, and feedback; the record's revisions and feedback go with it |
voice | When each recognized voice was last matched (or created, if never matched) |
A memory, conversation, or suggestion counts as one record however many revisions it has. The event never contains the removed content or record IDs.
Workspace deletion
workspace.deleted records that a workspace was deleted permanently
and that its data is being erased. Its resource is the workspace, and data.reason is
owner_deleted (an owner deleted it), staff_deleted (Optima deleted it), or
account_deleted (erasing a deleted account removed the workspace because it was the
only member). Each account that lost the workspace receives one event: every member of a
deleted shared workspace, or the deleted account itself for its private workspace and any
shared workspace it was alone in. The event never contains the workspace's name. A deleted workspace cannot
be read or restored, and its ID is never reused. Erasing its data emits no per-record
events.