AWS SNS: Mobile Push, Email and SMS Notification Delivery (2026)

AWS SNS Mobile Push and Notifications

Amazon Simple Notification Service (SNS) is AWS's fully managed pub/sub messaging service — but calling it just a pub/sub broker undersells what it actually does in production. SNS is the notification backbone for millions of applications: it dispatches transactional SMS to customers in 200+ countries, sends mobile push alerts via Apple's APNS and Google's FCM, delivers email confirmations, triggers Lambda functions, and fans out events to dozens of SQS queues simultaneously. All of this happens at AWS scale with sub-second delivery, no servers to manage, and a pay-per-message pricing model that beats any self-hosted alternative.

This guide goes deep on every SNS delivery channel — from registering device tokens for mobile push to configuring FIFO topics with deduplication, setting up DLQs for failed deliveries, and archiving messages to S3 via Kinesis Firehose. Real Python boto3 code and AWS CLI commands are included throughout.

SNS Subscription Protocols Overview

Every SNS topic can have multiple subscriptions, each with its own protocol. When you publish a message to the topic, SNS fans it out to every confirmed subscription simultaneously. Understanding the delivery semantics and confirmation requirements of each protocol is essential before building production workflows.

SNS supports eight subscription protocols:

  • HTTP / HTTPS — SNS POSTs a JSON envelope to a public endpoint. The endpoint must respond 200 OK within 15 seconds. AWS retries with exponential backoff (20 retries over ~23 hours) on non-2xx responses.
  • Email — SNS sends a plaintext or JSON email. Requires manual confirmation click from the subscriber before messages are delivered. Useful for internal alerts and notifications.
  • Email-JSON — Same as Email but the body is the full SNS JSON envelope including message attributes, topic ARN, and timestamp.
  • SMS — SNS delivers messages as SMS to a phone number. No subscription confirmation needed — you send directly. Subject to carrier restrictions and country-level opt-out laws.
  • SQS — SNS enqueues a JSON-wrapped message into an SQS queue. The most reliable protocol because SQS provides durability. Use for decoupled async processing.
  • Lambda — SNS invokes a Lambda function synchronously with the message payload. SNS retries twice on Lambda errors. For critical pipelines, use SNS → SQS → Lambda instead.
  • Firehose — SNS delivers to an Amazon Kinesis Data Firehose stream, enabling fan-out to S3, Redshift, or OpenSearch for archiving and analytics.
  • Application (Mobile Push) — SNS sends a push notification via a Platform Application (FCM, APNS, ADM, Baidu). The actual protocol to the device is managed by the platform — SNS handles the authentication and delivery handshake.
# Create an SNS topic
aws sns create-topic --name my-notifications

# Subscribe an SQS queue to the topic
aws sns subscribe \
  --topic-arn arn:aws:sns:us-east-1:123456789:my-notifications \
  --protocol sqs \
  --notification-endpoint arn:aws:sqs:us-east-1:123456789:my-queue

# Subscribe an HTTPS endpoint
aws sns subscribe \
  --topic-arn arn:aws:sns:us-east-1:123456789:my-notifications \
  --protocol https \
  --notification-endpoint https://api.example.com/webhooks/sns

# List all subscriptions for a topic
aws sns list-subscriptions-by-topic \
  --topic-arn arn:aws:sns:us-east-1:123456789:my-notifications
Subscription Confirmation: HTTP, HTTPS, and Email protocols require the subscriber to confirm the subscription by visiting a SubscribeURL that SNS sends to the endpoint. Unconfirmed subscriptions expire after 3 days. For programmatic HTTP endpoints, parse the SubscriptionConfirmation request type and make a GET to SubscribeURL automatically.

Each protocol has its own retry policy, message size limits, and delivery guarantees. HTTP endpoints get 3 retries in the first 20 seconds, then exponential backoff; SQS gets effectively unlimited retries as long as the queue exists; Lambda gets 2 retries then drops the message (unless you configure a DLQ). Always match the protocol to your reliability requirements.

Mobile Push: FCM (Android) and APNS (iOS)

