> ## Documentation Index
> Fetch the complete documentation index at: https://docs.politicalcomms.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Two-Way Conversations

> Answer inbound texts programmatically: receive message.replied, reply inside the same conversation from the same number, and recover missed inbounds with the conversations list.

Every inbound text arrives on the `message.replied` webhook. The Conversations endpoints let you answer it with your own content, from the same number, inside the same thread, on the same project. The API never starts a conversation; a project send does that.

## The round trip

<Steps>
  <Step title="Receive the inbound">
    Your webhook endpoint gets a `message.replied` event. Its payload carries `conversation_id`, `message_id` (the inbound message), `from` (the contact), `to` (your number), `text`, `is_opt_out`, and `is_opt_in`. Acknowledge with a `2xx` quickly and hand the work off.
  </Step>

  <Step title="Reply inside the conversation">
    ```bash theme={null}
    curl https://api.politicalcomms.com/v1/conversations/{conversation_id}/messages \
      -X POST \
      -H "X-API-Key: $POLITICAL_COMMS_API_KEY" \
      -H "Idempotency-Key: $(uuidgen)" \
      -H "Content-Type: application/json" \
      -d '{ "text": "Thanks for reaching out. Polls are open until 7pm at Lincoln Elementary." }'
    ```

    The reply is accepted with `202` and queued at high priority. The response carries the new `message_id`, the sending number (`from`), and the text as it will go out.
  </Step>

  <Step title="Watch for the outcome">
    `message.sent`, `message.delivered`, or `message.failed` fires for the returned `message_id`, each carrying the same `conversation_id`. There is no separate "reply" event.
  </Step>
</Steps>

## What a reply can and cannot do

* **SMS only, up to 1,600 characters.** Media is not accepted; the body is `{ "text": "..." }` and unknown properties are rejected with `400`. Text is normalized for GSM encoding before sending, so a curly quote pasted from a document does not push the message into UCS-2.
* **Same number, always.** The sending number is a property of the conversation, not of the request. If that number was released the reply is refused with `409 PHONE_NUMBER_UNAVAILABLE`.
* **Opt-outs are enforced before anything is charged.** A contact who replied STOP gets `409 CONTACT_OPTED_OUT`. A thread with no sending number (`409 CONVERSATION_NOT_SENDABLE`) or a deleted project (`409 PROJECT_DELETED`) is refused the same way. `402 INSUFFICIENT_BALANCE` follows the same rule: nothing was charged.
* **Not subject to quiet hours.** A reply is a follow-up to a contact who just texted in, and goes out immediately.
* **Billed per segment** at the number's outbound rate, on the organization that owns the project.

## Retrying safely

Send an `Idempotency-Key` (a UUID) on every reply you might retry. Within 24 hours a retry with the same key returns the stored response with `X-Idempotent-Replayed: true` instead of sending a second text.

| You got                   | It means                                                                                           | Do this                                                                                      |
| ------------------------- | -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `503 SEND_ENQUEUE_FAILED` | The reply could not be handed to the delivery queue. Nothing was sent and the charge was reversed. | Retry the same call.                                                                         |
| Any other `5xx`           | Unknown whether the reply was queued.                                                              | Read `GET /conversations/{conversation_id}/messages` and look for your text before retrying. |
| `4xx`                     | The request itself is wrong or the thread cannot take a reply.                                     | Fix the cause; do not retry the same payload.                                                |

## Recovering missed inbounds

If your webhook endpoint was down past our four delivery attempts, list the threads that have new inbound messages:

```bash theme={null}
curl "https://api.politicalcomms.com/v1/conversations?updated_since=2026-09-07T00:00:00Z&limit=200" \
  -H "X-API-Key: $POLITICAL_COMMS_API_KEY"
```

`GET /conversations` returns only conversations with at least one inbound message, newest inbound first, across every organization the key can access. Filter on `last_inbound_at` with `updated_since` (default: the last 7 days; more than 90 days back is a `400`), narrow with `project_id`, and page until `next_cursor` is null. Then read each thread with `GET /conversations/{conversation_id}/messages` (newest first, both directions, never marks the thread read in the dashboard).

Treat this as recovery, not as a substitute for the webhook: poll at most once a minute, and advance `updated_since` to the newest `last_inbound_at` you have processed.

## Tenant scope

A conversation that does not exist and one that belongs to an organization outside your key's scope both answer `404 CONVERSATION_NOT_FOUND`. The API never confirms the existence of another tenant's thread.

## Reference

* [Conversations endpoints](/api-reference/openapi.json)
* [`message.replied` payload](/api-reference/webhooks/events#message-replied)
* [Error codes](/api-reference/errors)
