DEV Community

Cover image for Sending SMS from AWS Lambda with the Sinch SDK
Gunnar Grosch
Gunnar Grosch

Posted on

Sending SMS from AWS Lambda with the Sinch SDK

Your payment just failed. The user needs to know before they try to check out again. Your deployment pipeline just finished. The engineer who kicked it off is waiting. A patient's appointment is in 24 hours and they haven't confirmed.

These are the moments where SMS works better than email. It's immediate, it doesn't get filtered, and it doesn't require the recipient to be looking at an app. Lambda is already where your event-driven logic lives. Connecting it to SMS is a few dozen lines of code.

This post uses the official Sinch SDKs. The examples cover Node.js and Python. Sinch also publishes SDKs for Java and .NET that follow the same pattern. If you'd rather manage the HTTP calls and OAuth token exchange yourself, there's a companion post that covers the raw HTTP approach using the same repo.

Prerequisites

The Sinch dashboard setup takes about 10 minutes. After that, sending is a single SDK call. You'll need a Sinch account (sign up here, a trial account is enough to test, see pricing for details), the AWS SAM CLI, and Node.js 22+ or Python 3.13+.

When you first sign up, Sinch shows an onboarding wizard. If you completed it and selected SMS, you likely already have an app configured with a sender number. In that case, skip to step 4 below. If you dismissed the wizard or are not sure, follow all steps.

Before writing any code, configure the following in the Sinch Build Dashboard:

  1. Get access to Conversation API. Click Conversation API in the left menu, accept the terms, and click GET ACCESS.
  2. Create a Conversation API app. Go to Conversation API > Apps and click Create app. Record the app ID.
  3. 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.
  4. Find your sender number. Go to SMS > SMS Channel > Numbers. The assigned number is your SMS_SENDER value.
  5. Note your project ID. Click the project name in the top bar and go to Project Settings.
  6. Create an access key. Go to Settings > Access Keys. Record the access key ID and secret. The secret is only shown once.

Note: on a trial account, you can only send to verified numbers and the message content is fixed. To send to any number with custom content, upgrade your account.

One thing that trips people up: the Conversation API is regional. Your app must be created in the same region as your SMS service plan. If they're in different regions, messages will fail with no obvious error. The most common regions are us and eu. A Brazil region (br) also exists.

Storing credentials in SSM Parameter Store

The Lambda function reads your Sinch access key and secret from SSM Parameter Store at cold start. Storing them there as SecureString keeps them out of CloudFormation state and encrypted at rest using KMS.

Create the two parameters before deploying. Run these in the same AWS region where you'll deploy the Lambda:

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
Enter fullscreen mode Exit fullscreen mode

The Lambda function

The SDK handles OAuth 2.0 token exchange internally, so there's no token caching code in the handler. What you do cache is the SinchClient instance itself. Initializing it fetches credentials from SSM, so you want that to happen once at cold start, not on every invocation.

TypeScript

import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm";
import { SinchClient } from "@sinch/sdk-core";
import { SQSEvent, SQSBatchResponse, EventBridgeEvent, SNSEvent } from "aws-lambda";

const ssm = new SSMClient({});
const SINCH_REGION = process.env.SINCH_REGION || "us";
const SINCH_PROJECT_ID = process.env.SINCH_PROJECT_ID!;
const SINCH_APP_ID = process.env.SINCH_APP_ID!;
const SINCH_SMS_SENDER = process.env.SINCH_SMS_SENDER!;
const SINCH_ACCESS_KEY_PARAM = process.env.SINCH_ACCESS_KEY_PARAM!;
const SINCH_ACCESS_KEY_SECRET_PARAM = process.env.SINCH_ACCESS_KEY_SECRET_PARAM!;

let sinchClient: SinchClient | null = null;

async function getClient(): Promise<SinchClient> {
  if (sinchClient) return sinchClient;
  const [keyRes, secretRes] = await Promise.all([
    ssm.send(new GetParameterCommand({ Name: SINCH_ACCESS_KEY_PARAM, WithDecryption: true })),
    ssm.send(new GetParameterCommand({ Name: SINCH_ACCESS_KEY_SECRET_PARAM, WithDecryption: true })),
  ]);
  sinchClient = new SinchClient({
    projectId: SINCH_PROJECT_ID,
    keyId: keyRes.Parameter!.Value!,
    keySecret: secretRes.Parameter!.Value!,
    conversationRegion: SINCH_REGION as "us" | "eu",
  });
  return sinchClient;
}