Mobile push is where SNS shines. Instead of maintaining connections to Apple's APNs servers and Google's FCM servers yourself — managing authentication tokens, handling retries, scaling connection pools — SNS handles all of that. You register a Platform Application once, register device endpoints, and publish. SNS translates your message into the platform-native format and delivers it.

Step 1: Create a Platform Application

A Platform Application represents your app on a specific push platform. For Android/FCM, you provide your FCM Server Key (v1 API uses a service account JSON). For iOS/APNS, you provide your Apple Push Certificate or a token-based key (.p8 file).

import boto3

sns = boto3.client('sns', region_name='us-east-1')

# Create Platform Application for FCM (Android)
fcm_response = sns.create_platform_application(
    Name='MyApp-Android',
    Platform='GCM',  # GCM = Google Cloud Messaging / FCM
    Attributes={
        'PlatformCredential': 'YOUR_FCM_SERVER_KEY',
        'PlatformPrincipal': '',  # Not needed for GCM
    }
)
fcm_app_arn = fcm_response['PlatformApplicationArn']
print(f"FCM Platform Application ARN: {fcm_app_arn}")

# Create Platform Application for APNS (iOS) — token-based auth
apns_response = sns.create_platform_application(
    Name='MyApp-iOS',
    Platform='APNS',  # Use APNS_SANDBOX for development
    Attributes={
        'PlatformCredential': open('AuthKey_XXXXXXXXXX.p8').read(),
        'PlatformPrincipal': 'YOUR_10_CHAR_KEY_ID',
        'ApplePlatformTeamID': 'YOUR_TEAM_ID',
        'ApplePlatformBundleID': 'com.yourcompany.yourapp',
    }
)
apns_app_arn = apns_response['PlatformApplicationArn']
print(f"APNS Platform Application ARN: {apns_app_arn}")

Step 2: Register Device Endpoints

When a user installs your app and grants notification permission, the OS provides a device token (FCM registration token or APNs device token). Your backend calls create_platform_endpoint to register it with SNS. Store the resulting endpoint ARN in your user database — this is what you'll publish to for unicast (single-user) notifications.

def register_device(platform_app_arn: str, device_token: str, user_id: str) -> str:
    """Register or update a device endpoint. Returns endpoint ARN."""
    try:
        response = sns.create_platform_endpoint(
            PlatformApplicationArn=platform_app_arn,
            Token=device_token,
            CustomUserData=user_id,  # Store user_id for your own reference
            Attributes={
                'Enabled': 'true',
            }
        )
        endpoint_arn = response['EndpointArn']
        print(f"Created endpoint: {endpoint_arn}")
        return endpoint_arn
    except sns.exceptions.InvalidParameterException as e:
        # Endpoint already exists — extract ARN from error message
        import re
        match = re.search(r'Endpoint (arn:aws:sns[^ ]+) already exists', str(e))
        if match:
            endpoint_arn = match.group(1)
            # Update the token in case it refreshed
            sns.set_endpoint_attributes(
                EndpointArn=endpoint_arn,
                Attributes={
                    'Token': device_token,
                    'Enabled': 'true',
                }
            )
            return endpoint_arn
        raise

Step 3: Publish a Push Notification

SNS uses a structured JSON message format when publishing to mobile endpoints. The MessageStructure='json' parameter tells SNS to route the GCM or APNS key to the appropriate platform. This lets you craft platform-specific payloads in a single publish call.

import json

def send_push_notification(endpoint_arn: str, title: str, body: str, data: dict = None):
    """Send a push notification to a single device."""
    message = {
        'GCM': json.dumps({
            'notification': {
                'title': title,
                'body': body,
                'sound': 'default',
            },
            'data': data or {},
            'priority': 'high',
        }),
        'APNS': json.dumps({
            'aps': {
                'alert': {
                    'title': title,
                    'body': body,
                },
                'sound': 'default',
                'badge': 1,
            },
            **(data or {}),
        }),
        'APNS_SANDBOX': json.dumps({
            'aps': {
                'alert': {'title': title, 'body': body},
                'sound': 'default',
            },
        }),
        'default': f"{title}: {body}",
    }

    try:
        response = sns.publish(
            TargetArn=endpoint_arn,
            Message=json.dumps(message),
            MessageStructure='json',
            MessageAttributes={
                'notificationType': {
                    'DataType': 'String',
                    'StringValue': 'transactional',
                }
            }
        )
        return response['MessageId']
    except sns.exceptions.EndpointDisabledException:
        # Device token is stale — remove from your DB
        print(f"Endpoint disabled: {endpoint_arn}. Remove from database.")
        return None
