# Send texts and handle replies

> End-to-end: send a text from your business number, receive the reply by webhook, answer in the same thread, and close it out.

Source: https://dev.ody.co/guides/send-your-first-text

This guide builds the most common integration: your system texts a customer (an appointment reminder, say), the customer replies, and your code reads and answers the reply. The whole exchange happens in one Ody thread that your team can see in the inbox.

**You'll need:** an API key with `messages:send`, `conversations:read`, `conversations:write` and `webhooks:manage`; a public HTTPS URL for webhooks (use a tunnel such as ngrok or Cloudflare Tunnel in development); and, for live sends, a [10DLC-registered](https://dev.ody.co/api/messaging) workspace. Test keys skip that last requirement.

## 1. Subscribe to replies

Register a webhook endpoint for inbound messages once, and store the secret:

```bash
curl https://api.ody.co/v1/api/webhooks \
  -H "Authorization: Bearer $ODY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://your-app.example.com/ody/webhooks","events":["message.received","message.failed"]}'
```

Response (excerpt):

```json
{ "webhook": { "id": "3a8f…", "secret": "whsec_…" } }
```

Save the `secret` as `ODY_WEBHOOK_SECRET`.

## 2. Send the text

cURL:

```bash
curl https://api.ody.co/v1/api/messages \
  -H "Authorization: Bearer $ODY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: appt-7731-reminder" \
  -d '{"to":"+15125550123","body":"Hi Ada, reminder: cleaning tomorrow at 10am. Reply C to confirm or R to reschedule."}'
```
TypeScript:

```ts
const { conversationId } = await ody.messages.send({
  to: "+15125550123",
  body: "Hi Ada, reminder: cleaning tomorrow at 10am. Reply C to confirm or R to reschedule.",
  idempotencyKey: "appt-7731-reminder",
});
await db.appointments.update(7731, { odyConversationId: conversationId });
```
Python:

```python
res = client.messages.send(
    to="+15125550123",
    body="Hi Ada, reminder: cleaning tomorrow at 10am. Reply C to confirm or R to reschedule.",
    idempotency_key="appt-7731-reminder",
)
db.appointments.update(7731, ody_conversation_id=res["conversationId"])
```

Store the `conversationId` next to your record. That's how you'll match the reply. The [`Idempotency-Key`](https://dev.ody.co/api/idempotency) means a retried job never sends the reminder twice.

## 3. Receive the reply

When Ada answers, Ody POSTs a signed `message.received` event. The payload carries **IDs**, so fetch the thread to read the text:

server.ts:

```ts
import express from "express";
import { Ody, verifyWebhookSignature } from "@ody/sdk";

const ody = new Ody(process.env.ODY_API_KEY!);
const app = express();

app.post("/ody/webhooks", express.raw({ type: "application/json" }), async (req, res) => {
  if (!verifyWebhookSignature(process.env.ODY_WEBHOOK_SECRET!, req.headers, req.body)) {
    return res.status(401).end();
  }
  res.status(200).end(); // acknowledge within 10s, then work

  const event = JSON.parse(req.body.toString("utf8"));
  if (event.type !== "message.received") return;
  if (await alreadyProcessed(event.id)) return; // deliveries can repeat

  const { conversationId, activityId } = event.data;
  const { activities } = await ody.conversations.get(conversationId);
  const reply = activities.find((a) => a.id === activityId);
  await handleReply(conversationId, reply?.body ?? "");
});

app.listen(3000);
```

## 4. Answer in the same thread

Reply with `conversationId`. It goes out from the same number, so the customer sees one continuous conversation:

```ts
async function handleReply(conversationId: string, text: string) {
  const appt = await db.appointments.findByConversation(conversationId);
  if (!appt) return;

  const answer = text.trim().toUpperCase();
  if (answer === "C") {
    await db.appointments.confirm(appt.id);
    await ody.messages.send({ conversationId, body: "Confirmed, see you tomorrow at 10am!", idempotencyKey: `appt-${appt.id}-confirmed` });
    await ody.conversations.setStatus(conversationId, "done"); // clears it from the team's open inbox
  } else if (answer === "R") {
    await ody.messages.send({ conversationId, body: "No problem. A team member will text you shortly with new times.", idempotencyKey: `appt-${appt.id}-resched` });
    // leave it open, a human takes over in the Ody inbox
  }
}
```

Anything you don't handle stays **open** in the shared inbox, so a person picks it up. You don't need to code every edge case.

## 5. Handle failures

Subscribe to `message.failed` (done in step 1) to catch unreachable numbers and opt-outs:

```ts
if (event.type === "message.failed") {
  await db.contacts.flagUnreachable(event.data.conversationId, event.data.deliveryStatus);
}
```

> **STOP and opt-outs:**
Carriers handle `STOP`, `HELP` and `START` keywords automatically. After a customer texts `STOP`, sends to them fail with `message.failed`. Don't retry them.

## Going further

- No public URL? Poll [`GET /conversations?status=open`](https://dev.ody.co/api/conversations#list-conversations) every 15–30 seconds instead of using webhooks.
- Let an LLM write the replies: [Run a live SMS agent](https://dev.ody.co/guides/live-sms-agent).
- Send images with `mediaUrls` (MMS): see [Send a message](https://dev.ody.co/api/messages#send-a-message).
