Third-party conversation ingest
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}.
Authorizations
Section titled “Authorizations”Request Bodyrequired
Section titled “Request Bodyrequired”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
OPTIONAL. The counterpart’s display name; drives the contact upsert. At most 200 characters; blank is treated as absent.
REQUIRED. The message text, stored verbatim; at most 32768 characters and not blank.
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.
OPTIONAL, default inbound. inbound = from the contact, outbound = an
answer in the source system.
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.
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).
Examplegenerated
{ "author_name": "example", "body": "example", "conversation_external_id": "example", "direction": "example", "external_message_id": "example", "occurred_at": "example"}Responses
Section titled “Responses”Message stored on its conversation thread. A redelivery carrying the same external_message_id returns this same body and stores nothing new.
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
The conversation the message landed on — newly created or pre-existing.
Always stored on a 200 (failures are 400/401/404, never a 200 ack).
Examplegenerated
{ "conversation_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "status": "example"}Malformed body (non-JSON / rejected content-type).
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
Machine-readable, stable error code.
Present only on a quota-exceeded 403 — the inline upgrade-CTA payload.
object
The entitlement feature key that was hit, e.g. apps.max_count.
The plan’s limit for this key.
Where to send the user to upgrade.
Current usage (count or bytes, per the key).
Human-readable message (the server’s English text; the client may localize
by code).
Example
{ "code": "not_found"}Invalid or missing HMAC signature.
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
Machine-readable, stable error code.
Present only on a quota-exceeded 403 — the inline upgrade-CTA payload.
object
The entitlement feature key that was hit, e.g. apps.max_count.
The plan’s limit for this key.
Where to send the user to upgrade.
Current usage (count or bytes, per the key).
Human-readable message (the server’s English text; the client may localize
by code).
Example
{ "code": "not_found"}Unknown trigger token.
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
Machine-readable, stable error code.
Present only on a quota-exceeded 403 — the inline upgrade-CTA payload.
object
The entitlement feature key that was hit, e.g. apps.max_count.
The plan’s limit for this key.
Where to send the user to upgrade.
Current usage (count or bytes, per the key).
Human-readable message (the server’s English text; the client may localize
by code).
Example
{ "code": "not_found"}