Token Refresh Handling: FCM tokens can be rotated by Google. Your app should detect a new token on launch and re-register it. On the backend, handle EndpointDisabledException by disabling or deleting the endpoint ARN and updating your user record. For iOS, APNS tokens rarely change but can after a device restore.

For broadcast push (send to all users), subscribe all endpoint ARNs to a topic and publish once to the topic. For audience segments (e.g., "all iOS users in the US"), use SNS message filtering with subscription filter policies on attributes like platform and region.

Email Notifications and SES vs SNS Email

SNS email subscriptions are quick to set up and useful for internal alerts — infrastructure notifications, deployment events, error summaries. However, they come with important limitations that determine when you should use SES instead.

SNS Email Subscription Flow

When you create an email subscription, SNS sends a confirmation email with a SubscribeURL link. The recipient must click this link before SNS will deliver any messages to that address. This double opt-in is by design — it prevents you from spamming arbitrary addresses via SNS.

import boto3

sns = boto3.client('sns', region_name='us-east-1')

# Subscribe an email address to an SNS topic
response = sns.subscribe(
    TopicArn='arn:aws:sns:us-east-1:123456789:alerts-topic',
    Protocol='email',
    Endpoint='ops-team@yourcompany.com',
    ReturnSubscriptionArn=False  # ARN only available after confirmation
)
print("Confirmation email sent. Subscription pending until confirmed.")

# For JSON-formatted email (includes full SNS envelope)
sns.subscribe(
    TopicArn='arn:aws:sns:us-east-1:123456789:alerts-topic',
    Protocol='email-json',
    Endpoint='devops@yourcompany.com'
)

SNS email has a critical limitation: you cannot customize the From address, subject line formatting beyond the topic's DisplayName, or HTML email body. The email body is the raw message string. For customer-facing email with branding, HTML templates, and deliverability management, use Amazon SES.

When to Use SNS Email vs SES

FeatureSNS EmailAmazon SES
Setup complexityMinimalRequires domain verification, DKIM
HTML email supportNo (plaintext only)Yes (full HTML + attachments)
Custom From addressNoYes (any verified domain)
Deliverability managementBasicFull (bounce, complaint handling)
Bulk sendingNot designed for itYes (up to 50,000/day sandbox, unlimited production)
Best forInternal ops alerts, monitoringTransactional & marketing email
A common pattern: Use SNS Email for internal CloudWatch alarms and budget alerts where speed matters and HTML formatting is irrelevant. For customer order confirmations, password resets, and newsletters, route SNS → Lambda → SES so you get the pub/sub fanout of SNS with the template power of SES.

To publish a message that arrives cleanly in email, set the SNS topic's DisplayName attribute — this becomes the sender name in the email client. Keep messages concise since there's no HTML rendering, and include the relevant context (resource ID, metric value, timestamp) directly in the message body.

# Set a display name so emails show "Techoral Alerts" instead of a raw ARN
aws sns set-topic-attributes \
  --topic-arn arn:aws:sns:us-east-1:123456789:alerts-topic \
  --attribute-name DisplayName \
  --attribute-value "Techoral Alerts"

# Publish a message (arrives as email to all confirmed email subscribers)
aws sns publish \
  --topic-arn arn:aws:sns:us-east-1:123456789:alerts-topic \
  --subject "Deploy Failed: api-service v2.3.1" \
  --message "Deployment of api-service v2.3.1 failed at 14:23 UTC.
Region: us-east-1
Error: Health check timeout after 5 minutes
Action required: Check ECS service events and CloudWatch logs."

SMS Deep-Dive: Transactional vs Promotional

