DEV Community

Marcus Chan
Marcus Chan

Posted on • Originally published at marcuscjh.Medium on

Sending Apple Push Notifications via AWS SNS

How I Send iOS Push Notifications Through SNS (and Why It Needs a Custom Resource)

If you’ve tried setting up iOS push notifications through AWS SNS, you’ve probably hit the same wall I did. CloudFormation has no native resource for an SNS Platform Application, and the AWS docs quietly assume you’ll click through the console to create one. I didn’t want to. Here’s how I kept the whole thing in CDK, credential rotation included.

TL;DR

  1. Get Apple APNs credentials from Apple Developer: .p8 key, key_id, team_id, and bundle_id. Store them in SSM Parameter Store so the backend reads them at deploy time, not from code.
  2. Create an SNS topic and an SNS Platform Application for APNs. CloudFormation has no native resource for the platform application, so use a Lambda-backed custom resource to create and update it.
  3. Use a Lambda to manage device tokens : clients send a device token to your API; the Lambda creates a platform endpoint in SNS and subscribes it to the topic. On deregister: disable the endpoint and unsubscribe.
  4. Publish to the topic from any Lambda : send an APNS / APNS_SANDBOX JSON payload and SNS fans out to all subscribed endpoints.

Overview

I send iOS push notifications for case updates, status changes, and field actions. The events originate in backend Lambdas that have no direct way to reach a device, so I route them through SNS.

The backend has four parts:

  1. CDK : SNS topic + APNs platform application (via a custom resource). Credentials in SSM.
  2. Custom Resource Lambda : CDK/CloudFormation has no native SNS Platform Application resource, so I use a Lambda to create or update it from SSM. This also lets us rotate keys without replacing the resource.
  3. Notification Manager Lambda : GraphQL mutations registerDeviceToken / deregisterDeviceToken create or find a platform endpoint, then subscribe or unsubscribe it to the topic.
  4. Publisher Lambdas : Publish to the topic with a JSON message (default, APNS, APNS_SANDBOX). SNS delivers to subscribed endpoints, then to APNs, then to devices.

1. Credentials in SSM

Before anything else, store four values from your Apple Developer account in SSM Parameter Store as SecureString parameters:

  • .p8 key (the private key file contents)
  • Key ID
  • Team ID
  • Bundle ID

The custom resource Lambda reads these at deploy time. Nothing lives in code or environment variables. Make sure the Lambda’s IAM role has ssm:GetParameter on those specific parameter paths.

2. SNS Platform Application (Custom Resource)

Why a custom resource?

CDK/CloudFormation has no native resource for an SNS Platform Application. You simply cannot declare one as a plain CDK construct; it doesn’t exist in the L2 or L1 construct library.

Rather than managing the platform application outside of CDK (clicking through the console or running a one-off CLI command), I keep it in CDK with the rest of the stack using a Lambda-backed Custom Resource. The Lambda calls the SNS API directly on CloudFormation’s behalf:

  • CreatePlatformApplication
  • SetPlatformApplicationAttributes
  • DeletePlatformApplication

We keep the handler in a custom_resources/ folder next to our other CDK constructs (e.g. sns_platform_applications.py), and the SNS construct file wires it in as the custom resource provider.

Keeping it in CDK means credential rotation is just a code change: update the value in SSM, bump the RefreshToken property in the CDK construct, and redeploy. CloudFormation compares property values on each deploy, so changing RefreshToken is enough to trigger the Lambda to re-read SSM and update the platform application in place without replacing it.

  • Create/Update: Read params from SSM, call list_platform_applications() to find an existing app whose ARN ends with /APNS/{name}, then update it or create it.
  • Delete: Delete by stored ARN.

Read from SSM, build attributes, then update or create:

# sns_platform_applications.py
p8_key = ssm.get_parameter(Name=props['P8KeyParameter'], WithDecryption=True)['Parameter']['Value']
key_id = ssm.get_parameter(Name=props['KeyIdParameter'], WithDecryption=True)['Parameter']['Value']
team_id = ssm.get_parameter(Name=props['TeamIdParameter'], WithDecryption=True)['Parameter']['Value']
bundle_id = ssm.get_parameter(Name=props['BundleIdParameter'], WithDecryption=True)['Parameter']['Value']

attributes = {
    'PlatformPrincipal': key_id,
    'PlatformCredential': p8_key,
    'ApplePlatformTeamID': team_id,
    'ApplePlatformBundleID': bundle_id
}

if existing_arn:
    sns.set_platform_application_attributes(
        PlatformApplicationArn=existing_arn,
        Attributes=attributes
    )
else:
    response = sns.create_platform_application(
        Name=app_name,
        Platform='APNS',
        Attributes=attributes
    )
Enter fullscreen mode Exit fullscreen mode

3. Topic and CDK Wiring

One SNS topic receives all notification publishes. I don’t subscribe users directly to the topic. Instead, I create platform endpoints (one per device token) under the APNs platform application, and subscribe those endpoints to the topic.

Flow: Publisher → Topic → SNS fan-out to endpoints → APNs → device

Three CDK constructs wire this together:

  • sns.py: creates the topic and the custom resource for the platform application. Exports the topic ARN and platform application ARN as environment variables for the Lambdas.
  • notification_manager.py: creates the Notification Manager Lambda, passes in PLATFORM_APPLICATION_ARN and NOTIFICATION_TOPIC_ARN, and wires registerDeviceToken / deregisterDeviceToken to AppSync resolvers.
  • Publishers : any Lambda that needs to send a notification reads NOTIFICATION_TOPIC_ARN from its environment and calls sns.publish. No SNS-specific setup needed on the publisher side.
