GraphQL API#
The primary API for interacting with Outeract. Every operation is a GraphQL query or mutation against a single endpoint.
Endpoints#
| Endpoint | Auth | Description |
|---|---|---|
POST / | API Key | Developer API for programmatic access |
Operations that manage the account itself (organisations, applications, platform connections, API keys, admin users, webhook subscriptions and event schemas) are performed in the console rather than through this API.
Headers#
Authorization: Bearer YOUR_API_KEY
Content-Type: application/jsonNote: API keys are already scoped to a specific application, so no
X-Outeract-App-IDheader is needed.
Schema Exploration#
Access the interactive GraphQL playground at the endpoint URL. Introspection is enabled by default:
{
__schema {
types {
name
description
}
}
}Developer API#
Operations available with API key authentication.
Events#
events#
Query events with Relay-style cursor pagination, filtering, and relation-based queries.
query Events($first: Int, $after: String, $eventTypes: [String!]) {
events(first: $first, after: $after, eventTypes: $eventTypes) {
edges {
node {
id
eventTypeName
payload
createdAt
processedAt
edges {
edgeType
targetNodeType
targetNodeId
}
}
cursor
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
count
}
}
}| Parameter | Type | Default | Description |
|---|---|---|---|
first | Int | - | Number of events (forward pagination) |
last | Int | - | Number of events (backward pagination) |
after | String | null | Cursor for forward pagination |
before | String | null | Cursor for backward pagination |
eventTypes | [String!] | null | Filter by event type names |
eventIds | [UUID!] | null | Fetch specific events by ID |
userId | UUID | null | Filter events linked to a user |
conversationId | UUID | null | Filter events in a conversation |
originEventId | UUID | null | Filter by parent event |
relatedTo | RelationFilter | null | Filter by relationships to other events |
relatedNodeId | UUID | null | Only events having an edge (event as source) targeting this node, of any node type (e.g. a user). ANDs with all other filters; applied at the database level so cursor pagination stays exact |
relatedNodeType | String | null | With relatedNodeId, require the edge’s target node type (e.g. "user"). Ignored without relatedNodeId |
relatedEdgeType | String | null | With relatedNodeId, require the edge’s edge type (e.g. "sent_to", "participant"). Ignored without relatedNodeId |
payloadFilters | [PayloadFilter!] | null | Filter by payload field values |
since | String | null | ISO datetime; only events after this time |
textSearch | String | null | Full-text search over the event’s message text (payload.message.text) and saved description (payload.description). See Searching events |
hasAttachment | Boolean | null | true: only events with at least one attachment edge (event → file). false: only events without one |
minimal | Boolean | false | Return minimal event data |
includeTiming | Boolean | false | Include query timing breakdown |
Searching events#
textSearch is a filter, not a ranking: matches come back in the same
chronological order as any other events query, and page with the same
cursors. To walk every match from newest to oldest, keep passing the previous
page’s startCursor as before:
query Search($q: String!, $before: String) {
events(textSearch: $q, last: 50, before: $before) {
edges { node { id payload createdAt } }
pageInfo { hasPreviousPage startCursor }
}
}The text is parsed with Postgres
websearch_to_tsquery
using the english configuration:
- words are ANDed and stemmed (
runningmatchesrun), and stop words such astheare ignored — a query made only of stop words matches nothing; "quoted words"match as a phrase,orbetween words matches either, and-wordexcludes events containing it;- malformed input never errors. A blank
textSearchis treated as no filter.
It combines (AND) with every other filter, e.g.
events(userId: $u, textSearch: "invoice", hasAttachment: true, last: 20).
On SQLite (local development) the search falls back to a case-insensitive
substring match of each word, without stemming or operators.
The search is backed by the ix_event_fts GIN index, created by
server/migrations/_2026_10_07_event_fts_index.py.
event#
Get a single event by ID.
query Event($id: UUID!) {
event(id: $id) {
id
eventTypeName
payload
createdAt
originEventId
edges {
edgeType
targetNodeType
targetNodeId
}
}
}Each edge exposes only edgeType, sourceNodeType/sourceNodeId,
targetNodeType/targetNodeId, and extraData. Resolve the target entity
with a follow-up query (identities(identityIds:), files(fileIds:),
users(userIds:)). See Edges.
createEvent#
Create a custom event with optional edges. Requires events:write scope.
mutation CreateEvent(
$userId: UUID!
$payload: JSON!
$eventType: String
$edges: [JSON!]
) {
createEvent(
userId: $userId
payload: $payload
eventType: $eventType
edges: $edges
) {
id
eventTypeName
payload
createdAt
}
}| Parameter | Type | Default | Description |
|---|---|---|---|
userId | UUID! | required | User ID to associate the event with |
payload | JSON! | required | Event payload data |
eventType | String | null | Event type name (e.g., "order.created") |
eventTypeId | UUID | null | Event schema ID (alternative to eventType) |
originEventId | UUID | null | Parent event ID for event chains |
edges | [JSON!] | null | Edges to create with the event |
idempotencyKey | String | null | Client-supplied key for safe retries; see Idempotency |
updateEvent#
Update an event’s payload. Requires events:write scope.
mutation UpdateEvent($eventId: UUID!, $payload: JSON, $mergePayload: Boolean) {
updateEvent(eventId: $eventId, payload: $payload, mergePayload: $mergePayload) {
id
payload
}
}| Parameter | Type | Default | Description |
|---|---|---|---|
eventId | UUID! | required | Event ID to update |
payload | JSON | null | New payload data |
mergePayload | Boolean | true | If true, merge with existing payload; if false, replace |
deleteUserEvents#
Delete events associated with a user. Requires events:write scope.
mutation DeleteUserEvents($userId: UUID!, $before: DateTime, $since: DateTime) {
deleteUserEvents(userId: $userId, before: $before, since: $since)
}Returns Int, the number of events deleted.
| Parameter | Type | Default | Description |
|---|---|---|---|
userId | UUID! | required | User whose events to delete |
before | DateTime | null | Only delete events before this time |
since | DateTime | null | Only delete events after this time |
eventCursorAtDatetime#
Get a cursor positioned at a specific datetime, for use with the events query.
query { eventCursorAtDatetime(datetime: "2024-06-01T00:00:00Z") }eventCursor#
Get the cursor for a specific event by ID.
query { eventCursor(eventId: "event-uuid") }eventTypeStats#
Get event counts grouped by event type over a time period.
query { eventTypeStats(days: 30) { entityId date count } }eventTypeLastActivity#
Get the timestamp of the most recent event for each event type.
query { eventTypeLastActivity { eventType lastEventAt } }resolveId#
Resolve a prefixed ID to a full UUID.
query { resolveId(prefix: "msg", userId: "user-uuid", recordType: "event") }Users#
users#
Query users with Relay-style cursor pagination and search.
query Users($first: Int, $search: String, $orderBy: String) {
users(first: $first, search: $search, orderBy: $orderBy) {
edges {
node {
id
name
isSystemUser
lastActiveAt
createdAt
identities {
id
externalId
identityType
platformConnection { id platformName }
}
}
cursor
}
pageInfo {
hasNextPage
endCursor
count
}
}
}| Parameter | Type | Default | Description |
|---|---|---|---|
first / last | Int | - | Number of users to return |
after / before | String | null | Cursor for pagination |
search | String | null | Search by name, user ID, external ID, or identity ID |
orderBy | String | "created_at" | "created_at" or "last_active_at" |
userIds | [UUID!] | null | Fetch specific users by ID (bypasses pagination) |
configFilters | [ConfigFilter!] | null | Filter by config_data fields |
ConfigFilter matches json_extract_text(config_data, field) == value:
# Only system users
users(configFilters: [{ field: "system_user", value: "true" }]) { ... }identities#
Query user identities (platform accounts).
query {
identities(identityType: "whatsapp", limit: 50) {
id
externalId
identityType
user { id name }
platformConnection { id platformName }
}
}| Parameter | Type | Default | Description |
|---|---|---|---|
identityIds | [UUID!] | null | Fetch specific identities by ID |
externalIds | [String!] | null | Fetch by external IDs |
userId | UUID | null | Filter by user |
identityType | String | null | Filter by type (e.g., "whatsapp", "slack") |
limit | Int | 100 | Maximum to return |
offset | Int | 0 | Offset for pagination |
createUser#
mutation CreateUser($name: String, $isSystemUser: Boolean) {
createUser(name: $name, isSystemUser: $isSystemUser) {
id
name
isSystemUser
createdAt
}
}| Parameter | Type | Default | Description |
|---|---|---|---|
name | String | null | Display name |
isSystemUser | Boolean | false | Whether this is a system/bot user |
createIdentity#
Create a platform identity for a user.
mutation CreateIdentity(
$userId: UUID!
$externalId: String!
$identityType: String!
$config: JSON
) {
createIdentity(
userId: $userId
externalId: $externalId
identityType: $identityType
config: $config
) {
id
externalId
identityType
user { id name }
}
}| Parameter | Type | Default | Description |
|---|---|---|---|
userId | UUID! | required | User to attach the identity to |
externalId | String! | required | External identifier (phone number, username, etc.) |
identityType | String! | required | Platform or custom type (e.g., "whatsapp", "slack", "phone") |
config | JSON | null | Additional identity configuration |
transferIdentities#
Transfer all identities from one user to another (user merge).
mutation TransferIdentities(
$sourceUserId: UUID!
$targetUserId: UUID!
$deleteSourceUser: Boolean
$deleteSourceConversations: Boolean
) {
transferIdentities(
sourceUserId: $sourceUserId
targetUserId: $targetUserId
deleteSourceUser: $deleteSourceUser
deleteSourceConversations: $deleteSourceConversations
) {
id
name
identities { id externalId }
}
}| Parameter | Type | Default | Description |
|---|---|---|---|
sourceUserId | UUID! | required | User to transfer identities from |
targetUserId | UUID! | required | User to transfer identities to |
deleteSourceUser | Boolean | true | Delete the source user after transfer |
deleteSourceConversations | Boolean | true | Delete orphaned conversations |
transferIdentity#
Transfer a single identity to a different user.
mutation { transferIdentity(identityId: "id-uuid", targetUserId: "user-uuid") { id externalId } }deleteIdentity#
mutation { deleteIdentity(identityId: "id-uuid") }Returns Boolean.
deleteUser#
mutation { deleteUser(id: "user-uuid") }Returns Boolean.
Conversations#
conversations#
List conversations with Relay-style pagination and search.
query {
conversations(first: 25, search: "Alice") {
edges {
node {
id
title
createdAt
updatedAt
messageCount
participants { userId userName }
}
cursor
}
pageInfo { hasNextPage endCursor count }
}
}| Parameter | Type | Default | Description |
|---|---|---|---|
first | Int | 25 | Number of conversations to return |
after | String | null | Cursor for pagination |
search | String | null | Filter by title or participant name |
userConversations#
Get conversations for a specific user.
query {
userConversations(userId: "user-uuid", limit: 50) {
id
title
messageCount
participants { userId userName }
}
}| Parameter | Type | Default | Description |
|---|---|---|---|
userId | UUID! | required | User ID |
limit | Int | 50 | Maximum conversations to return |
deleteConversations#
mutation {
deleteConversations(conversationIds: ["conv-uuid-1"], deleteMessages: true)
}Returns Int, the number of conversations deleted.
| Parameter | Type | Default | Description |
|---|---|---|---|
conversationIds | [UUID!]! | required | Conversation IDs to delete |
deleteMessages | Boolean | false | Also delete all messages in the conversations |
Messages#
sendMessage#
Send a message to a user. Requires at least one of message, template, or fileIds.
mutation SendMessage(
$message: String
$recipientUserId: UUID
$conversationId: UUID
$platform: PlatformName
$platformConnectionId: UUID
$fileIds: [UUID!]
$template: JSON
) {
sendMessage(
message: $message
recipientUserId: $recipientUserId
conversationId: $conversationId
platform: $platform
platformConnectionId: $platformConnectionId
fileIds: $fileIds
template: $template
) {
id
eventTypeName
payload
createdAt
}
}| Parameter | Type | Default | Description |
|---|---|---|---|
message | String | null | Text content |
recipientUserId | UUID | null | Recipient user ID |
conversationId | UUID | null | Send to an existing conversation |
senderUserId | UUID | null | Override sender (defaults to system user) |
platform | PlatformName | null | Target platform |
platformConnectionId | UUID | null | Specific platform connection to use |
fileIds | [UUID!] | null | File attachments |
template | JSON | null | Template message (see below) |
idempotencyKey | String | null | Client-supplied key for safe retries; see Idempotency |
Template structure:
| Field | Type | Description |
|---|---|---|
name | String (required) | Template name as registered with the platform |
language | String (required) | Language code (e.g. "en", "en_US") |
components | Array | Template components with parameter substitutions |
Calls#
Phone calls run on a Twilio Voice connection. These mutations only start and stop a call; what happens to it arrives as call events.
startCall#
Phone a user. Returns the call.initiated event. The number dialled is always the recipient’s own PHONE identity; there is deliberately no way to pass a number.
mutation StartCall(
$recipientUserId: UUID!
$idempotencyKey: String!
$reason: String
$firstMessage: String
) {
startCall(
recipientUserId: $recipientUserId
idempotencyKey: $idempotencyKey
reason: $reason
firstMessage: $firstMessage
) {
id
eventTypeName
payload
createdAt
}
}| Parameter | Type | Default | Description |
|---|---|---|---|
recipientUserId | UUID! | required | The user to call |
idempotencyKey | String! | required | Non-blank, at most 200 characters. A retry with the same key returns the original event and never dials twice |
senderUserId | UUID | null | The system user calling. Must be the one behind the voice connection; inferred when omitted |
reason | String | null | Why the call is being made; carried to call.started |
firstMessage | String | null | What to open the call with; carried to call.started |
metadata | JSON | null | Free-form context carried to call.started |
platformConnectionId | UUID | null | A specific voice connection (default: the application’s first enabled one) |
Errors: the application has no enabled voice connection, the recipient has no phone number on record, or Twilio refused the call. An error containing “may have been placed” means the outcome is unknown: do not retry with a new key.
endCall#
Hang up a live call by its Twilio CallSid. Returns true if Twilio accepted the request; call.ended follows as an event.
mutation {
endCall(callSid: "CA0123456789abcdef0123456789abcdef")
}| Parameter | Type | Default | Description |
|---|---|---|---|
callSid | String! | required | The call’s Twilio CallSid (call_sid on call.started) |
platformConnectionId | UUID | null | A specific voice connection (default: the application’s first enabled one) |
Both mutations require the events:write scope.
Files#
files#
Fetch files by their IDs (batch query).
query {
files(fileIds: ["file-uuid-1", "file-uuid-2"]) {
id
filename
mimeType
size
url
createdAt
}
}| Parameter | Type | Default | Description |
|---|---|---|---|
fileIds | [UUID!]! | required | List of file IDs to fetch |
Edges#
edges#
Query relationships between entities.
query {
edges(eventIds: ["evt-uuid-1", "evt-uuid-2"], edgeType: "sent_by") {
id
edgeType
sourceNodeId
sourceNodeType
targetNodeId
targetNodeType
extraData
}
}| Parameter | Type | Default | Description |
|---|---|---|---|
eventIds | [UUID!] | null | Filter by source event IDs |
sourceNodeId | UUID | null | Filter by source node |
sourceNodeType | String | null | Filter by source type ("event", "user", "identity") |
targetNodeId | UUID | null | Filter by target node |
targetNodeType | String | null | Filter by target type |
edgeType | String | null | Filter by edge type (e.g., "sent_by", "participant") |
limit | Int | 100 | Maximum to return |
offset | Int | 0 | Offset for pagination |
createEdge#
Create a relationship between two entities. Requires events:write scope.
mutation {
createEdge(
sourceNodeId: "event-uuid"
sourceNodeType: "event"
targetNodeId: "user-uuid"
targetNodeType: "user"
edgeType: "relates_to"
extraData: { note: "custom data" }
) {
id
edgeType
}
}| Parameter | Type | Default | Description |
|---|---|---|---|
sourceNodeId | UUID! | required | Source entity ID |
sourceNodeType | String! | required | Source type: "event", "user", "identity" |
targetNodeId | UUID! | required | Target entity ID |
targetNodeType | String! | required | Target type: "event", "user", "identity", "file" |
edgeType | String! | required | Relationship type |
extraData | JSON | null | Optional metadata on the edge |
Link Codes#
generateLinkCode#
Generate a code that links two user accounts across platforms.
mutation {
generateLinkCode(
identityId: "identity-uuid"
maxUses: 1
expiryMinutes: 15
sendToUser: true
) {
id
payload
}
}| Parameter | Type | Default | Description |
|---|---|---|---|
identityId | UUID! | required | Identity generating the code |
maxUses | Int | 1 | Maximum redemptions (0 = unlimited) |
expiryMinutes | Int | 15 | Minutes until expiration |
sendToUser | Boolean | false | Send the code to the user via their platform |
redeemLinkCode#
Redeem a link code to merge two user accounts.
mutation {
redeemLinkCode(code: "1234-5678-9012-3456", identityId: "identity-uuid") {
id
payload
}
}Logs#
logs#
Query logs for your application. Requires logs:read scope.
query {
logs(eventId: "event-uuid", logLevel: "error", limit: 50) {
id
logLevel
logType
message
payload
createdAt
}
}| Parameter | Type | Default | Description |
|---|---|---|---|
eventId | UUID | null | Filter by associated event |
eventIds | [UUID!] | null | Filter by multiple events |
logLevel | String | null | Filter by level: "debug", "info", "warning", "error" |
logType | String | null | Filter by type (e.g., "execution", "webhook_error") |
limit | Int | 100 | Maximum to return |
offset | Int | 0 | Offset for pagination |
createLog#
Create a log entry. Requires logs:write scope.
mutation {
createLog(
message: "Processing completed"
logLevel: "info"
logType: "execution"
payload: { duration_ms: 42 }
eventId: "event-uuid"
) {
id
createdAt
}
}| Parameter | Type | Default | Description |
|---|---|---|---|
message | String! | required | Log message |
logLevel | String | "info" | Log level |
logType | String | "execution" | Log category |
payload | JSON | null | Structured data |
eventId | UUID | null | Associated event ID |
Stats#
stats#
Unified time-series stats query for events, webhooks, API keys, and more.
query {
stats(
sourceType: EVENT_TYPE
startDate: "2024-01-01T00:00:00Z"
endDate: "2024-02-01T00:00:00Z"
granularity: DAILY
entityId: "message.inbound"
) {
timestamp
count
entityId
}
}| Parameter | Type | Default | Description |
|---|---|---|---|
sourceType | StatsSourceType! | required | Data source (see enum below) |
startDate | DateTime! | required | Start of date range |
endDate | DateTime! | required | End of date range |
granularity | StatsGranularity! | required | Time bucket size (see enum below) |
entityId | String | null | Filter by entity; when null, groups by entity |
userId | String | null | Filter events by user (EVENT source only) |
eventTypes | [String!] | null | Filter by event types (EVENT source only) |
conversationId | String | null | Filter by conversation (EVENT source only) |
getStats#
Get aggregate counts for the current application.
query {
getStats(timeframe: LAST_30_DAYS) {
identitiesCount
messagesCount
usersCount
}
}| Parameter | Type | Description |
|---|---|---|
timeframe | Timeframe! | ALL_TIME or LAST_30_DAYS |
Enums#
PlatformName#
enum PlatformName {
WHATSAPP
INSTAGRAM
FACEBOOK
TELEGRAM
SLACK
DISCORD
TWILIO
TWILIO_VOICE
EMAIL
IMESSAGE
CUSTOM
UNKNOWN
}StatsSourceType#
enum StatsSourceType {
EVENT
EVENT_TYPE
API_KEY
PLATFORM_CONNECTION
WEBHOOK
USER
APPLICATION
}StatsGranularity#
enum StatsGranularity {
ONE_MINUTE
FIVE_MINUTE
FIFTEEN_MINUTE
HOURLY
SIX_HOURLY
DAILY
WEEKLY
}Timeframe#
enum Timeframe {
LAST_30_DAYS
ALL_TIME
}Pagination#
Outeract uses cursor-based pagination following the Relay specification.
Forward Pagination#
query {
events(first: 20, after: "cursor_xyz") {
edges {
node { id }
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}Backward Pagination#
query {
events(last: 20, before: "cursor_xyz") {
edges {
node { id }
cursor
}
pageInfo {
hasPreviousPage
startCursor
}
}
}Idempotency#
sendMessage and createEvent accept an optional idempotencyKey argument so
retries are safe. A client that times out and retries a mutation with the same
key gets the original event back instead of sending a second message or
creating a duplicate event.
mutation {
sendMessage(
message: "Your order has shipped"
recipientUserId: "user-uuid"
idempotencyKey: "b3c5a7de-8f21-4f6e-9c44-1a2b3c4d5e6f"
) {
id
}
}Semantics:
- Retrying with the same key in the same application returns the original event, with no second platform send and no duplicate webhook delivery to subscribers.
- Keys must be non-blank and at most 255 characters.
- Keys are scoped per application: the same key in two different applications creates two independent events.
- Keys share one namespace across
sendMessageandcreateEvent. Reusing a key with a different mutation or a different event type is rejected with an error rather than replaying an unrelated event. - Keys are unique per application indefinitely; they do not expire. Reusing an old key returns the old event, so generate a fresh UUID for each logical operation rather than deriving keys from request content.
- The key is stored in the event payload as
idempotency_key, which is a reserved field:createEventrejects a payload carrying one unless it exactly matches theidempotencyKeyargument (a payload-only key with the argument omitted is also rejected), andupdateEventcannot inject, change, or remove it. - A failed platform send does not consume the key. The failed attempt is recorded as an event without the key, and retrying with the same key performs a new send.
- Uniqueness is enforced by a database constraint, so concurrent retries with the same key still result in a single event.
Note: if the server crashes between the platform accepting a message and the event committing, a retry can re-send. This window is small; the key still prevents the common failure mode of client timeouts and network retries.
Error Handling#
GraphQL errors are returned in the errors array:
{
"data": null,
"errors": [
{
"message": "Platform connection not found",
"locations": [{"line": 2, "column": 3}],
"path": ["sendMessage"]
}
]
}Batching#
Combine multiple queries in one request:
query BatchQuery {
events(first: 10) {
edges { node { id } }
}
users(first: 10) {
edges { node { id } }
}
}