SNS SMS delivery is powerful but nuanced. Before sending a single SMS in production, understand the two message types, the account-level spending limit, opt-out handling, and country-level restrictions — getting these wrong leads to blocked messages, unexpected bills, or regulatory violations.

Transactional vs Promotional SMS

Every SNS SMS message is classified as either Transactional or Promotional. This classification affects routing, deliverability, and in some countries (like India with the DND registry) whether the message is delivered at all.

  • Transactional — OTPs, authentication codes, order confirmations, bank alerts. Highest priority routing. Delivered even to DND-registered numbers in India. More expensive.
  • Promotional — marketing offers, announcements, newsletters. Standard routing. Blocked by DND registrations. Cheaper.
import boto3

sns = boto3.client('sns', region_name='us-east-1')

def send_sms(phone_number: str, message: str, message_type: str = 'Transactional',
             sender_id: str = None) -> dict:
    """
    Send an SMS via SNS.
    phone_number: E.164 format, e.g. '+12025551234' or '+919876543210'
    message_type: 'Transactional' or 'Promotional'
    sender_id: Alphanumeric sender ID (not supported in US/Canada)
    """
    attributes = {
        'AWS.SNS.SMS.SMSType': {
            'DataType': 'String',
            'StringValue': message_type,
        },
    }
    if sender_id:
        attributes['AWS.SNS.SMS.SenderID'] = {
            'DataType': 'String',
            'StringValue': sender_id,
        }

    response = sns.publish(
        PhoneNumber=phone_number,
        Message=message,
        MessageAttributes=attributes,
    )
    return {'MessageId': response['MessageId'], 'Status': 'sent'}

# Send an OTP (Transactional)
send_sms('+919876543210', 'Your OTP is 847291. Valid for 5 minutes.', 'Transactional')

# Send a promotion (Promotional)
send_sms('+919876543210', 'Get 20% off this weekend! Use code SAVE20.', 'Promotional')

Bulk SMS Sender with Rate Control

import time
import boto3
from typing import List

sns = boto3.client('sns', region_name='us-east-1')

def bulk_sms(recipients: List[dict], message_template: str,
             message_type: str = 'Transactional', rate_per_second: int = 20):
    """
    Send SMS to a list of recipients with rate limiting.
    recipients: [{'phone': '+1...', 'name': 'Alice', 'data': {...}}]
    """
    results = {'sent': 0, 'failed': 0, 'errors': []}
    interval = 1.0 / rate_per_second

    for i, recipient in enumerate(recipients):
        phone = recipient['phone']
        # Personalize message (simple template replacement)
        message = message_template.format(**recipient.get('data', {}),
                                           name=recipient.get('name', ''))
        try:
            sns.publish(
                PhoneNumber=phone,
                Message=message[:160],  # SMS segment limit
                MessageAttributes={
                    'AWS.SNS.SMS.SMSType': {'DataType': 'String', 'StringValue': message_type}
                }
            )
            results['sent'] += 1
        except Exception as e:
            results['failed'] += 1
            results['errors'].append({'phone': phone, 'error': str(e)})

        # Rate limiting — SNS default is 20 TPS per account for SMS
        if (i + 1) % rate_per_second == 0:
            time.sleep(1)
        else:
            time.sleep(interval)

    return results
Spending Limit: SNS has a default monthly SMS spending limit of $1 (yes, one dollar) per account. Increase this via the SNS console under Text messaging (SMS) > Text messaging preferences before any production use. Also set up a CloudWatch alarm on SMSMonthToDateSpentUSD to prevent surprise bills.

Opt-Out Handling

In the US, recipients can text STOP to opt out. SNS manages opt-out lists automatically — opted-out numbers are added to the account-level opt-out list and will not receive further messages. You can check and manage this list programmatically:

# List opted-out phone numbers
aws sns list-phone-numbers-opted-out

# Check if a specific number has opted out
aws sns check-if-phone-number-is-opted-out \
  --phone-number +12025551234

# Re-opt-in a number (only if the user explicitly requested it)
aws sns opt-in-phone-number \
  --phone-number +12025551234

Message Attributes and Subscription Filtering

