---
title: "Open Conversation"
method: POST
path: "/conversations"
tags: ["Conversations"]
---

# Open Conversation

`POST /conversations`

## Request

- Base URL: `https://api.verdun.ai` (the document declares no server; this is the origin it was published from)
- URL: `https://api.verdun.ai/conversations`
- Auth: HTTP bearer

Find-or-create the thread for a named contact.

200, NOT 201: it is idempotent find-or-create, so a 201 would be a lie on
the common path. Tenancy comes from the RESOLVED CONTACT'S SITE, never from
the request body, and an out-of-scope contact answers 404 rather than 403.

## Request body

- OpenConversationRequest: Find-or-create the thread for a named contact. NO `event_id`. The brief had one, but a CONVERSATION stores no event — the event link lives on MESSAGES (`sms_messages.event_metadata_id`), so accepting it here would publish a no-op field in the contract. The open-from-review flow is this route, then `POST /conversations/{id}/messages` with `event_id`. TENANCY IS NOT TAKEN FROM THIS BODY. The contact is resolved, its site is checked against the caller's scope, and the thread is opened with THAT site's parent and site (round-0 review R0.9).
  - `site_contact_id` string, required: The site contact to open a thread with. Its site must be in your scope.
  - `number` 'primary' | 'afterhours': Which of the contact's two numbers on file identifies the thread. Mirrors NotifyOwnerRequest.number; the server only ever texts numbers from the contact row.

## Response `200`

Successful Response

- ConversationSummary
  - `id` string, required: The thread id.
  - `verdun_number` string, required: Our line, E.164.
  - `customer_number` string, required: The customer's number, E.164.
  - `contact_name` string, nullable: Snapshot of the contact at resolution time. Null when the number could not be pinned to one person.
  - `site_contact_id` string, nullable: The resolved contact, if exactly one matched. Null makes the thread read-only (consent cannot be checked without it).
  - `parent_id` string, nullable: Client. Null only for a thread orphaned by a hard client delete.
  - `parent_name` string, nullable: Client name, for the list row.
  - `site_id` string, nullable: Site. **Null means a PARENT-LEVEL thread** — the number belongs to contacts at several sites of one client — not a missing field. It is visible only to a scope holding the whole client.
  - `site_name` string, nullable: Site name, for the list row.
  - `account_id` string, nullable: The SITE'S account, resolved at read time. Null for a client-wide thread and for a site directly under the client.
  - `account_name` string, nullable: Account name, for the list row.
  - `status` 'open' | 'archived', required: An inbound or an outbound reopens an archived thread.
  - `unread_count` integer, required: Conversation-level and SHARED across operators in v1, not per viewer.
  - `last_message_at` string, date-time, required: Last activity. The list's sort key, never null.
  - `last_inbound_at` string, date-time, nullable: Last message from the customer.
  - `last_outbound_at` string, date-time, nullable: Last message from us.
  - `last_message_preview` string, nullable: First 160 characters of the newest body. Staff-only; it never reaches an activity-log entry.
  - `last_message_status` string, nullable: Delivery state of the LAST message, provider vocabulary (queued / sent / delivered / failed / undelivered; `received` on an inbound). Null with no messages. For the list row's badge.
  - `last_message_operator_id` string, nullable: Who sent the last message. Null on an inbound.
  - `last_message_operator_name` string, nullable: Their name, snapshotted when the message was sent. Null on an inbound.
  - `last_message_direction` 'outbound' | 'inbound', nullable: Direction of the previewed message.
  - `claimed_by_operator_id` string, nullable: Who is answering this thread.
  - `claimed_by_name` string, nullable: Their name, for the list row.
  - `claimed_at` string, date-time, nullable: When they claimed it.
  - `last_event` LastEventSummary: The newest incident this thread carries a message about. NO `closed_at` — `events_metadata` HAS NO SUCH COLUMN, and the two things that could stand in for one mean different things: `operator_evaluated` is "a human triaged or dismissed it", `expired_at` is "it aged out unreviewed". Both are exposed and the UI derives "open / closed" rather than the API inventing a single field that erases the distinction.
    - `id` string, required: The event id.
    - `occurred_at` string, date-time, nullable: Ingestor-reported time of the motion. Display only.
    - `created_at` string, date-time, nullable: When we recorded the event — the single DB clock.
    - `operator_evaluated` boolean, required: True once a human triaged or dismissed it.
    - `expired_at` string, date-time, nullable: Set when it aged out unreviewed. NOT the same as a dismissal.
  - `created_at` string, date-time, nullable: When the thread was opened.
  - `updated_at` string, date-time, nullable: Last write to the thread row.

## Other responses

- `422`: Validation Error

---

[API](https://skmtc.dev/verdun/apis/verdun-api.md) - [All operations](https://skmtc.dev/verdun/apis/verdun-api/llms.txt) - [OpenAPI document](https://skmtc.dev/verdun/apis/verdun-api/revisions/00252d8227c1?raw)
