Deliver user-specific notifications on iOS with AWS SNS


link1. Introduction

AWS's SNS is very versatile and generally a good starting point for you when you need to send push notifications to your users. The free tier gives you one million notifications free per month and is relatively cheap when scaling up. One thing that has bugged me, though, was the lack of good guidance to quickly get something running. That's why I'm writing this posts, so you don't have to deal with clicking through more than two pages of Google search results.

link2. What my project consists of

For this blog post, I'm using a combination of express, Postgres and TypeORM for my backend. My starter template for express with TypeScript is still holding up well for small projects and TypeORM is easily integrated as well.

The iOS app is going to be a simple SwiftUI app, but this guide is mostly architecture-agnostic.

link3. Setting up SNS

If you haven't done this already, you need to sign up for an AWS account. Inside the management console, you need to search for SNS.

There, you'll need to create a new platform application. Look under mobile and click create. Now, the tricky stuff happens (well, actually, you'll probably get used to it).

link3.1. Creating the push certificate

Inside Certificates, Identifiers & Profiles, add a new certificate. Select Apple Push Notification service SSL (Sandbox & Production). This way, you don't need to create a new certificate for production. Continue and choose the right App ID. After that, you need to create a Certificate Signing Request (CSR). See Apple's guide on how to do it. After that, you should be able to download your new push certificate.

Install the new certificate and open Keychain Access. Go to Keys and look for the name you've given your CSR. Expand the private key, select both the private key and the new push certificate, right click with both selected and export them in .p12 format.

Back inside the AWS management console, upload the .p12 certificate, apply it and create the platform application.

Note down the ARN of the new endpoint.

link3.2. Creating an IAM user

To use SNS in your backend, you need to create a new user under IAM as well. It's good to keep users coupled to what they're good for, so we're going to create a user just for our SNS purposes.

Inside the AWS management console, search for IAM, go to users and click add user. Choose a name you like, tick programmatic access. For permissions, choose Attach existing policies directly and search for SNS. Choose AmazonSNSFullAccess. Skip the tags and create the new user.

Download the credentials as CSV and note down both the access key ID as well as the secret access key.

link4. Backend

So, now it's time for the backend! I'm assuming you have set up express with some authorization system and TypeORM (or any other database access layer).

link4.1. Routes

For most basic purposes, we only need two routes: one to send the device token to SNS and another one to test our notifications later.

Inside where you add your routes, create the following routes:

routes.ts
1router.post('/register', authorize(), async (req, res) => { // --> Your authorize() method should probably add some user object to req

2 if (req.body.deviceToken) {

3 try {

4 const subscription = await notificationService.subscribeNotifications(req.user, req.body.deviceToken); // --> We'll write this later

5

6 return res.sendStatus(200);

7 } catch (error) {

8 console.error(error);

9

10 return res.status(500).send({ code: 'REGISTRATION_ERROR', message: error.message });

11 }

12 }

13});

This route will register the device with the supplied deviceToken with SNS. We'll write the notificationService.subscribeNotifications later!

routes.ts
1router.get('/test/:userId', async (req, res) => {

2 await notificationService.notifyUser(parseInt(req.params.userId, 10)); // --> of course, we'll also take a look at this function later

3

4 res.send({ message: 'ok' });

5});

For obvious reasons, you should throw out this function once your code runs on production, but it's a good function to test your notification sending code.

link4.2. Notification Service (Backend)

It's much less a service and more just a collection of functions to help us with registering for notifications.

First, we need to set up our connection with AWS. I recommend storing all your secrets and configuration in some config object, which loads them from environment variables. I will give you an example in another blog post sometime.

notificationService.ts
1const credentials = new AWS.Credentials({ accessKeyId: config.sns.accessKey, secretAccessKey: config.sns.secretAccessKey }); // --> the accessKey and secretAccessKey are the ones you've noted down before when creating the IMS user

2

3AWS.config.update({credentials, region: config.sns.region }); // --> this is the region you've created your SNS stuff in

4

5const sns = new AWS.SNS();

Let's take a look at this function: subscribeNotifications(user: RequestUser, deviceToken: string)