Without filtering, every SNS subscriber receives every message published to the topic. That's inefficient when different subscribers care about different event types. SNS subscription filter policies let each subscriber define exactly which messages it wants, based on message attributes. This eliminates the need for separate topics per event type.

Publishing with Message Attributes

import boto3, json

sns = boto3.client('sns', region_name='us-east-1')

# Publish an order event with attributes for filtering
sns.publish(
    TopicArn='arn:aws:sns:us-east-1:123456789:order-events',
    Message=json.dumps({
        'orderId': 'ORD-9841',
        'customerId': 'CUST-4521',
        'total': 149.99,
        'currency': 'USD',
        'status': 'placed',
    }),
    MessageAttributes={
        'eventType': {
            'DataType': 'String',
            'StringValue': 'order.placed',
        },
        'region': {
            'DataType': 'String',
            'StringValue': 'us-east-1',
        },
        'totalAmount': {
            'DataType': 'Number',
            'StringValue': '149.99',
        },
        'isPrime': {
            'DataType': 'String',
            'StringValue': 'true',
        }
    }
)

Configuring Subscription Filter Policies

# Filter policy: only receive order.placed events with totalAmount >= 100 from Prime customers
filter_policy = {
    "eventType": ["order.placed", "order.confirmed"],
    "totalAmount": [{"numeric": [">=", 100]}],
    "isPrime": ["true"]
}

# Apply filter policy when subscribing (or update existing subscription)
sns.subscribe(
    TopicArn='arn:aws:sns:us-east-1:123456789:order-events',
    Protocol='sqs',
    Endpoint='arn:aws:sqs:us-east-1:123456789:high-value-orders',
    Attributes={
        'FilterPolicy': json.dumps(filter_policy),
        'FilterPolicyScope': 'MessageAttributes',  # or 'MessageBody'
    }
)

# Another subscriber receives ALL order events (no filter)
sns.subscribe(
    TopicArn='arn:aws:sns:us-east-1:123456789:order-events',
    Protocol='sqs',
    Endpoint='arn:aws:sqs:us-east-1:123456789:all-orders-audit',
)
FilterPolicyScope: Set to MessageAttributes (default) to filter on message attributes, or MessageBody to filter on fields inside the JSON message body. MessageBody filtering requires the message to be valid JSON. Combine both for complex routing — e.g., route by eventType attribute and then check a nested status field inside the body.

Platform-Specific Message Overrides (Mobile Push)

When publishing to a topic with both FCM and APNS subscribers, you can provide platform-specific payloads in a single publish call. SNS routes the correct payload to each platform:

message_payload = {
    "default": "You have a new message",
    "GCM": json.dumps({
        "notification": {"title": "New Message", "body": "Alice sent you a photo"},
        "data": {"chatId": "CHT-882", "type": "photo"},
        "android": {"priority": "high", "ttl": "3600s"}
    }),
    "APNS": json.dumps({
        "aps": {
            "alert": {"title": "New Message", "body": "Alice sent you a photo"},
            "badge": 3,
            "sound": "chime.wav",
            "mutable-content": 1,  # Allows notification service extension
        },
        "chatId": "CHT-882",
        "type": "photo",
    }),
    "APNS_SANDBOX": json.dumps({
        "aps": {"alert": {"title": "New Message", "body": "Alice sent you a photo"}}
    })
}

sns.publish(
    TopicArn='arn:aws:sns:us-east-1:123456789:push-broadcast',
    Message=json.dumps(message_payload),
    MessageStructure='json',
)

FIFO SNS Topics

Standard SNS topics are eventually consistent and deliver messages without strict ordering guarantees — adequate for most notification use cases where "at-least-once, approximate order" is fine. FIFO SNS topics (available since 2020) add exactly-once delivery, strict message ordering per Message Group ID, and deduplication — at the cost of throughput (300 publishes/second, 3,000 with batching).

When to Use FIFO Topics

Use FIFO SNS when downstream consumers must process events in the order they occurred. Classic examples: financial transaction ledgers, inventory state machines, and order lifecycle events (placed → confirmed → shipped → delivered) where processing "shipped" before "placed" would corrupt state.