const E164_REGEX = /^\+[1-9]\d{1,14}$/;

async function sendSms(to: string, message: string) {
  if (!E164_REGEX.test(to)) {
    throw new Error(`Invalid phone number format. Use E.164 (e.g. +15551234567), got: ${to}`);
  }
  const client = await getClient();
  return client.conversation.messages.send({
    sendMessageRequestBody: {
      app_id: SINCH_APP_ID,
      recipient: { identified_by: { channel_identities: [{ channel: "SMS", identity: to }] } },
      message: { text_message: { text: message } },
      channel_properties: { SMS_SENDER: SINCH_SMS_SENDER },
    },
  });
}

// --- Direct invocation (default) ---
export const handler = async (event: { to: string; message: string }) => {
  if (!event.to || !event.message) return { statusCode: 400, body: "Missing to or message" };
  try {
    return { statusCode: 200, body: await sendSms(event.to, event.message) };
  } catch (err) {
    console.error(err);
    return { statusCode: 500, body: String(err) };
  }
};

// --- SQS (uncomment to use) ---
// Message body format: { "to": "+15551234567", "message": "Hello!" }
// Returns batchItemFailures so only failed records are retried, preventing duplicate sends.
//
// export const handler = async (event: SQSEvent): Promise<SQSBatchResponse> => {
//   const failures: { itemIdentifier: string }[] = [];
//   for (const record of event.Records) {
//     try {
//       const payload = JSON.parse(record.body);
//       await sendSms(payload.to, payload.message);
//     } catch (err) {
//       console.error(`Failed record ${record.messageId}:`, err);
//       failures.push({ itemIdentifier: record.messageId });
//     }
//   }
//   return { batchItemFailures: failures };
// };

// --- EventBridge (uncomment to use) ---
// Event detail format: { "to": "+15551234567", "message": "Hello!" }
//
// export const handler = async (
//   event: EventBridgeEvent<"SendSms", { to: string; message: string }>
// ) => {
//   await sendSms(event.detail.to, event.detail.message);
// };

// --- SNS (uncomment to use) ---
// SNS message body format: { "to": "+15551234567", "message": "Hello!" }
//
// export const handler = async (event: SNSEvent) => {
//   for (const record of event.Records) {
//     const payload = JSON.parse(record.Sns.Message);
//     await sendSms(payload.to, payload.message);
//   }
// };
Enter fullscreen mode Exit fullscreen mode

Python

import json
import os
import re
import boto3
from sinch import SinchClient
from sinch.core.exceptions import SinchException

ssm = boto3.client("ssm")

SINCH_REGION = os.environ.get("SINCH_REGION", "us")
SINCH_PROJECT_ID = os.environ["SINCH_PROJECT_ID"]
SINCH_APP_ID = os.environ["SINCH_APP_ID"]
SINCH_SMS_SENDER = os.environ["SINCH_SMS_SENDER"]
SINCH_ACCESS_KEY_PARAM = os.environ["SINCH_ACCESS_KEY_PARAM"]
SINCH_ACCESS_KEY_SECRET_PARAM = os.environ["SINCH_ACCESS_KEY_SECRET_PARAM"]

E164_REGEX = re.compile(r"^\+[1-9]\d{1,14}$")

_sinch_client = None


def get_client():
    global _sinch_client
    if _sinch_client:
        return _sinch_client
    key = ssm.get_parameter(Name=SINCH_ACCESS_KEY_PARAM, WithDecryption=True)["Parameter"]["Value"]
    secret = ssm.get_parameter(Name=SINCH_ACCESS_KEY_SECRET_PARAM, WithDecryption=True)["Parameter"]["Value"]
    _sinch_client = SinchClient(
        project_id=SINCH_PROJECT_ID,
        key_id=key,
        key_secret=secret,
        conversation_region=SINCH_REGION,
    )
    return _sinch_client