notificationService.ts
1async function subscribeNotifications(user: RequestUser, deviceToken: string) {

2 const userTopicName = 'user-topic' + user.id; // --> we'll use the topic's name later to identify the user

3

4 const topic = await sns.createTopic({ Name: userTopicName }).promise();

5

6 // first, we created the topic

7 if (topic.TopicArn) {

8 const userEndpointAttributes = 'user-endpoint-' + user.id;

9

10 // then, we create an endpoint where the device token will subscribe to

11 const endpoint = await sns.createPlatformEndpoint({ PlatformApplicationArn: config.sns.arn, Token: deviceToken }).promise(); // --> the endpoint tells SNS "who" to reach, in our case a device specified by the deviceToken

12

13 if (endpoint.EndpointArn) {

14 const subscription = await sns.subscribe({ TopicArn: topic.TopicArn, Endpoint: endpoint.EndpointArn, Protocol: 'application' }).promise(); // --> the subscription basically connects our topic with the "client"

15

16 if (subscription.SubscriptionArn) {

17 const notificationSubscription = await saveToDatabase(user, userTopicName, topic.TopicArn); // --> we'll take a look at this later

18

19 return notificationSubscription; // --> we don't necessarily need to return this to our client in the end. But if we ever want to give the user the ability to turn off notifications (without turning them off by removing permissions), we could store this somewhere inside the client to make it a bit easier

20 }

21 throw new Error('Could not create subscription');

22 } else {

23 throw new Error('Could not create endpoint');

24 }

25 } else {

26 throw new Error('Could not create topic');

27 }

28}

Why are we saving the references to the database? It's easier to show you when we take a look at our test function:

notificationService.ts
1function makeMessage(title: string, message: string) {

2 const apsContent = JSON.stringify( // --> It's important to stringify the APS payload, as it's invalid syntax otherwise

3 {

4 aps: {

5 alert: {

6 title,

7 body: message

8 }

9 }

10 });

11

12 return {

13 'APNS_SANDBOX': apsContent, // --> APNS_SANDBOX is important if you're debugging in non-production environments, because otherwise you wouldn't receive any notifications. Trust me, it wasted an entire hour to figure out why my notifications wouldn't get delivered.

14 'APNS': apsContent,

15 'default': `${title}: ${message}`

16 };

17}

18

19async function notifyUser(userId: number) {

20 const subscription = await getSubscriptionFromDatabase(userId); // --> here, you should fetch our stored SNS topic/references by specifically getting the one for the user

21

22 if (subscription) {

23 const message = makeMessage('hello', 'hello from tims.coding.blog');

24 const success = await sns.publish({ Message: JSON.stringify(message), MessageStructure: 'json', TopicArn: subscription.topicArn }).promise(); // --> the topicArn is used to identify our user here

25

26 if (success.MessageId) {

27 console.log('notified user with message id ' + success.MessageId);

28 } else {

29 console.log('could not notify user');

30 }

31 }

32

33 // you should probably provide some fallback or so here

34}

You may ask why I'm not just directly publishing to the endpoint. My reasoning for using the topic instead is that in many usecases you don't just want to reach one of the user's devices, but many instead. Then, we might have two endpoints because the user is logged in on both their iPad as well as their iPhone and they are able to receive the same notification on both devices. In that case, you'd need to alter the code where we create the topic, but that should be a minor step once everything else is done.

link5. iOS App

For the iOS app, I'm assuming you have set up some sort of authentication services and integrated them inside your request pipeline. If you need some guides on how to build a simple API access layer, I recommend this blog post.

Inside the AppDelegate, we'll add a new function called setupNotifications:

AppDelegate.swift
1extension AppDelegate {

2 func setupNotifications(application: UIApplication) {

3 if serviceRegistry.authService.isAuthorized() { // --> or whatever you use to check if the user is signed in

4 let center = UNUserNotificationCenter.current()

5 center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in

6

7 guard error != nil {

8 print(error)

9 return;

10 }

11

12 print("successfully requested push")

13

14 DispatchQueue.main.async { // --> authorization is requested on a background thread, and we need to run registerForRemoteNotifications on the main thread

15 application.registerForRemoteNotifications()

16 }

17 }

18 }

19 }

20

21 func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {

22 print("registered for remote notifications")

23 let token = deviceToken.reduce("", {$0 + String(format: "%02X", $1)}) // --> our backend requires a string, so we need to "convert" Data into a string

24

25 serviceRegistry.notificationService.subscribeNotifications(deviceToken: token) { (success) in // --> this is where you call our backend API route with the deviceToken

26 print(success ? "successfully subscribed to notifications" : "could not subscribe to notifications")

27 }

28 }

29}

Now, we need to call this function when the app starts up. If you need to register somewhere else (after user onboarding, for example), you could access the application instance as described here.

AppDelegate.swift
1func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

2 // Override point for customization after application launch.

3 self.setupNotifications(application: application)

4

5 return true

6}

That's it! When you call our test route with the right user id, a notification should show up on your device. It should be a real device, though, as push notifications are not supported inside the Simulator.


Thank you for reading this post! Feel free to reach out to me on hey@timweiss.net! I'd love to hear what you think of this post!

1. Introduction2. What my project consists of3. Setting up SNS3.1. Creating the push certificate3.2. Creating an IAM user4. Backend4.1. Routes4.2. Notification Service (Backend)5. iOS App

Home

Swift Stuffchevron_right
Cloud Computingchevron_right