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.
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 workspace. Test keys skip that last requirement.
#1. Subscribe to replies
Register a webhook endpoint for inbound messages once, and store the secret:
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"]}'{ "webhook": { "id": "3a8f…", "secret": "whsec_…" } }Save the secret as ODY_WEBHOOK_SECRET.
#2. Send the text
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."}'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 });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 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:
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:
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:
if (event.type === "message.failed") {
await db.contacts.flagUnreachable(event.data.conversationId, event.data.deliveryStatus);
}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=openevery 15–30 seconds instead of using webhooks. - Let an LLM write the replies: Run a live SMS agent.
- Send images with
mediaUrls(MMS): see Send a message.