def send_sms(to, message):
    if not E164_REGEX.match(to):
        raise ValueError(f"Invalid phone number format. Use E.164 (e.g. +15551234567), got: {to}")
    client = get_client()
    return client.conversation.messages.send_text_message(
        app_id=SINCH_APP_ID,
        text=message,
        recipient_identities=[{"channel": "SMS", "identity": to}],
        channel_properties={"SMS_SENDER": SINCH_SMS_SENDER},
    )


# --- Direct invocation (default) ---
def handler(event, context):
    to = event.get("to")
    message = event.get("message")
    if not to or not message:
        return {"statusCode": 400, "body": "Missing to or message"}
    try:
        result = send_sms(to, message)
        return {"statusCode": 200, "body": {"message_id": result.message_id, "accepted_time": str(result.accepted_time)}}
    except Exception as e:
        if isinstance(e, SinchException):
            print(f"Sinch API error {e.response_status_code}: {e}")
            return {"statusCode": e.response_status_code or 500, "body": str(e)}
        print(f"Error: {e}")
        return {"statusCode": 500, "body": str(e)}


# --- SQS (uncomment to use) ---
# Message body format: {"to": "+15551234567", "message": "Hello!"}
# Returns batchItemFailures so only failed records are retried, preventing duplicate sends.
#
# def handler(event, context):
#     failures = []
#     for record in event["Records"]:
#         try:
#             payload = json.loads(record["body"])
#             send_sms(payload["to"], payload["message"])
#         except Exception as e:
#             print(f"Failed record {record['messageId']}: {e}")
#             failures.append({"itemIdentifier": record["messageId"]})
#     return {"batchItemFailures": failures}


# --- EventBridge (uncomment to use) ---
# Event detail format: {"to": "+15551234567", "message": "Hello!"}
#
# def handler(event, context):
#     detail = event["detail"]
#     send_sms(detail["to"], detail["message"])


# --- SNS (uncomment to use) ---
# SNS message body format: {"to": "+15551234567", "message": "Hello!"}
#
# def handler(event, context):
#     for record in event["Records"]:
#         payload = json.loads(record["Sns"]["Message"])
#         send_sms(payload["to"], payload["message"])
Enter fullscreen mode Exit fullscreen mode

Compared to the raw HTTP version, there's no getAccessToken() function and no token cache. The SDK handles the OAuth 2.0 exchange internally and refreshes tokens as needed. The tradeoff: you add @sinch/sdk-core (Node.js) or sinch (Python) as a runtime dependency. The Java and .NET SDKs work the same way if those are your languages.

The SAM template

The repo uses a single template.yaml at the root that deploys all four functions together. Here's the relevant section for the Node.js SDK function:

AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31

Globals:
  Function:
    Timeout: 10
    MemorySize: 256
    Environment:
      Variables:
        SINCH_REGION: !Ref SinchRegion
        SINCH_PROJECT_ID: !Ref SinchProjectId
        SINCH_APP_ID: !Ref SinchAppId
        SINCH_SMS_SENDER: !Ref SinchSmsSender
        SINCH_ACCESS_KEY_PARAM: !Ref SinchAccessKeyParam
        SINCH_ACCESS_KEY_SECRET_PARAM: !Ref SinchAccessKeySecretParam

Parameters:
  SinchRegion:
    Type: String
    Default: us
    AllowedValues: [us, eu]
  # ... SinchProjectId, SinchAppId, SinchSmsSender,
  #     SinchAccessKeyParam, SinchAccessKeySecretParam

Resources:
  NodeSdkFunction:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: nodejs22.x
      Handler: src/handler.handler
      CodeUri: node-sdk/
      Policies:
        - Statement:
            - Effect: Allow
              Action: ssm:GetParameter
              Resource:
                - !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter${SinchAccessKeyParam}"
                - !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter${SinchAccessKeySecretParam}"
    Metadata:
      BuildMethod: esbuild
      BuildProperties:
        Minify: false
        Target: es2022
        EntryPoints:
          - node-sdk/src/handler.ts
Enter fullscreen mode Exit fullscreen mode

Deploy from the repo root:

sam build
sam deploy --guided
Enter fullscreen mode Exit fullscreen mode

SAM handles all dependencies automatically during sam build: Node.js packages via npm install, Python packages via requirements.txt.

Invoking the function