# Create a FIFO SNS topic (name must end in .fifo)
aws sns create-topic \
  --name order-lifecycle.fifo \
  --attributes '{
    "FifoTopic": "true",
    "ContentBasedDeduplication": "false"
  }'

# FIFO SNS can only subscribe to FIFO SQS queues
# Create a FIFO SQS queue
aws sqs create-queue \
  --queue-name order-processing.fifo \
  --attributes '{
    "FifoQueue": "true",
    "ContentBasedDeduplication": "false"
  }'

# Subscribe FIFO SQS to FIFO SNS
aws sns subscribe \
  --topic-arn arn:aws:sns:us-east-1:123456789:order-lifecycle.fifo \
  --protocol sqs \
  --notification-endpoint arn:aws:sqs:us-east-1:123456789:order-processing.fifo
import boto3, json, hashlib

sns = boto3.client('sns', region_name='us-east-1')

def publish_order_event(order_id: str, event_type: str, payload: dict):
    """Publish an order event with guaranteed ordering and deduplication."""
    # Deduplication ID: hash of order_id + event_type + timestamp bucket
    # Using content-based dedup would auto-hash the message body
    dedup_id = hashlib.md5(f"{order_id}:{event_type}".encode()).hexdigest()

    response = sns.publish(
        TopicArn='arn:aws:sns:us-east-1:123456789:order-lifecycle.fifo',
        Message=json.dumps(payload),
        Subject=f"Order {event_type}",
        MessageGroupId=order_id,        # All events for same order are ordered
        MessageDeduplicationId=dedup_id, # Prevents duplicates within 5-min window
        MessageAttributes={
            'eventType': {'DataType': 'String', 'StringValue': event_type}
        }
    )
    return response['MessageId']

# Events for the same order will be delivered in publish order
publish_order_event('ORD-9841', 'order.placed', {'total': 149.99, 'items': 3})
publish_order_event('ORD-9841', 'order.confirmed', {'warehouse': 'BLR-1'})
publish_order_event('ORD-9841', 'order.shipped', {'trackingId': 'DTDC123'})
FIFO Constraint: FIFO SNS topics only support SQS (FIFO) subscriptions — you cannot subscribe Lambda, HTTP endpoints, email, or SMS to a FIFO topic. Use FIFO SNS → FIFO SQS → Lambda for ordered serverless processing. See our guide on SQS and SNS messaging for the full FIFO SQS deep-dive.

MessageGroupId controls the ordering scope. All messages with the same MessageGroupId are delivered in order, but messages across different groups can be delivered in parallel — allowing horizontal scaling while maintaining per-entity ordering. Use order_id, user_id, or account_id as the group ID depending on your consistency requirement.

Delivery Status Logging and S3 Archiving

SNS delivery status logging gives you visibility into whether each message was successfully delivered to each subscriber. Without it, you're flying blind — a silent delivery failure to an HTTP endpoint or a disabled mobile endpoint goes unnoticed. Enable delivery status logging to CloudWatch Logs for every production topic.

Enabling Delivery Status Logging

import boto3

sns = boto3.client('sns', region_name='us-east-1')
iam = boto3.client('iam', region_name='us-east-1')

# First, create an IAM role that allows SNS to write to CloudWatch Logs
# (In production, use CloudFormation or CDK for this)
topic_arn = 'arn:aws:sns:us-east-1:123456789:my-notifications'

# Enable delivery status logging for HTTP/HTTPS (100% sample rate)
sns.set_topic_attributes(
    TopicArn=topic_arn,
    AttributeName='HTTPSuccessFeedbackRoleArn',
    AttributeValue='arn:aws:iam::123456789:role/SNSDeliveryFeedback'
)
sns.set_topic_attributes(
    TopicArn=topic_arn,
    AttributeName='HTTPFailureFeedbackRoleArn',
    AttributeValue='arn:aws:iam::123456789:role/SNSDeliveryFeedback'
)
sns.set_topic_attributes(
    TopicArn=topic_arn,
    AttributeName='HTTPSuccessFeedbackSampleRate',
    AttributeValue='100'  # Log 100% of successful deliveries
)

