A customer gets your appointment reminder and texts back "Confirmed" or "Can we reschedule?" Someone texts your support number asking about their order. A user responds to your verification flow with a question. Something needs to receive each of these, understand the context, and respond. That's the gap between broadcasting SMS and actually having a conversation.
Two scenarios matter here. The customer texts you first: a question, a support request, a reply to a notification from a different channel. Or your application sends the opening message: an appointment reminder, a verification code, an order update. Either way, the customer can reply. Both flows use the same webhook. What changes is whether your application fires the first shot.
This post builds that on AWS serverless using the Sinch Conversation API. If you've read the previous posts in this series, you'll recognize the SSM credential pattern. This post stands on its own if you're coming in fresh.
The architecture
DLQs not shown. Both queues have a dead-letter queue that captures messages that fail after three attempts.
SenderFunction: reads from the outbound queue, sends an SMS via the Conversation API, calls storeConversation with the messageId. All outbound messages flow through here, whether they come from your application, a human agent, or the processor.
WebhookFunction: validates the HMAC signature, extracts the sender, text, and conversationId, puts them on the inbound queue, returns 200. Fast, never fails because of slow downstream logic.
ProcessorFunction: reads from the inbound queue, calls handleInboundMessage to decide what to reply, puts the reply on the outbound queue. Has 60 seconds to work. No Sinch credentials needed here. If it fails, SQS retries automatically.
This means your processor never touches the Sinch API directly. A human agent, a support tool, or any other system can send messages through the same outbound queue.
The webhook function
Validates the signature, rejects stale requests, and enqueues. Nothing else.
The Function URL uses AuthType: NONE because Sinch needs to reach it without an AWS auth header. The HMAC signature check covers two things: the payload hasn't been tampered with, and the request was sent recently. The timestamp header is signed alongside the body, so an attacker can't replay a captured request after the 5-minute window closes. Without these checks, anyone who knows the URL can send arbitrary payloads.
import { createHmac, timingSafeEqual } from "crypto";
import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm";
import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs";
const ssm = new SSMClient({});
const sqs = new SQSClient({});
const SINCH_WEBHOOK_SECRET_PARAM = process.env.SINCH_WEBHOOK_SECRET_PARAM!;
const QUEUE_URL = process.env.QUEUE_URL!;
const TIMESTAMP_TOLERANCE_SECONDS = 300; // 5 minutes
let webhookSecret: string | null = null;
async function getWebhookSecret(): Promise<string> {
if (webhookSecret) return webhookSecret;
const res = await ssm.send(
new GetParameterCommand({ Name: SINCH_WEBHOOK_SECRET_PARAM, WithDecryption: true })
);
webhookSecret = res.Parameter!.Value!;
return webhookSecret;
}
function validateTimestamp(timestamp: string): boolean {
const ts = parseInt(timestamp, 10);
if (isNaN(ts)) return false;
return Math.abs(Math.floor(Date.now() / 1000) - ts) <= TIMESTAMP_TOLERANCE_SECONDS;
}
function validateSignature(
body: string, signature: string, nonce: string, timestamp: string, secret: string
): boolean {
const signedData = body + "." + nonce + "." + timestamp;
const expected = createHmac("sha256", secret).update(signedData).digest("base64");
return signature.length === expected.length &&
timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
export const handler = async (event: { headers: Record<string, string>; body: string }) => {
const body = event.body;
const signature = event.headers["x-sinch-webhook-signature"];
const nonce = event.headers["x-sinch-webhook-signature-nonce"];
const timestamp = event.headers["x-sinch-webhook-signature-timestamp"];
if (!signature || !nonce || !timestamp) {
return { statusCode: 401, body: "Missing signature headers" };
}
if (!validateTimestamp(timestamp)) {
console.error("Webhook timestamp outside acceptable window");
return { statusCode: 401, body: "Request expired" };
}
const secret = await getWebhookSecret();
if (!validateSignature(body, signature, nonce, timestamp, secret)) {
console.error("Invalid webhook signature");
return { statusCode: 401, body: "Invalid signature" };
}
const payload = JSON.parse(body);
if (payload.message && payload.message.direction === "TO_APP") {
const rawFrom = payload.message.channel_identity?.identity;
const from = rawFrom && !rawFrom.startsWith('+') ? `+${rawFrom}` : rawFrom;
const text = payload.message.contact_message?.text_message?.text;
if (from && text) {
await sqs.send(new SendMessageCommand({
QueueUrl: QUEUE_URL,
MessageBody: JSON.stringify({
from,
text,
messageId: payload.message.id,
conversationId: payload.message.conversation_id,
}),
}));
}
}
return { statusCode: 200, body: "OK" };
};
The processor function
Reads from the inbound queue, runs your logic, and enqueues the reply. This is where your application lives.
import { SQSEvent } from "aws-lambda";
import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs";
const sqs = new SQSClient({});
const OUTBOUND_QUEUE_URL = process.env.OUTBOUND_QUEUE_URL!;
async function getConversation(from: string): Promise<Record<string, string> | null> {
// Add your retrieval logic here, e.g. read from DynamoDB by 'from' (phone number)
// to find what your application last sent to this customer.
console.log("getConversation", { from });
return null;
}
// Add your application logic here. The function signature and SQS wiring are done.
async function handleInboundMessage(from: string, text: string, conversationId: string): Promise<string> {
const context = await getConversation(from);
// context contains what your application originally sent, if you stored it in storeConversation.
// For customer-initiated messages, context will be null. Treat them as fresh inquiries.
return `Thanks for your message! You said: "${text}"`;
}
export const handler = async (event: SQSEvent) => {
for (const record of event.Records) {
const { from, text, conversationId } = JSON.parse(record.body);
try {
const reply = await handleInboundMessage(from, text, conversationId);
await sqs.send(new SendMessageCommand({
QueueUrl: OUTBOUND_QUEUE_URL,
MessageBody: JSON.stringify({ to: from, message: reply }),
}));
} catch (err) {
console.error(`Failed to process message from ${from}:`, err);
throw err; // Let SQS retry
}
}
};
handleInboundMessage is where your logic goes. It calls getConversation first: a stub that looks up by the sender's phone number. If your application sent the first message and stored it in storeConversation, that's how you retrieve what was originally sent. For customer-initiated messages, getConversation returns null and you handle it as a fresh inquiry.
The sender function
SenderFunction is triggered by the outbound queue. Any message put on that queue gets sent to the recipient via Sinch. The processor puts replies there. Your application puts company-initiated messages there. A human agent's tool puts messages there. One function handles all of it.
Sinch credentials and auth logic live in exactly one place. Anything that needs to send a message puts { to, message } on the queue. No knowledge of the Sinch API required.
export const handler = async (event: SQSEvent): Promise<void> => {
for (const record of event.Records) {
const { to, message } = JSON.parse(record.body);
// ... E164 validation, auth, Sinch API call ...
const data = await response.json() as { message_id: string; accepted_time: string };
await storeConversation(data.message_id, to, message);
}
};
The messageId comes back from Sinch after every send. storeConversation is a stub: add your DynamoDB write there, keyed by the recipient's phone number, so you can look up what was sent when they reply.
To kick off a conversation from your application, put a message on the outbound queue:
aws sqs send-message \
--queue-url YOUR_OUTBOUND_QUEUE_URL \
--message-body '{"to": "+15559876543", "message": "Your appointment is tomorrow at 2pm. Reply to confirm."}'
YOUR_OUTBOUND_QUEUE_URL is in the deploy outputs as OutboundQueueUrl.
What an inbound message looks like
The webhook receives this from Sinch:
{
"message": {
"id": "01EXAMPLE8235TD19N21XQTH12B",
"direction": "TO_APP",
"contact_message": {
"text_message": {
"text": "What's my order status?"
}
},
"channel_identity": {
"channel": "SMS",
"identity": "+15559876543"
},
"conversation_id": "01EXAMPLECONV172WMDB8008EFT"
}
}
The webhook extracts from, text, and conversationId and puts them on the queue. The processor picks them up.
Deploying
If you followed the sending SMS post, your Sinch app is already configured. Skip to the SSM parameter setup below.
If you're starting here, configure the following in the Sinch Build Dashboard first:
- Get access to the Conversation API. Click Conversation API in the left menu, accept the terms, and click Get Access.
- Create a Conversation API app. Go to Conversation API > Apps and click Create app. Record the app ID.
-
Switch the app to Conversation mode. New apps default to Dispatch mode. Open your app, go to Settings, and change the processing mode to Conversation. This enables the
conversation_idfield in webhook payloads, which Sinch uses to track threads on their side. TheconversationIdis available in your processor if you want to use it as a thread key. This example keys on phone number instead, since that's available in both directions: Sinch returns it on inbound webhooks, and you already know the recipient when sending outbound. If you expect one active thread per number, phone number is the simpler choice. - Enable the SMS channel on your app. Open the app, find SMS in the channel list, click Set up channel, and connect your service plan.
- Find your sender number. Go to SMS > Numbers. The assigned number is your SMS sender.
- Note your project ID. Click the project name in the top bar and go to Project Settings.
- Create an access key. Go to Settings > Access Keys. Record the key ID and secret. The secret is only shown once.
You'll need three things in SSM Parameter Store before deploying. If you followed the sending SMS post, your access key and secret are already there. The webhook secret is new to this post.
# Skip these two if you already have them from the sending SMS post
aws ssm put-parameter --name /sinch/access-key --value "YOUR_ACCESS_KEY" --type SecureString
aws ssm put-parameter --name /sinch/access-key-secret --value "YOUR_ACCESS_KEY_SECRET" --type SecureString
# New: a dedicated secret for this webhook's HMAC signature validation.
# Use a different value from any secret you set for the delivery receipts webhook.
aws ssm put-parameter \
--name /sinch/conversation-webhook-secret \
--value "your-webhook-secret-here" \
--type SecureString
Then build and deploy from your preferred language directory:
cd typescript # or: cd python
sam build
sam deploy --guided
sam deploy --guided will prompt for SinchRegion, SinchProjectId, SinchAppId, and SinchSmsSender. After the deploy completes, the outputs include WebhookUrl and OutboundQueueUrl.
Registering the webhook
Register the Function URL with Sinch so it starts receiving inbound message callbacks. You can do this in the Sinch Build Dashboard (Conversation API > Apps > your app > Webhooks) or via the API.
Get an OAuth 2.0 token:
curl https://auth.sinch.com/oauth2/token \
-d grant_type=client_credentials \
-u YOUR_ACCESS_KEY:YOUR_ACCESS_KEY_SECRET
Then register:
curl -X POST \
"https://us.conversation.api.sinch.com/v1/projects/YOUR_PROJECT_ID/webhooks" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"app_id": "YOUR_APP_ID",
"target": "YOUR_FUNCTION_URL",
"target_type": "HTTP",
"secret": "your-webhook-secret-here",
"triggers": ["MESSAGE_INBOUND"]
}'
The secret must match what you stored in SSM at /sinch/conversation-webhook-secret. Replace us with eu if your app is in the EU region.
Testing
Tail the logs first:
sam logs --stack-name YOUR_STACK_NAME --tail
Flow 1: Your application sends first
Put a message on the outbound queue using the URL from the deploy outputs:
aws sqs send-message \
--queue-url YOUR_OUTBOUND_QUEUE_URL \
--message-body '{"to": "+15559876543", "message": "Your appointment is tomorrow at 2pm. Reply to confirm."}'
The customer gets the SMS. When they reply, you'll see the webhook log the inbound enqueue, the processor log the outbound enqueue, and the sender log the Sinch call. Your phone gets the response.
Flow 2: Customer sends first
Text your Sinch number directly. The webhook logs the incoming message, the processor enqueues the reply, the sender logs the Sinch call, your phone gets the response.
Things worth knowing
Why three functions and two queues?
The webhook must return 200 quickly or Sinch retries. If your AI agent takes 10 seconds, the webhook would time out. The inbound queue decouples the two: the webhook always returns quickly and the processor gets its own 60-second window with automatic retries on failure.
The outbound queue does the same for sending. The processor doesn't need Sinch credentials or auth logic. It just enqueues { to, message }. The sender handles the Sinch API call in one place, which means your application backend and any human agent tools can send messages the same way without reimplementing credentials.
Retries are automatic
If the processor throws an error, SQS makes the message visible again after the visibility timeout. The visibility timeout is set to 360 seconds, six times the processor's 60-second Lambda function timeout. That gap is intentional: it prevents SQS from redelivering a message while the processor is still running it.
After three failures, the message moves to a dead-letter queue. Both the inbound and outbound queues have one. Monitor them for processing failures: messages there didn't make it through after three attempts.
Sinch stores conversation history too
In Conversation mode, Sinch keeps a copy of all messages for the retention period configured on your app (7 days by default, up to 180 days). You can query it via the Conversation API. That's useful for debugging and audit trails, but don't rely on it as your application's source of truth. Implement your own storage if you need conversation context beyond the retention window.
Duplicates are possible
SQS is at-least-once delivery. Your processor might see the same message twice. Use the messageId field as an idempotency key if your logic has side effects.
Non-text messages are ignored
The webhook extracts contact_message?.text_message?.text using optional chaining. If Sinch delivers a non-text message type (media, location, template), the text field is undefined and the message is silently dropped before it reaches the queue. That's the right default for a text conversation app. If your use case requires handling other types, add explicit branching in the webhook before the enqueue step.
This is the foundation for conversational AI
The processor function is where you plug in your agent. For multi-turn conversations that need to wait for replies and branch based on responses, Lambda Durable Functions let you write that as sequential code that suspends and resumes.
Wrapping up
You now have the full loop: user texts you, the webhook validates and enqueues, the processor generates a reply and sends it back. Clean separation, automatic retries, room for slow processing.
If you also need to know whether your outbound messages were delivered, see the delivery receipts post.
The source code for this post is available on GitHub: sinch-sms-conversation
The repo has a typescript/ and python/ directory, each fully self-contained with its own template.yaml. Pick your language and deploy from that directory.
What are you building with two-way SMS? An AI support agent? Appointment confirmations? Let me know in the comments.

Top comments (0)