# sns.py (simplified)
self.notification_topic = aws_sns.Topic(self, "PushNotificationTopic", topic_name=topic_name)

# Wrap the custom resource handler Lambda in a Provider so CDK can invoke it
# during stack create/update/delete events.
provider = cr.Provider(
    self, "APNSPlatformAppProvider",
    on_event_handler=apns_platform_handler_lambda,
)

platform_app = CustomResource(
    self, "APNSPlatformApp",
    service_token=provider.service_token,
    properties={
        "ApplicationName": f"{topic_name}-apns",
        "P8KeyParameter": apns_params.p8_key_path_param,
        "KeyIdParameter": apns_params.key_id_param,
        "TeamIdParameter": apns_params.team_id_param,
        "BundleIdParameter": apns_params.bundle_id_param,
        # Bump this string (date format is arbitrary) to force a Custom Resource update,
        # e.g. after rotating credentials in SSM.
        "RefreshToken": "2025-09-22-09",
    },
    # Without this, deleting the stack leaves the SNS Platform Application orphaned.
    removal_policy=RemovalPolicy.DESTROY,
)
Enter fullscreen mode Exit fullscreen mode

4. Notification Manager: Register / Deregister

registerDeviceToken(device_token)

  • Get the user id from AppSync context. It flows from the Cognito JWT, which is how user_id lands in CustomUserData.
  • Paginate through list_endpoints_by_platform_application and match by CustomUserData.device_token. SNS has no API to look up an endpoint by token directly, so pagination is the only way. At very large scale (tens of thousands of endpoints), you'd want to cache device_token → endpoint_arn in DynamoDB. For our footprint, pagination is fine.
  • If one exists, re-enable it (Enabled=true) and update its ownership, then ensure it is subscribed to the topic. SNS auto-disables endpoints when APNs rejects a delivery (uninstalled app, expired token, etc.), so re-enabling on re-registration is the normal recovery path.
  • If not, create a new endpoint and subscribe it to the topic. Subscription failures are logged but do not fail the registration. The token is still stored and the device can receive notifications once the subscription is retried.

A note on token rotation: APNs device tokens can change. When they do, the old endpoint becomes effectively dead, and this flow creates a new endpoint for the new token. Cleaning up orphaned endpoints is a separate concern (I handle it with a periodic sweep), but it’s out of scope for this article.

deregisterDeviceToken(device_token)

  • Find the endpoint by paginating through platform endpoints and matching CustomUserData.device_token. Return an error if not found.
  • Call SetEndpointAttributes with Enabled=false.
  • Paginate through the topic’s subscriptions, find the one matching the endpoint ARN, and unsubscribe it.
# Notification Manager lambda_function.py
existing_endpoint = find_existing_endpoint(device_token) # paginates list_endpoints_by_platform_application

if existing_endpoint:
    # Re-enable and re-subscribe. SNS may have disabled it after a delivery failure.
    sns_client.set_endpoint_attributes(
        EndpointArn=existing_endpoint,
        Attributes={"Enabled": "true", "CustomUserData": json.dumps({"user_id": user_id, "device_token": device_token})}
    )
    endpoint_arn = existing_endpoint
else:
    # Store device_token in CustomUserData so we can find this endpoint later.
    # SNS doesn't expose the raw Token in list responses, only CustomUserData.
    response = sns_client.create_platform_endpoint(
        PlatformApplicationArn=PLATFORM_APPLICATION_ARN,
        Token=device_token,
        CustomUserData=json.dumps({"user_id": user_id, "device_token": device_token})
    )
    endpoint_arn = response.get('EndpointArn')

sns_client.subscribe(
    TopicArn=NOTIFICATION_TOPIC_ARN,
    Protocol='application',
    Endpoint=endpoint_arn
)
Enter fullscreen mode Exit fullscreen mode

5. Publisher: Sending a Notification

The publisher reads NOTIFICATION_TOPIC_ARN from the environment and calls sns.publish with MessageStructure='json'.

One thing worth clarifying: APNS vs APNS_SANDBOX is a property of the platform application , not the message. An endpoint inherits its mode from the platform application it was created under. Including both keys in the message body only matters if you have separate sandbox and production platform applications and want one publisher to feed both. If you only have a production platform application, the APNS_SANDBOX key is ignored. I keep both in the payload so the same publisher code works across environments.

# Your publisher Lambda, for example publish_notification()
apns_payload = {
    "aps": {
        "alert": {"title": "Your App", "body": message_text},
        "sound": "default",
        "badge": 1
    },
    "data": {"entity_id": entity_id, "type": notification_type} # custom data for your app
}

message = {
    "default": message_text,
    "APNS": json.dumps(apns_payload),
    "APNS_SANDBOX": json.dumps(apns_payload)
}

sns_client.publish(
    TopicArn=NOTIFICATION_TOPIC_ARN,
    Message=json.dumps(message),
    MessageStructure='json',
    MessageAttributes={
        "notification_type": {"DataType": "String", "StringValue": notification_type},
        "entity_id": {"DataType": "String", "StringValue": entity_id},
    },
)
Enter fullscreen mode Exit fullscreen mode

Wrap up

Four moving parts: one SNS topic, one APNs platform application (managed by a custom resource), one Notification Manager Lambda, and any number of publishers.

The biggest time sink wasn’t the SNS or APNs side. It was realizing the custom resource was the only viable path to keep the platform application in CDK at all. Once that clicked, the rest was wiring.

If you have a better way to do it, I’d be curious to hear about it.

Top comments (0)