# Enable for mobile push (GCM/FCM)
sns.set_topic_attributes(
    TopicArn=topic_arn,
    AttributeName='ApplicationSuccessFeedbackRoleArn',
    AttributeValue='arn:aws:iam::123456789:role/SNSDeliveryFeedback'
)
sns.set_topic_attributes(
    TopicArn=topic_arn,
    AttributeName='ApplicationFailureFeedbackRoleArn',
    AttributeValue='arn:aws:iam::123456789:role/SNSDeliveryFeedback'
)

Delivery status logs appear in CloudWatch Logs under /aws/sns/deliverylogs/<protocol>. Each log entry includes the message ID, subscriber endpoint ARN, delivery status, status code, dwell time (time from publish to delivery attempt), and total time.

SNS to S3 via Kinesis Firehose

For long-term message archiving, analytics, or compliance, subscribe a Kinesis Data Firehose delivery stream to your SNS topic. Firehose buffers and batches records before writing to S3, with configurable prefix, format (JSON lines, Parquet), and compression.

# Create Firehose delivery stream to S3 (abbreviated — use CDK/CloudFormation in production)
aws firehose create-delivery-stream \
  --delivery-stream-name sns-archive \
  --delivery-stream-type DirectPut \
  --s3-destination-configuration '{
    "RoleARN": "arn:aws:iam::123456789:role/FirehoseS3Role",
    "BucketARN": "arn:aws:s3:::my-sns-archive-bucket",
    "Prefix": "sns/year=!{timestamp:yyyy}/month=!{timestamp:MM}/day=!{timestamp:dd}/",
    "ErrorOutputPrefix": "sns-errors/",
    "BufferingHints": {"SizeInMBs": 5, "IntervalInSeconds": 300},
    "CompressionFormat": "GZIP"
  }'

# Subscribe Firehose to SNS topic
aws sns subscribe \
  --topic-arn arn:aws:sns:us-east-1:123456789:my-notifications \
  --protocol firehose \
  --notification-endpoint arn:aws:firehose:us-east-1:123456789:deliverystream/sns-archive \
  --attributes '{"SubscriptionRoleArn": "arn:aws:iam::123456789:role/SNSFirehoseRole"}'
Firehose vs S3 Direct: You cannot have SNS write to S3 directly. The Firehose protocol subscription is the correct way to archive SNS messages to S3. Firehose also enables real-time analytics — add an AWS Lambda transform to enrich records before they land in S3, or enable Firehose's native Parquet conversion for Athena queries on the archive. See our guide on EventBridge for complementary event-driven archiving patterns.

Dead Letter Queues and Cost Optimization

Even with SNS's robust retry logic, some messages fail permanent delivery. HTTP endpoints can go offline, Lambda functions can error past the retry limit, mobile endpoints can be permanently disabled. Without a Dead Letter Queue (DLQ), those messages are silently dropped. A DLQ captures failed deliveries so you can inspect, replay, or alert on them.

Configuring SQS DLQ for SNS Subscriptions

Each SNS subscription can have its own DLQ — a dedicated SQS queue where SNS sends messages that failed all delivery retries. This is separate from the SQS queue DLQ (which handles messages that consumers failed to process).

import boto3, json

sns = boto3.client('sns', region_name='us-east-1')
sqs = boto3.client('sqs', region_name='us-east-1')

# Create the DLQ
dlq_response = sqs.create_queue(
    QueueName='sns-notifications-dlq',
    Attributes={'MessageRetentionPeriod': '1209600'}  # 14 days
)
dlq_url = dlq_response['QueueUrl']
dlq_arn = sqs.get_queue_attributes(
    QueueUrl=dlq_url, AttributeNames=['QueueArn']
)['Attributes']['QueueArn']

