Skip to content
Select themeSelect language

Third-party conversation ingest

POST

Third-party conversation INGEST (ADR 0067 Addendum A). A source SupaCloud has no native adapter for - a marketplace connector node polling on a scheduled workflow, or pushing on its own webhook - POSTs ONE message per call. Authenticated by the X-SupaCloud-Signature HMAC-SHA256 header over {X-SupaCloud-Timestamp}.{raw_body}, keyed by the trigger’s per-trigger signing secret, with a stale-timestamp replay guard (10 min). The trigger’s config.resource_id PINS the token to exactly one Resource and config.channel to one channel label - both are components of the thread identity, so a token can only ever reach its own source’s threads. This endpoint WRITES conversation rows and nothing else: it enqueues no backlog item and starts no run. Path: POST /api/workflows/triggers/conversation-ingest/{token}.

Media typeapplication/json

The ADR 0067 Addendum A conversation INGEST request body — ONE message per delivery. Unlike every other webhook body in this module this is a CLOSED contract SupaCloud owns, not an open record a third party owns, so it is modelled as a real struct with named fields and unknown fields are rejected (a misspelled external_message_id would otherwise silently disable dedupe).

Documentation-only mirror: the executable contract is the hand-written pure parser services::workflows::triggers::conversation_ingest_request, whose accepted-key list this struct is drift-gated against by a test below.

Note the fields that are NOT here: channel and resource_id are pinned on the trigger config (a caller must not be able to choose which source’s threads it writes), workspace_id is derived from the token, and author_kind is derived from direction (so a third party cannot mint a message attributed to SupaCloud itself).

object
author_name

OPTIONAL. The counterpart’s display name; drives the contact upsert. At most 200 characters; blank is treated as absent.

string | null
body
required

REQUIRED. The message text, stored verbatim; at most 32768 characters and not blank.

string
conversation_external_id
required

REQUIRED. The source’s id for the thread; at most 200 characters. With the token’s pinned resource + channel this is the conversation identity, so redelivering the same id lands on the same thread.

string
direction

OPTIONAL, default inbound. inbound = from the contact, outbound = an answer in the source system.

string | null
external_message_id

OPTIONAL but strongly recommended. The source’s message id — THE idempotency key. A redelivery carrying an id already stored is a no-op that leaves no gap in the thread sequence; a delivery without one is appended unconditionally, so a retry duplicates the turn.

string | null
occurred_at

OPTIONAL. Source time as RFC3339. Omitted stamps receipt time. May not be more than 5 minutes in the future (a future stamp would pin the thread to the top of the board permanently).

string | null
Examplegenerated
{
"author_name": "example",
"body": "example",
"conversation_external_id": "example",
"direction": "example",
"external_message_id": "example",
"occurred_at": "example"
}

Message stored on its conversation thread. A redelivery carrying the same external_message_id returns this same body and stores nothing new.

Media typeapplication/json

The ack body returned by the ADR 0067 Addendum A conversation INGEST facade: { "status": "stored", "conversation_id": "<uuid>" }.

Deliberately narrower than both sibling acks. There is no skipped status, because every authenticated, contract-valid ingest delivery is stored — a receiver that filters (WebhookAck) or gates on an autonomy mode (ConversationWebhookAck) has outcomes this one does not. And the id it carries is a conversation_id, never a backlog_item_id: ingest WRITES and never DISPATCHES, so no backlog item exists to name. A replay of the same external_message_id returns this same body — the store deduped it.

object
conversation_id
required

The conversation the message landed on — newly created or pre-existing.

string format: uuid
status
required

Always stored on a 200 (failures are 400/401/404, never a 200 ack).

string
Examplegenerated
{
"conversation_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0",
"status": "example"
}

Malformed body (non-JSON / rejected content-type).

Media typeapplication/json

The canonical JSON body of every error response — the single source of truth the frontend binds to. Every AppError serializes as this exact shape, and the generated OpenAPI component ApiErrorBody (with its ErrorCode enum) is what the frontend error schema is generated from, so there is no hand-written error schema on either end.

object
code
required

Machine-readable, stable error code.

string
Allowed values: not_found unauthorized forbidden license_required license_expired bad_request unprocessable precondition_failed conflict method_not_allowed rate_limited too_many_requests quota_exceeded database_error docker_error vault_error internal_error
details
One of:
null
error
required

Human-readable message (the server’s English text; the client may localize by code).

string
Example
{
"code": "not_found"
}

Invalid or missing HMAC signature.

Media typeapplication/json

The canonical JSON body of every error response — the single source of truth the frontend binds to. Every AppError serializes as this exact shape, and the generated OpenAPI component ApiErrorBody (with its ErrorCode enum) is what the frontend error schema is generated from, so there is no hand-written error schema on either end.

object
code
required

Machine-readable, stable error code.

string
Allowed values: not_found unauthorized forbidden license_required license_expired bad_request unprocessable precondition_failed conflict method_not_allowed rate_limited too_many_requests quota_exceeded database_error docker_error vault_error internal_error
details
One of:
null
error
required

Human-readable message (the server’s English text; the client may localize by code).

string
Example
{
"code": "not_found"
}

Unknown trigger token.

Media typeapplication/json

The canonical JSON body of every error response — the single source of truth the frontend binds to. Every AppError serializes as this exact shape, and the generated OpenAPI component ApiErrorBody (with its ErrorCode enum) is what the frontend error schema is generated from, so there is no hand-written error schema on either end.

object
code
required

Machine-readable, stable error code.

string
Allowed values: not_found unauthorized forbidden license_required license_expired bad_request unprocessable precondition_failed conflict method_not_allowed rate_limited too_many_requests quota_exceeded database_error docker_error vault_error internal_error
details
One of:
null
error
required

Human-readable message (the server’s English text; the client may localize by code).

string
Example
{
"code": "not_found"
}