You sent an SMS. Did it arrive?
A password reset link that never reached the user. An appointment reminder that bounced because the number was disconnected. A fraud alert that failed silently while the transaction went through.
The previous post covered sending SMS from Lambda. But messages:send returning 200 only means Sinch accepted the message. It doesn't mean the recipient's phone received it. Delivery receipts close that gap. They tell you whether the message was delivered, and if not, why it failed. You can retry, fall back to email, alert an operator, or update a record. On the success side, you can mark a notification as confirmed, start a countdown timer for a response, or log proof of delivery for compliance.
The Sinch Conversation API delivers status updates as webhook callbacks. You register a URL, Sinch POSTs to it every time a message changes state, and your function logs or acts on the result. This post builds that webhook endpoint.
How delivery receipts work
After you send a message, it moves through a series of states:
- QUEUED_ON_CHANNEL: Sinch accepted it and dispatched it to the SMS carrier
- DELIVERED: the carrier confirmed it reached the recipient's device
- FAILED: delivery failed (with a reason code explaining why)
- READ: the recipient opened/read it (rare for SMS, more common on WhatsApp/RCS)
Each state change triggers a MESSAGE_DELIVERY callback to your webhook. A single sent message typically generates 2-3 callbacks as it moves through these states.
HMAC signature validation
When you create a webhook with a secret, Sinch signs every callback. Your function must verify the signature before processing anything. If it doesn't match, return 401 and ignore the payload.
The signature is: HMAC-SHA256(secret, body + "." + nonce + "." + timestamp), base64-encoded.
import { createHmac, timingSafeEqual } from "crypto";
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));
}
The Lambda handler
import { createHmac, timingSafeEqual } from "crypto";
import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm";
const ssm = new SSMClient({});
const SINCH_WEBHOOK_SECRET_PARAM = process.env.SINCH_WEBHOOK_SECRET_PARAM!;
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;
}
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" };
}
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_delivery_report) {
const report = payload.message_delivery_report;
handleDeliveryReceipt(report.message_id, report.status, report.reason);
}
return { statusCode: 200, body: "OK" };
};
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));
}
// Replace this with your application logic
function handleDeliveryReceipt(messageId: string, status: string, reason?: any) {
console.log(`Message ${messageId}: ${status}`);
if (status === "FAILED") {
// Alert, retry on a different channel, update a database record, etc.
console.error(`Delivery failed:`, reason);
}
}
handleDeliveryReceipt is where your logic goes. The demo logs the status. In production you might update a database record, trigger an alert on failure, or retry via a different channel.
What a delivery receipt looks like
Here's an actual callback payload for a successfully delivered message:
{
"app_id": "01EXAMPLE1NFQ9H0N6HBWPTB10A",
"accepted_time": "2026-05-28T09:34:19.812Z",
"event_time": "2026-05-28T09:34:21.267Z",
"project_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"message_delivery_report": {
"message_id": "01EXAMPLE54NBHBQZDHHHPYQ3FS",
"conversation_id": "01EXAMPLEBTR4C1HJZS05PVTXZ8",
"status": "DELIVERED",
"channel_identity": {
"channel": "SMS",
"identity": "+15559876543",
"app_id": ""
},
"contact_id": "01EXAMPLEN3NANZ05VM0FS80EHD",
"metadata": "",
"processing_mode": "CONVERSATION"
}
}
And here's a failed delivery:
{
"message_delivery_report": {
"message_id": "01EQBF0BT63J7S1FEKJZ0Z08VD",
"status": "FAILED",
"reason": {
"code": "RECIPIENT_NOT_REACHABLE",
"description": "The channel reported: Unable to deliver message to recipient",
"sub_code": "UNSPECIFIED_SUB_CODE",
"channel_code": "150"
}
}
}
Common failure codes: RECIPIENT_NOT_REACHABLE, RECIPIENT_INVALID_CHANNEL_IDENTITY, OUTSIDE_ALLOWED_SENDING_WINDOW, CHANNEL_FAILURE.
Correlating receipts to your business logic
The delivery receipt includes the message_id from when you sent the message. If you set message_metadata in your original messages:send request, it's also included in the receipt. This is how you tie a delivery status back to a specific order, alert, or notification in your system.
The triggers array controls which callback types Sinch sends to this URL. MESSAGE_DELIVERY means "only delivery receipts." Other triggers you might use:
-
MESSAGE_INBOUND: a user sent you a message (covered in the companion post about two-way conversations) -
EVENT_INBOUND: typing indicators and other events from users -
CONVERSATION_START/CONVERSATION_STOP: conversation lifecycle events -
CONTACT_CREATE: a new contact was created
You can subscribe to multiple triggers on the same webhook, or create separate webhooks for different triggers. Up to 5 webhooks per app.
Setting up the webhook endpoint
A Lambda Function URL is the simplest way to expose a Lambda as an HTTPS endpoint for Sinch to call. No API Gateway needed.
DeliveryReceiptFunction:
Type: AWS::Serverless::Function
Properties:
Runtime: nodejs22.x
Handler: src/delivery-receipt.handler
CodeUri: webhook/
FunctionUrlConfig:
AuthType: NONE
Environment:
Variables:
SINCH_WEBHOOK_SECRET_PARAM: !Ref SinchWebhookSecretParam
The complete template (with Parameters and Outputs) is in the repo. The snippet above shows the key parts.
AuthType: NONE makes the URL publicly accessible. That's why HMAC validation is required.
Deploying
Store the webhook secret in SSM (same region where you'll deploy):
aws ssm put-parameter \
--name /sinch/webhook-secret \
--value "your-webhook-secret-here" \
--type SecureString
Then build and deploy:
sam build
sam deploy --guided
After the deploy completes, sam deploy prints the stack outputs including your Function URL. Copy it for the next step.
Registering the webhook
Register the Function URL with Sinch so it starts receiving delivery receipt callbacks. You can do this via the dashboard (Conversation API > Apps > your app > Webhooks) or via the API.
First, 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 the webhook using the access_token from the response:
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_DELIVERY"]
}'
You can also do this in the Sinch Build Dashboard under Conversation API > Apps > your app > Webhooks.
The secret value here must match what you stored in SSM at /sinch/webhook-secret.
Watching the logs
After registering the webhook, send an SMS through your Sinch Conversation app (using the Lambda function from the previous post, the Sinch dashboard, or the API directly) and tail the logs:
sam logs --stack-name sinch-sms-delivery-receipts --tail
You should see delivery receipt callbacks arriving within seconds of sending a message.
Things worth knowing
Multiple callbacks per message
Each sent message generates 2-3 callbacks. First QUEUED_ON_CHANNEL, then DELIVERED or FAILED. Don't treat the first callback as the final status. This means your function is invoked 2-3 times per message you send. If you're sending 1000 messages, expect 2000-3000 invocations for delivery receipts alone. At Lambda pricing this is negligible, but worth knowing when you look at your metrics.
Return 200 immediately
Sinch expects a quick response. For simple logging or a single database write, processing inline is fine. If your logic involves external API calls or complex processing, accept the callback immediately, write the payload to SQS, and return 200. A separate function processes the queue.
Callbacks can arrive out of order
A DELIVERED callback might arrive before QUEUED_ON_CHANNEL due to network timing. If your logic depends on ordering, track state per message ID rather than assuming sequential delivery.
Duplicates are possible
Network issues can cause the same callback to arrive more than once. Use message_delivery_report.message_id + status as a deduplication key.
Retries
Sinch retries on 5xx, 429, and network failures with exponential backoff. 4xx (except 429) is treated as a permanent failure and not retried. If your function is down, you'll miss callbacks permanently once retries are exhausted.
Wrapping up
You now know whether your messages are reaching recipients. The handler validates the webhook signature, parses the delivery report, and routes to a function you control.
The natural next step is receiving inbound messages: what happens when the user replies? That's covered in the companion post about two-way SMS conversations.
The source code for this post is available on GitHub: gunnargrosch/sinch-sms-delivery-receipts
The repo also includes a Python implementation of the same handler.
Have you built retry logic on top of delivery receipts, or are you using them differently? I'd like to hear about it in the comments.
Top comments (0)