With no event source attached, you invoke the function directly with a JSON payload. The simplest way is the AWS CLI:

# Get the function name from the stack outputs after sam deploy --guided,
# then invoke it:
aws lambda invoke \
  --function-name <NodeSdkFunctionName from outputs> \
  --payload '{"to": "+15559876543", "message": "Hello from Lambda!"}' \
  --cli-binary-format raw-in-base64-out \
  response.json

cat response.json
Enter fullscreen mode Exit fullscreen mode

A successful response looks like:

{
  "statusCode": 200,
  "body": {
    "accepted_time": "2026-05-27T18:00:00.000Z",
    "message_id": "01ABC123DEF456GHI789JKL012"
  }
}
Enter fullscreen mode Exit fullscreen mode

message_id is what you'd use to correlate delivery receipts if you set up a webhook.

If something goes wrong:

{
  "statusCode": 400,
  "body": "{\"error\":{\"code\":400,\"message\":\"recipient identity is not a valid MSISDN\",\"status\":\"INVALID_ARGUMENT\"}}"
}
Enter fullscreen mode Exit fullscreen mode

Common causes: malformed phone number, wrong app_id, region mismatch between your app and service plan, or missing SMS_SENDER without a default originator configured.

When you're ready to wire it up to something, change only the handler function. sendSms() / send_sms() doesn't change. The SAM template has commented event source blocks for SQS, EventBridge, SNS, and API Gateway ready to uncomment:

  • SQS for queued, rate-controlled sending
  • EventBridge for event-driven notifications (order placed, alarm fired, job completed)
  • SNS for fan-out scenarios
  • API Gateway if you need an HTTP endpoint

Testing locally

echo '{"to": "+15559876543", "message": "Test"}' > event.json
sam local invoke NodeSdkFunction --event event.json
# or
sam local invoke PythonSdkFunction --event event.json
Enter fullscreen mode Exit fullscreen mode

Note: sam local invoke won't be able to reach SSM or the Sinch API unless your local environment has AWS credentials with the right permissions. For local testing without live credentials, stub the SSM calls or use environment variables directly.

Things worth knowing

Phone numbers must be E.164

The to value must be in E.164 format: +15551234567, not 5551234567 or (555) 123-4567. The handler validates this before calling the SDK. The Sinch API won't normalize it for you.

Long messages get split

SMS has a 160-character limit for standard GSM encoding. Longer messages get split into multiple parts. You can cap this with SMS_MAX_NUMBER_OF_MESSAGE_PARTS in channel_properties:

"channel_properties": {
  "SMS_SENDER": "+15551231234",
  "SMS_MAX_NUMBER_OF_MESSAGE_PARTS": "2"
}
Enter fullscreen mode Exit fullscreen mode

If the message would require more parts than the cap, the API rejects it. It won't truncate or silently drop it. You'll get an error back, so you can handle it in your application.

Error handling and delivery status

When sendSms() throws, the handler logs the error and returns a 500. For SQS triggers, implement partial batch failure reporting to avoid retrying already-sent messages: catch errors per record and return { batchItemFailures: [{ itemIdentifier: record.messageId }] }.

For true end-to-end idempotency, you need deduplication before the Sinch call since the Conversation API doesn't support idempotency keys. Use an SQS FIFO queue with a MessageDeduplicationId derived from your business event, or the Powertools for AWS Lambda idempotency utility which handles a DynamoDB check-before-send with a decorator.

For delivery status, the Conversation API sends a DELIVERED or FAILED callback to a webhook URL you configure on your app. Register one in the Sinch Build Dashboard or via the Webhooks API.

Wrapping up

The SDK version is shorter than the raw HTTP version: no token management, no fetch calls, just client.conversation.messages.send(). The cost is a runtime dependency. Whether that tradeoff is worth it depends on your project.

Sinch also publishes SDKs for Java and .NET. The same pattern applies.

The natural next step is delivery receipts: configuring a webhook so your app knows whether each message actually reached the recipient's device. After that, if you need to send to many recipients, wiring an SQS queue in front of the function gives you rate control and automatic retries without changing the handler.

The source code for this post is available on GitHub: gunnargrosch/sinch-sms-lambda

What are you building that needs SMS notifications? Let me know in the comments.

Additional Resources

Top comments (0)