Skip to main content
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

1

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.
2

Reply inside the conversation

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.
3

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.

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.

Recovering missed inbounds

If your webhook endpoint was down past our four delivery attempts, list the threads that have new inbound messages:
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