# Allow SNS to send to the DLQ (SQS resource policy)
dlq_policy = {
    "Version": "2012-10-17",
    "Statement": [{
        "Effect": "Allow",
        "Principal": {"Service": "sns.amazonaws.com"},
        "Action": "sqs:SendMessage",
        "Resource": dlq_arn,
        "Condition": {
            "ArnEquals": {
                "aws:SourceArn": "arn:aws:sns:us-east-1:123456789:my-notifications"
            }
        }
    }]
}
sqs.set_queue_attributes(
    QueueUrl=dlq_url,
    Attributes={'Policy': json.dumps(dlq_policy)}
)

# Subscribe an HTTP endpoint with a DLQ
response = sns.subscribe(
    TopicArn='arn:aws:sns:us-east-1:123456789:my-notifications',
    Protocol='https',
    Endpoint='https://api.example.com/webhooks/sns',
    Attributes={
        'RedrivePolicy': json.dumps({
            'deadLetterTargetArn': dlq_arn
        })
    }
)
print(f"Subscription with DLQ: {response['SubscriptionArn']}")

Monitoring Undelivered Messages

# CloudWatch alarm on DLQ depth
aws cloudwatch put-metric-alarm \
  --alarm-name "SNS-DLQ-Messages" \
  --metric-name "ApproximateNumberOfMessagesVisible" \
  --namespace "AWS/SQS" \
  --dimensions Name=QueueName,Value=sns-notifications-dlq \
  --statistic Sum \
  --period 60 \
  --threshold 1 \
  --comparison-operator GreaterThanOrEqualToThreshold \
  --evaluation-periods 1 \
  --alarm-actions arn:aws:sns:us-east-1:123456789:ops-alerts \
  --treat-missing-data notBreaching
DLQ Replay: After fixing the root cause (endpoint outage, Lambda bug), replay messages from the DLQ by moving them back to the source topic using the SQS console "Start DLQ Redrive" feature or the start-message-move-task API. Set a manageable replay rate to avoid overwhelming the downstream subscriber.

Cost Optimization

SNS costs are low but add up at scale. Here's a breakdown of the main cost drivers:

ChannelCostNotes
API requests (publish)$0.50 per millionEach publish = 1 request regardless of subscribers
HTTP/SQS/Lambda deliveries$0.50 per millionEach delivery to each subscriber counts
Mobile push (free tier)1 million free/monthFirst 1M push deliveries free per month
SMS — US$0.00645 per messageTransactional
SMS — India~$0.0023 per messageVaries by carrier
SMS — UK~$0.0424 per messageHigher in Western Europe
Email$2 per 100,000Only SNS charges — no SES costs for SNS-sent email
Firehose deliveries$0.19 per GBFirehose itself has separate charges

Key cost optimization strategies:

  • Use subscription filtering to avoid delivering irrelevant messages. Without filtering, you pay per delivery to every subscriber even if most ignore the message.
  • Batch SMS with message compression — a single 160-character SMS counts as one message; messages over 160 chars are split into segments and billed per segment. Keep transactional SMS under 160 chars.
  • Fan-out to SQS, not Lambda directly — SNS to Lambda invocations at scale are billed per Lambda invocation. Routing through SQS allows batching (one Lambda invocation processes 10 messages) reducing Lambda costs by 10x.
  • Disable delivery status logging for low-risk topics — 100% sample rate logging generates significant CloudWatch Logs ingestion costs. Use 5-10% sample rate for high-volume topics where you only need statistical visibility.
  • Clean up disabled mobile endpoints — disabled endpoints still count in your endpoint quota and incur minimal management overhead. Use SNS's list-endpoints-by-platform-application to find and delete endpoints with Enabled=false.
import boto3

sns = boto3.client('sns', region_name='us-east-1')

def cleanup_disabled_endpoints(platform_app_arn: str) -> int:
    """Remove disabled (stale) endpoints from a Platform Application."""
    paginator = sns.get_paginator('list_endpoints_by_platform_application')
    deleted = 0
    for page in paginator.paginate(PlatformApplicationArn=platform_app_arn):
        for endpoint in page['Endpoints']:
            if endpoint['Attributes'].get('Enabled') == 'false':
                sns.delete_endpoint(EndpointArn=endpoint['EndpointArn'])
                deleted += 1
    print(f"Deleted {deleted} disabled endpoints from {platform_app_arn}")
    return deleted

Related Articles