<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Marcus Chan</title>
    <description>The latest articles on Forem by Marcus Chan (@marcuscjh).</description>
    <link>https://forem.com/marcuscjh</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2533285%2F3bd43a25-6e32-4af3-ae30-a952de9a5caf.jpeg</url>
      <title>Forem: Marcus Chan</title>
      <link>https://forem.com/marcuscjh</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://practicaldev.herokuapp.com/feed/marcuscjh"/>
    <language>en</language>
    <item>
      <title>Sending Apple Push Notifications via AWS SNS</title>
      <dc:creator>Marcus Chan</dc:creator>
      <pubDate>Thu, 21 May 2026 10:46:40 +0000</pubDate>
      <link>https://forem.com/marcuscjh/sending-apple-push-notifications-via-aws-sns-24ia</link>
      <guid>https://forem.com/marcuscjh/sending-apple-push-notifications-via-aws-sns-24ia</guid>
      <description>&lt;h3&gt;
  
  
  How I Send iOS Push Notifications Through SNS (and Why It Needs a Custom Resource)
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;TL;DR&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Get Apple APNs credentials&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Create an SNS topic and an SNS Platform Application&lt;/strong&gt; for APNs. CloudFormation has no native resource for the platform application, so use a Lambda-backed custom resource to create and update it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a Lambda to manage device tokens&lt;/strong&gt; : 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publish to the topic from any Lambda&lt;/strong&gt; : send an APNS / APNS_SANDBOX JSON payload and SNS fans out to all subscribed endpoints.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Overview
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The backend has four parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;CDK&lt;/strong&gt; : SNS topic + APNs platform application (via a custom resource). Credentials in SSM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom Resource Lambda&lt;/strong&gt; : 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Notification Manager Lambda&lt;/strong&gt; : GraphQL mutations registerDeviceToken / deregisterDeviceToken create or find a platform endpoint, then subscribe or unsubscribe it to the topic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publisher Lambdas&lt;/strong&gt; : Publish to the topic with a JSON message (default, APNS, APNS_SANDBOX). SNS delivers to subscribed endpoints, then to APNs, then to devices.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhxn1qr5l6g228jy34ye4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhxn1qr5l6g228jy34ye4.png" width="760" height="244"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Credentials in SSM
&lt;/h3&gt;

&lt;p&gt;Before anything else, store four values from your Apple Developer account in SSM Parameter Store as SecureString parameters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;.p8 key (the private key file contents)&lt;/li&gt;
&lt;li&gt;Key ID&lt;/li&gt;
&lt;li&gt;Team ID&lt;/li&gt;
&lt;li&gt;Bundle ID&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. SNS Platform Application (Custom Resource)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Why a custom resource?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;strong&gt;Lambda-backed Custom Resource&lt;/strong&gt;. The Lambda calls the SNS API directly on CloudFormation’s behalf:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CreatePlatformApplication&lt;/li&gt;
&lt;li&gt;SetPlatformApplicationAttributes&lt;/li&gt;
&lt;li&gt;DeletePlatformApplication&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Create/Update:&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delete:&lt;/strong&gt; Delete by stored ARN.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Read from SSM, build attributes, then update or create:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# sns_platform_applications.py
&lt;/span&gt;&lt;span class="n"&gt;p8_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ssm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_parameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;P8KeyParameter&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;WithDecryption&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Parameter&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Value&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;key_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ssm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_parameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;KeyIdParameter&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;WithDecryption&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Parameter&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Value&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;team_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ssm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_parameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;TeamIdParameter&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;WithDecryption&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Parameter&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Value&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;bundle_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ssm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_parameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;BundleIdParameter&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;WithDecryption&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Parameter&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Value&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;attributes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;PlatformPrincipal&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;key_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;PlatformCredential&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;p8_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ApplePlatformTeamID&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;team_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ApplePlatformBundleID&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;bundle_id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;existing_arn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;sns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_platform_application_attributes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;PlatformApplicationArn&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;existing_arn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Attributes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_platform_application&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;app_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Platform&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;APNS&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Attributes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Topic and CDK Wiring
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Flow: &lt;strong&gt;Publisher → Topic → SNS fan-out to endpoints → APNs → device&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Three CDK constructs wire this together:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;notification_manager.py: creates the Notification Manager Lambda, passes in PLATFORM_APPLICATION_ARN and NOTIFICATION_TOPIC_ARN, and wires registerDeviceToken / deregisterDeviceToken to AppSync resolvers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publishers&lt;/strong&gt; : 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.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# sns.py (simplified)
&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;notification_topic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;aws_sns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Topic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PushNotificationTopic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;topic_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;topic_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Wrap the custom resource handler Lambda in a Provider so CDK can invoke it
# during stack create/update/delete events.
&lt;/span&gt;&lt;span class="n"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Provider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;APNSPlatformAppProvider&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;on_event_handler&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;apns_platform_handler_lambda&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;platform_app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;CustomResource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;APNSPlatformApp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;service_token&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;service_token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ApplicationName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;topic_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;-apns&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;P8KeyParameter&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;apns_params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;p8_key_path_param&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;KeyIdParameter&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;apns_params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key_id_param&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TeamIdParameter&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;apns_params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;team_id_param&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BundleIdParameter&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;apns_params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bundle_id_param&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="c1"&gt;# Bump this string (date format is arbitrary) to force a Custom Resource update,
&lt;/span&gt;        &lt;span class="c1"&gt;# e.g. after rotating credentials in SSM.
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RefreshToken&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2025-09-22-09&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="c1"&gt;# Without this, deleting the stack leaves the SNS Platform Application orphaned.
&lt;/span&gt;    &lt;span class="n"&gt;removal_policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;RemovalPolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DESTROY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Notification Manager: Register / Deregister
&lt;/h3&gt;

&lt;h4&gt;
  
  
  registerDeviceToken(device_token)
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;Get the user id from AppSync context. It flows from the Cognito JWT, which is how user_id lands in CustomUserData.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h4&gt;
  
  
  deregisterDeviceToken(device_token)
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;Find the endpoint by paginating through platform endpoints and matching CustomUserData.device_token. Return an error if not found.&lt;/li&gt;
&lt;li&gt;Call SetEndpointAttributes with Enabled=false.&lt;/li&gt;
&lt;li&gt;Paginate through the topic’s subscriptions, find the one matching the endpoint ARN, and unsubscribe it.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Notification Manager lambda_function.py
&lt;/span&gt;&lt;span class="n"&gt;existing_endpoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;find_existing_endpoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;device_token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# paginates list_endpoints_by_platform_application
&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;existing_endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Re-enable and re-subscribe. SNS may have disabled it after a delivery failure.
&lt;/span&gt;    &lt;span class="n"&gt;sns_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_endpoint_attributes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;EndpointArn&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;existing_endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Attributes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Enabled&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CustomUserData&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;device_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;device_token&lt;/span&gt;&lt;span class="p"&gt;})}&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;endpoint_arn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;existing_endpoint&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Store device_token in CustomUserData so we can find this endpoint later.
&lt;/span&gt;    &lt;span class="c1"&gt;# SNS doesn't expose the raw Token in list responses, only CustomUserData.
&lt;/span&gt;    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sns_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_platform_endpoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;PlatformApplicationArn&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;PLATFORM_APPLICATION_ARN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Token&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;device_token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;CustomUserData&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;device_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;device_token&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;endpoint_arn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;EndpointArn&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;sns_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;TopicArn&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;NOTIFICATION_TOPIC_ARN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Protocol&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;application&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Endpoint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;endpoint_arn&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  5. Publisher: Sending a Notification
&lt;/h3&gt;

&lt;p&gt;The publisher reads NOTIFICATION_TOPIC_ARN from the environment and calls sns.publish with MessageStructure='json'.&lt;/p&gt;

&lt;p&gt;One thing worth clarifying: APNS vs APNS_SANDBOX is a property of the &lt;strong&gt;platform application&lt;/strong&gt; , 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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Your publisher Lambda, for example publish_notification()
&lt;/span&gt;&lt;span class="n"&gt;apns_payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aps&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alert&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Your App&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;message_text&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sound&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;default&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;badge&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;entity_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;entity_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;notification_type&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;# custom data for your app
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;default&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;message_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;APNS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;apns_payload&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;APNS_SANDBOX&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;apns_payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;sns_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;TopicArn&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;NOTIFICATION_TOPIC_ARN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;MessageStructure&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;json&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;MessageAttributes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;notification_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DataType&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;String&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;StringValue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;notification_type&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;entity_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DataType&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;String&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;StringValue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;entity_id&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Wrap up
&lt;/h3&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;If you have a better way to do it, I’d be curious to hear about it.&lt;/p&gt;

</description>
      <category>pushnotification</category>
      <category>ios</category>
      <category>softwaredevelopment</category>
      <category>aws</category>
    </item>
    <item>
      <title>How I Rebuilt My Portfolio with a Little Help from AI</title>
      <dc:creator>Marcus Chan</dc:creator>
      <pubDate>Fri, 23 Jan 2026 13:00:52 +0000</pubDate>
      <link>https://forem.com/marcuscjh/how-i-rebuilt-my-portfolio-with-a-little-help-from-ai-3k12</link>
      <guid>https://forem.com/marcuscjh/how-i-rebuilt-my-portfolio-with-a-little-help-from-ai-3k12</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;What started as a messy side project is now a smarter, easier to maintain site, thanks to a configuration-driven approach and modern dev tools.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The Messy Start
&lt;/h3&gt;

&lt;p&gt;Like many developers, my portfolio started as a quick weekend project. It was built with plain HTML, CSS, and JavaScript, functional but outdated, hard to maintain, and definitely not something I was proud of.&lt;/p&gt;

&lt;p&gt;Every small update meant digging through multiple files, tweaking styles, and hoping nothing broke. So the project sat untouched for months… until I decided to finish it properly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2ez7j0mb0gll3kqst6r0.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2ez7j0mb0gll3kqst6r0.gif" width="720" height="491"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Before&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Cleaning It Up with AI
&lt;/h3&gt;

&lt;p&gt;The first step was cleaning up the mess. This time, I used &lt;strong&gt;AI as a coding partner&lt;/strong&gt; to help with repetitive work like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Refactoring messy code&lt;/li&gt;
&lt;li&gt;Organizing files properly&lt;/li&gt;
&lt;li&gt;Making CSS more consistent&lt;/li&gt;
&lt;li&gt;Adding basic JavaScript structure&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tasks I had procrastinated for weeks were done in days. AI didn’t build the project for me, it simply accelerated the boring parts so I could focus on structure and decisions.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6nl8k1f5l94je17l4k0i.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6nl8k1f5l94je17l4k0i.gif" width="480" height="327"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;After&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Making It Configuration-Driven
&lt;/h3&gt;

&lt;p&gt;Next, I tackled the real problem: content was scattered everywhere. Adding a new project or skill meant editing multiple HTML files manually.&lt;/p&gt;

&lt;p&gt;My solution was simple: create a single data.json file to store all the content. This one file now controls:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Bio and personal info&lt;/li&gt;
&lt;li&gt;Project showcases&lt;/li&gt;
&lt;li&gt;Timeline (work, education, certifications)&lt;/li&gt;
&lt;li&gt;Skills and social links
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"_comment"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"This file controls all content and navigation for the portfolio website."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"config"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"real_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"subtitle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"summary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"website"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"typedMessages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"cvDriveId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"social"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"platform"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"icon"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"showcase"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"backgroundImage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"technologies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"modalContent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"links"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"skills"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"languages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"cloud"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"web"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"devops"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"databases"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ai"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timeline"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"order"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"startDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"endDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"company"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"modalContent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"navigation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"icon"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, updating the site means editing one file. Add a new project or certification to data.json, and the site updates automatically, no HTML editing required.&lt;/p&gt;

&lt;h3&gt;
  
  
  Upgrading the Tooling
&lt;/h3&gt;

&lt;p&gt;With the structure in place, I modernized the build process using  &lt;strong&gt;Vite&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The migration was smooth and made development much more enjoyable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Automating the Workflow
&lt;/h3&gt;

&lt;p&gt;To finish things off, I added &lt;strong&gt;CI/CD&lt;/strong&gt; with GitHub Actions. Now every change follows this flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Update data.json&lt;/li&gt;
&lt;li&gt;Commit and push&lt;/li&gt;
&lt;li&gt;Tests run automatically, the site builds, and it deploys&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No more manual steps, and no more deployment headaches.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsah2n085ti9h5a8p849i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsah2n085ti9h5a8p849i.png" width="800" height="252"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Github Actions&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What I Learned
&lt;/h3&gt;

&lt;p&gt;✅ &lt;strong&gt;Centralize your content.&lt;/strong&gt; A single JSON source of truth makes updates simple and scalable.&lt;/p&gt;

&lt;p&gt;✅ &lt;strong&gt;AI is a helper, not a replacement.&lt;/strong&gt; It speeds up development and removes friction.&lt;br&gt;&lt;br&gt;
✅ &lt;strong&gt;Modern tools matter.&lt;/strong&gt; Vite, TypeScript, and CI/CD make even small projects more maintainable.&lt;br&gt;&lt;br&gt;
✅ &lt;strong&gt;Automation saves time.&lt;/strong&gt; Build, test, and deploy steps should run without effort.&lt;/p&gt;

&lt;p&gt;Connect with me on &lt;a href="https://www.linkedin.com/in/marcuschanjh" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;| &lt;a href="https://github.com/MarcusCJH" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;| &lt;a href="http://www.marcuscjh.com" rel="noopener noreferrer"&gt;Portfolio&lt;/a&gt;&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>automation</category>
      <category>webdev</category>
      <category>portfolio</category>
    </item>
    <item>
      <title>Built a Game in 2 Hours with Amazon Q</title>
      <dc:creator>Marcus Chan</dc:creator>
      <pubDate>Thu, 19 Jun 2025 15:35:21 +0000</pubDate>
      <link>https://forem.com/marcuscjh/built-a-game-in-2-hours-with-amazon-q-2o2d</link>
      <guid>https://forem.com/marcuscjh/built-a-game-in-2-hours-with-amazon-q-2o2d</guid>
      <description>&lt;h3&gt;
  
  
  🧠 The Idea
&lt;/h3&gt;

&lt;p&gt;So I was walking around AWS Summit Singapore and saw this poster:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fb50c9kvmqzl5x889bftb.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fb50c9kvmqzl5x889bftb.jpeg" width="800" height="1066"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;em&gt;“Build Games with Amazon Q CLI”&lt;/em&gt;&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Score a T-shirt.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I didn’t go there planning to build a game. But I &lt;em&gt;was&lt;/em&gt; curious what Amazon Q CLI could do. And yeah, the swag sounded fun.&lt;/p&gt;

&lt;p&gt;So I gave myself a quick challenge:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;⏱ Max 2 hours&lt;/li&gt;
&lt;li&gt;🧠 Let Q CLI do most of the work&lt;/li&gt;
&lt;li&gt;🎮 End up with something that runs&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  🧪 My Iteration Journey (with Prompts I gave Q)
&lt;/h3&gt;

&lt;h4&gt;
  
  
  🎮 Getting the First Build Running
&lt;/h4&gt;

&lt;blockquote&gt;
&lt;p&gt;“Build an endless jumper game in Python using pygame.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe1jyrpcxeq3vq59ojrru.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe1jyrpcxeq3vq59ojrru.gif" width="799" height="631"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Q spun up a basic prototype:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Player block&lt;/li&gt;
&lt;li&gt;Green platforms&lt;/li&gt;
&lt;li&gt;Gravity&lt;/li&gt;
&lt;li&gt;Score counter&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It worked! But sometimes… you just drop off the screen instantly. RIP.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fynl67fa0cb6kssapoglt.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fynl67fa0cb6kssapoglt.gif" width="799" height="631"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  ⛔ Bug #1 — Sudden Death
&lt;/h4&gt;

&lt;blockquote&gt;
&lt;p&gt;“Fix a bug where the player sometimes falls immediately when the game starts.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9go90owfv52b1lnut68x.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9go90owfv52b1lnut68x.gif" width="799" height="631"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This worked.&lt;/p&gt;

&lt;p&gt;But there was another issue: once you lost, the game window just closed. No warning, no time to react.&lt;/p&gt;

&lt;h4&gt;
  
  
  💀 Game Over Screen + Reset
&lt;/h4&gt;

&lt;blockquote&gt;
&lt;p&gt;“When the player loses, show a ‘Game Over’ screen and wait for a key press before closing. Also, add a reset feature, pressing ‘R’ should restart the game.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmre65nh1fgfwlhqhk8uo.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmre65nh1fgfwlhqhk8uo.gif" width="799" height="631"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Q delivered:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Game Over screen&lt;/li&gt;
&lt;li&gt;Press ‘R’ to restart&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Super helpful for testing. No more relaunching the app every time I mess up.&lt;/p&gt;

&lt;p&gt;But there was something weird, the platforms were too wide. You literally couldn’t lose. You just bounced forever.&lt;/p&gt;

&lt;h4&gt;
  
  
  📏 Fixing the “Can’t Lose” Bug + Adding Difficulty
&lt;/h4&gt;

&lt;blockquote&gt;
&lt;p&gt;“Fix the bug where when you jump further up, the platform becomes too big — like make some difficulty in the game.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvu8qw106r8x6l63eds1r.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvu8qw106r8x6l63eds1r.gif" width="799" height="631"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With this prompt, Q made the game more challenging:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Narrower platforms&lt;/strong&gt; : You could finally miss a jump and fall&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scaling difficulty&lt;/strong&gt; : Platforms spread apart&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now we’re talking. It finally felt like a game — one you could actually lose.&lt;/p&gt;

&lt;h4&gt;
  
  
  ⚡ Adding Power-Ups
&lt;/h4&gt;

&lt;blockquote&gt;
&lt;p&gt;“Add a random power-up — you decide what it is — and spawn it on some platforms. Enhance the game.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdm91zf2os2zh9xqq0afw.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdm91zf2os2zh9xqq0afw.gif" width="760" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Q gave me power-ups like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Double jump&lt;/li&gt;
&lt;li&gt;Bigger platforms&lt;/li&gt;
&lt;li&gt;Slow motion&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cool stuff — but a new bug appeared: timer kept ticking even after you lost.&lt;/p&gt;

&lt;h4&gt;
  
  
  🛥️ Fixing the Timer Bug
&lt;/h4&gt;

&lt;blockquote&gt;
&lt;p&gt;“Fix the timer — once the game ends or is frozen, the timer should stop.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fup02qpu9shj68vsa9j1t.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fup02qpu9shj68vsa9j1t.gif" width="760" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Clean fix. Timer now pauses properly during freezes and stops on game over. That wrapped it up nicely.&lt;/p&gt;

&lt;h3&gt;
  
  
  🧹 Final Touches
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;“Create a README.md for the project.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Q generated a clean, well-written README file with usage instructions.&lt;/p&gt;

&lt;h3&gt;
  
  
  💡 Final Thoughts
&lt;/h3&gt;

&lt;p&gt;This was a fun, focused experiment. In under 2 hours, I went from nothing to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A playable endless jumper game&lt;/li&gt;
&lt;li&gt;Scaling difficulty and proper fail conditions&lt;/li&gt;
&lt;li&gt;A working power-up system&lt;/li&gt;
&lt;li&gt;Reset and game-over mechanics&lt;/li&gt;
&lt;li&gt;A complete README&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No, I haven’t claimed the T-shirt yet.&lt;br&gt;&lt;br&gt;
But maybe this post will help.&lt;/p&gt;

&lt;p&gt;You can find my repository here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/MarcusCJH/amazon-q-pygame" rel="noopener noreferrer"&gt;https://github.com/MarcusCJH/amazon-q-pygame&lt;/a&gt;&lt;/p&gt;

</description>
      <category>amazonwebservices</category>
      <category>amazonq</category>
      <category>python</category>
      <category>vibecoding</category>
    </item>
    <item>
      <title>My AWS Cloud Certification Was Expiring — Here’s How I Recertified It Through Gamification</title>
      <dc:creator>Marcus Chan</dc:creator>
      <pubDate>Tue, 11 Mar 2025 08:05:53 +0000</pubDate>
      <link>https://forem.com/marcuscjh/my-aws-cloud-certification-was-expiring-heres-how-i-recertified-it-through-gamification-1j3</link>
      <guid>https://forem.com/marcuscjh/my-aws-cloud-certification-was-expiring-heres-how-i-recertified-it-through-gamification-1j3</guid>
      <description>&lt;h3&gt;
  
  
  My AWS Cloud Certification Was Expiring — Here’s How I Recertified It Through Gamification
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzb9y03oj8hacue0w8f68.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzb9y03oj8hacue0w8f68.png" width="800" height="617"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;📩 &lt;strong&gt;“Important: Your AWS Certification expires on Mar 23, 2025”&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxz76eeat6cx03mjwgdgh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxz76eeat6cx03mjwgdgh.png" width="645" height="66"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Wait, what? My &lt;strong&gt;AWS Certified Cloud Practitioner&lt;/strong&gt; credential was about to expire! And, of course, like any responsible tech professional, I had &lt;em&gt;totally&lt;/em&gt; not forgotten about it &lt;em&gt;(okay, maybe just a little)&lt;/em&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The Usual Recertification Route vs. A Gamified Approach&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw7aioob2lsnb2hjaxwag.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw7aioob2lsnb2hjaxwag.png" width="800" height="78"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Initially, I was considering the traditional route: &lt;strong&gt;just take an Associate-level exam and be done with it&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;But then, two things happened at the same time:&lt;/p&gt;

&lt;p&gt;1️⃣ &lt;strong&gt;I saw AWS now offered a gamified recertification process.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
2️⃣ &lt;strong&gt;There was a “Cloud Tournament” happening within my organization.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So I thought… &lt;strong&gt;why not align everything and go all in?&lt;/strong&gt; It was the perfect opportunity to &lt;strong&gt;recertify and compete at the same time!&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How AWS Cloud Quest for Recertification Works&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;✔️ &lt;strong&gt;Stages 1–12:&lt;/strong&gt; These were &lt;strong&gt;interactive and scenario-based&lt;/strong&gt; , covering real AWS concepts with plenty of hands-on learning. Sure, there was a &lt;strong&gt;lot of reading&lt;/strong&gt; , but it was a great way to &lt;strong&gt;refresh my cloud knowledge&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;✔️ &lt;strong&gt;Stage 13 (Final Boss Battle):&lt;/strong&gt; A &lt;strong&gt;Troubleshooting Challenge&lt;/strong&gt; where you analyze and fix an &lt;strong&gt;AWS Cloud Architecture&lt;/strong&gt;. This one definitely required some &lt;strong&gt;AWS experience&lt;/strong&gt; , but it was a solid &lt;strong&gt;practical application&lt;/strong&gt; of everything learned.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8k7218bxyjfo2z64r0qm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8k7218bxyjfo2z64r0qm.png" width="800" height="382"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fo1b68efq9og9ufxrsmla.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fo1b68efq9og9ufxrsmla.png" width="788" height="367"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw5wjeyyao1us0i3qwacw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw5wjeyyao1us0i3qwacw.png" width="800" height="150"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How Long Did It Take?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I’m suggesting completing one stage per day, meaning you could finish in 13 days.&lt;/p&gt;

&lt;p&gt;Me? I did it in &lt;strong&gt;4 hours.&lt;/strong&gt; &lt;em&gt;(Would have been faster if I didn’t stop for snacks.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Plus, since I was already in &lt;strong&gt;“locked in”&lt;/strong&gt; , I had the extra motivation to &lt;strong&gt;speedrun through it!&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Final Thoughts 💭&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;✅ &lt;strong&gt;Was it fun?&lt;/strong&gt; Yes!&lt;br&gt;&lt;br&gt;
✅ &lt;strong&gt;Did it refresh my AWS knowledge?&lt;/strong&gt; Absolutely.&lt;br&gt;&lt;br&gt;
✅ &lt;strong&gt;Would I recommend it?&lt;/strong&gt; If your &lt;strong&gt;AWS Cloud Practitioner cert&lt;/strong&gt; is expiring and you want a &lt;strong&gt;different experience&lt;/strong&gt; from the usual exam, this is a solid option.&lt;/p&gt;

&lt;p&gt;💡 &lt;strong&gt;Bonus:&lt;/strong&gt; It’s &lt;strong&gt;free until the end of July 2025&lt;/strong&gt; , so why not?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try it out here:&lt;/strong&gt; &lt;a href="https://explore.skillbuilder.aws/learn/courses/17623/aws-cloud-quest-recertify-cloud-practitioner" rel="noopener noreferrer"&gt;AWS Cloud Quest: Recertify Cloud Practitioner&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You can connect with me on linkedin: &lt;a href="https://www.linkedin.com/in/marcuschanjh/" rel="noopener noreferrer"&gt;https://www.linkedin.com/in/marcuschanjh/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>amazonwebservices</category>
      <category>aws</category>
      <category>awscertification</category>
      <category>certification</category>
    </item>
    <item>
      <title>Leveraging Amazon Connect for Real-Time Incident Response Calls</title>
      <dc:creator>Marcus Chan</dc:creator>
      <pubDate>Fri, 27 Sep 2024 18:54:13 +0000</pubDate>
      <link>https://forem.com/marcuscjh/leveraging-amazon-connect-for-real-time-incident-response-calls-2a8d</link>
      <guid>https://forem.com/marcuscjh/leveraging-amazon-connect-for-real-time-incident-response-calls-2a8d</guid>
      <description>&lt;p&gt;Amazon Connect is a cloud-based contact center.&lt;/p&gt;

&lt;p&gt;This use case was to automate incident response calls: when an alert is triggered, it sends the alert to SNS, which triggers a Lambda function. This Lambda function calls the first escalation point. If no one answers, it escalates to the second contact.&lt;/p&gt;

&lt;p&gt;Here’s how I built it:&lt;/p&gt;

&lt;p&gt;Ensure that you are in the Correct Region — I’m using ap-southeast-1 as I am located in Singapore.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Setting up AWS Identity and Access Management (IAM)
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Step 1:&lt;/strong&gt; In the AWS Console, search for  &lt;strong&gt;IAM&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fs514fsvkanp60gh7d0k4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fs514fsvkanp60gh7d0k4.png" width="800" height="167"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Step 2:&lt;/strong&gt; Under &lt;strong&gt;Access management&lt;/strong&gt; , click on &lt;strong&gt;Roles&lt;/strong&gt; , then select &lt;strong&gt;Create role&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F35hjouoyqssgol81dgfy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F35hjouoyqssgol81dgfy.png" width="270" height="444"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr7jk4990357pmc67d2ep.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr7jk4990357pmc67d2ep.png" width="522" height="254"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Step 3:&lt;/strong&gt; For the trusted entity type, choose &lt;strong&gt;AWS Service&lt;/strong&gt; , and under &lt;strong&gt;Use Case&lt;/strong&gt; , select  &lt;strong&gt;Lambda&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frxgocalqrbhznmxaz2dy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frxgocalqrbhznmxaz2dy.png" width="800" height="364"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Step 4:&lt;/strong&gt; Use the following &lt;strong&gt;AWS Managed Policies&lt;/strong&gt; :&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;AmazonConnect_FullAccess&lt;/li&gt;
&lt;li&gt;AmazonDynamoDBFullAccess&lt;/li&gt;
&lt;li&gt;AmazonSNSFullAccess&lt;/li&gt;
&lt;li&gt;AWSLambdaBasicExecutionRole&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Step 5:&lt;/strong&gt; Finally, create a role name, then click &lt;strong&gt;Create role&lt;/strong&gt;. Make sure to take note of your &lt;strong&gt;Role Name&lt;/strong&gt; for future use.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fubxw7st182abkzves1vv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fubxw7st182abkzves1vv.png" width="800" height="273"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2x71soscmamgdh1suyf0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2x71soscmamgdh1suyf0.png" width="280" height="261"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Setting Up Amazon Simple Notification Service (SNS) (Optional)
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Step 1:&lt;/strong&gt; In the AWS Console, search for  &lt;strong&gt;SNS&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fomunol3xltdkpg8kok3x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fomunol3xltdkpg8kok3x.png" width="800" height="202"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Step 2:&lt;/strong&gt; Navigate to &lt;strong&gt;Topics&lt;/strong&gt; and click on &lt;strong&gt;Create topic&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F06pkvcw9llqwsqas8dyx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F06pkvcw9llqwsqas8dyx.png" width="800" height="158"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Step 3:&lt;/strong&gt; Choose &lt;strong&gt;Standard&lt;/strong&gt; for the topic type and give it a name (e.g., “AmazonConnectSNS”). After the topic is created, make sure to note down the &lt;strong&gt;ARN&lt;/strong&gt; for later use.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fypzk3ywppxbosp3jws71.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fypzk3ywppxbosp3jws71.png" width="800" height="446"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Setting Up Amazon Lambda
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Step 1:&lt;/strong&gt; In the AWS Console, search for  &lt;strong&gt;Lambda&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0caihhzouamhpowdolp5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0caihhzouamhpowdolp5.png" width="800" height="192"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Step 2:&lt;/strong&gt; Click on &lt;strong&gt;Create Function&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Foqg7uhw3auo6osihxmxf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Foqg7uhw3auo6osihxmxf.png" width="622" height="140"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Step 3:&lt;/strong&gt; On the Create Function page, provide a &lt;strong&gt;Function Name&lt;/strong&gt; (e.g., “AmazonConnectLambda”), select &lt;strong&gt;Runtime&lt;/strong&gt; as Python 3.xx, and choose the &lt;strong&gt;existing role&lt;/strong&gt; you created in the IAM setup section.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl4dfehssz3652betxbhy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl4dfehssz3652betxbhy.png" width="800" height="795"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Step 4:&lt;/strong&gt; Add an &lt;strong&gt;SNS Trigger&lt;/strong&gt; to the Lambda function.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhrbs6uq5vxm32nkjpv82.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhrbs6uq5vxm32nkjpv82.png" width="800" height="292"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi3rnpo8dnzundqydr1go.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi3rnpo8dnzundqydr1go.png" width="800" height="517"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Step 5:&lt;/strong&gt; Go to &lt;strong&gt;Configuration&lt;/strong&gt; , then navigate to &lt;strong&gt;Generation Configuration&lt;/strong&gt; , and set the &lt;strong&gt;Timeout&lt;/strong&gt; to &lt;strong&gt;1 minute&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsi8am544a1lu20xyr3i8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsi8am544a1lu20xyr3i8.png" width="800" height="172"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Step 6:&lt;/strong&gt; Paste the following code into the lambda_function.py file. I will explain the code in the last section, where we’ll also revisit it to modify variables:&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import boto3
import time
from datetime import datetime

# Initialize the DynamoDB resource
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('CallLogs')

# Create a Connect client
connect_client = boto3.client('connect')

#Amazon Connect Instance ID, Contact Flow ID and Claim number
CONNECT_INSTANCE_ID = 'xxxxxxxxx'
CONTACT_FLOW_ID = 'xxxxxxxxx'
SOURCE_PHONE_NUMBER = 'xxxxxxxxx' 

# Phone numbers for the contacts
FIRST_CONTACT_PHONE = "+65xxxxxx"
SECOND_CONTACT_PHONE = "+65xxxxxx"

def log_event_to_dynamodb(contact_id, event, phone_number, escalation_stage):
    """
    Log the event (CONNECTED, DISCONNECTED) into DynamoDB with relevant details.
    """
    try:
        table.put_item(
            Item={
                'ContactId': contact_id,
                'Event': event,
                'Timestamp': datetime.utcnow().isoformat(),
                'PhoneNumber': phone_number,
                'EscalationStage': escalation_stage
            }
        )
        print(f"Logged {event} event for ContactId {contact_id} in DynamoDB")
    except Exception as e:
        print(f"Error logging event to DynamoDB: {e}")

def start_outbound_call(phone_number, escalation_stage):
    """
    Start an outbound call using Amazon Connect and return the ContactId.
    """
    try:
        response = connect_client.start_outbound_voice_contact(
            DestinationPhoneNumber=phone_number,
            ContactFlowId=CONTACT_FLOW_ID,
            InstanceId=CONNECT_INSTANCE_ID,
            SourcePhoneNumber=SOURCE_PHONE_NUMBER,
            Attributes={
                'Escalation': escalation_stage
            }
        )
        contact_id = response['ContactId']
        print(f"Started outbound call to {phone_number} with ContactId: {contact_id}")
        return contact_id
    except Exception as e:
        print(f"Failed to start outbound call: {e}")
        raise e

def check_call_status(contact_id):
    """
    Check the status of the outbound call using the ContactId.
    """
    try:
        response = connect_client.describe_contact(
            InstanceId=CONNECT_INSTANCE_ID,
            ContactId=contact_id
        )

        if 'Contact' in response and 'DisconnectTimestamp' in response['Contact']:
            print(f"Call with ContactId {contact_id} was disconnected at {response['Contact']['DisconnectTimestamp']}")
            return 'DISCONNECTED'

        elif 'Contact' in response and 'ConnectedToSystemTimestamp' in response['Contact']:
            print(f"Call with ContactId {contact_id} was connected at {response['Contact']['ConnectedToSystemTimestamp']}")
            return 'CONNECTED'

    except Exception as e:
        print(f"Failed to check call status: {e}")
        raise e

def lambda_handler(event, context):
    try:
        acknowledgment = event.get('Details', {}).get('Parameters', {}).get('acknowledgment', 'False')
        escalation_stage = event.get('Details', {}).get('Parameters', {}).get('EscalationStage', 'FirstAttempt')
        print(f"Acknowledgment: {acknowledgment}, Escalation Stage: {escalation_stage}")
    except KeyError:
        print("Expected keys not found in event")
        acknowledgment = 'False'

    if acknowledgment == 'False' and escalation_stage == 'FirstAttempt':
        print("Calling the first contact...")
        contact_id = start_outbound_call(FIRST_CONTACT_PHONE, 'FirstAttempt')

        for attempt in range(10):
            time.sleep(5) 
            status = check_call_status(contact_id)

            if status == 'CONNECTED':
                log_event_to_dynamodb(contact_id, 'CONNECTED', FIRST_CONTACT_PHONE, 'FirstAttempt')
                print("First contact answered the call. No need to escalate.")
                return {
                    'statusCode': 200,
                    'body': 'First contact answered the call.'
                }

            elif status == 'DISCONNECTED':
                print(f"First contact did not pick up or disconnected. Escalating to second contact.")
                break

        print("Escalating to second contact...")
        contact_id = start_outbound_call(SECOND_CONTACT_PHONE, 'SecondAttempt')
        log_event_to_dynamodb(contact_id, 'ESCALATION', SECOND_CONTACT_PHONE, 'SecondAttempt')
        return {
            'statusCode': 200,
            'body': 'Second contact attempt made.'
        }

    elif acknowledgment == 'False' and escalation_stage == 'SecondAttempt':
        print("Calling the second contact...")
        contact_id = start_outbound_call(SECOND_CONTACT_PHONE, 'SecondAttempt')
        log_event_to_dynamodb(contact_id, 'ESCALATION', SECOND_CONTACT_PHONE, 'SecondAttempt')
        return {
            'statusCode': 200,
            'body': 'Second contact attempt made.'
        }

    elif acknowledgment == 'True':
        print("First contact acknowledged the call.")
        return {
            'statusCode': 200,
            'body': 'Call acknowledged by the first contact.'
        }

    else:
        print("Unexpected scenario encountered.")
        return {
            'statusCode': 500,
            'body': 'Unexpected scenario.'
        }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Step 6&lt;/strong&gt; : Click on  &lt;strong&gt;Deploy&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw04yvnnvo6t7reib5mno.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw04yvnnvo6t7reib5mno.png" width="767" height="85"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Setting up DynamoDB
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Step 1:&lt;/strong&gt; In the AWS Console, search for &lt;strong&gt;DynamoDB&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkhga7ppmkfk2tkxhlcjv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkhga7ppmkfk2tkxhlcjv.png" width="800" height="202"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Step 2:&lt;/strong&gt; In the navigation pane, click on &lt;strong&gt;Tables&lt;/strong&gt; , then select &lt;strong&gt;Create Table&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8ab2xiytia961v6lt6fp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8ab2xiytia961v6lt6fp.png" width="800" height="187"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Step 3:&lt;/strong&gt; In the &lt;strong&gt;Create Table&lt;/strong&gt; form, follow these steps:&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;Set the &lt;strong&gt;Table name&lt;/strong&gt; to CallLogs.&lt;/li&gt;
&lt;li&gt;For the &lt;strong&gt;Partition key&lt;/strong&gt; , use ContactId.&lt;/li&gt;
&lt;li&gt;For the &lt;strong&gt;Sort key&lt;/strong&gt; , use Timestamp.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Finally, click &lt;strong&gt;Create table&lt;/strong&gt; at the bottom.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdejlahpcmxq649bjehb4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdejlahpcmxq649bjehb4.png" width="800" height="613"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsj6agq9rzjahx94znrod.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsj6agq9rzjahx94znrod.png" width="800" height="66"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Setting Up Amazon Connect
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Step 1&lt;/strong&gt; : In the AWS Console, search for &lt;strong&gt;Amazon Connect&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Foamfk58hbuy4v1savr52.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Foamfk58hbuy4v1savr52.png" width="800" height="161"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Step 2:&lt;/strong&gt; Create a new instance by following the on-screen instructions. Make sure to enable both &lt;strong&gt;Inbound&lt;/strong&gt; and &lt;strong&gt;Outbound&lt;/strong&gt; calls during setup.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Foe51xuu6wqz02ctp5noo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Foe51xuu6wqz02ctp5noo.png" width="800" height="235"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Step 3:&lt;/strong&gt; After the instance is created, navigate to &lt;strong&gt;Flows&lt;/strong&gt; in the side menu. In the flow editor, under &lt;strong&gt;AWS Lambda&lt;/strong&gt; , add the Lambda function that was created earlier.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvt39o62w2bw2gzwungxc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvt39o62w2bw2gzwungxc.png" width="279" height="729"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2rngelxmbwwqanojp0w1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2rngelxmbwwqanojp0w1.png" width="800" height="160"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Step 4:&lt;/strong&gt; Log in to your Amazon Connect instance with admin privileges. Once logged in, navigate to the sidebar and select &lt;strong&gt;Phone numbers&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1qufkz7qxymyev095i2g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1qufkz7qxymyev095i2g.png" width="475" height="319"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Step 5:&lt;/strong&gt; Click on &lt;strong&gt;Claim a number&lt;/strong&gt; and follow the recommended steps to complete the process.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnd1d3jjg9nu0wnrucrfs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnd1d3jjg9nu0wnrucrfs.png" width="800" height="182"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjn3fxcruc7reooce62vk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjn3fxcruc7reooce62vk.png" width="800" height="645"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Step 6:&lt;/strong&gt; Test both &lt;strong&gt;incoming&lt;/strong&gt; and &lt;strong&gt;outgoing&lt;/strong&gt;  calls:&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;To test incoming calls, use a mobile phone to dial the number you claimed.&lt;/li&gt;
&lt;li&gt;For outgoing calls, use the &lt;strong&gt;Contact Control Panel&lt;/strong&gt; in Amazon Connect.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0ayggq7e2txp93nyh8mk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0ayggq7e2txp93nyh8mk.png" width="506" height="917"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Step 7:&lt;/strong&gt; In the Amazon Connect navigation bar, click on &lt;strong&gt;Flows&lt;/strong&gt; and create a new flow.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fci9jvt2jci19eos9rsxc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fci9jvt2jci19eos9rsxc.png" width="242" height="281"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffni58lz2bi88z70h6wrz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffni58lz2bi88z70h6wrz.png" width="429" height="209"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Step 8:&lt;/strong&gt; Add a title to your flow, design the flow, and once done, click &lt;strong&gt;Publish&lt;/strong&gt;. You can follow this basic structure for your flow:&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmlt62mhmcev3gczmyjoy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmlt62mhmcev3gczmyjoy.png" width="800" height="310"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Entry&lt;/strong&gt; -&amp;gt; &lt;strong&gt;Set Logging Behavior&lt;/strong&gt; block -&amp;gt; &lt;strong&gt;Play Prompt&lt;/strong&gt; -&amp;gt; &lt;strong&gt;Get Customer Input&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If the user presses 1, proceed to the &lt;strong&gt;Play Prompt&lt;/strong&gt; block-&amp;gt; &lt;strong&gt;Disconnect&lt;/strong&gt; block&lt;/li&gt;
&lt;li&gt;If not, invoke the &lt;strong&gt;Lambda function&lt;/strong&gt; block -&amp;gt; &lt;strong&gt;Disconnect&lt;/strong&gt; block&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the &lt;strong&gt;Invoke AWS Lambda&lt;/strong&gt; block, you’ll need to set up the function input parameters. Follow this structure for the input parameters:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0axrkfs1h07bd0mbq2g4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0axrkfs1h07bd0mbq2g4.png" width="401" height="669"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Source Code Explanation
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Calling the First Contact&lt;/strong&gt; : Once the Lambda function is triggered, it initiates an outbound call to the FIRST_CONTACT_PHONE. The function then monitors the call status (whether it’s connected or disconnected) and logs the event accordingly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Escalating to the Second Contact&lt;/strong&gt; : If the first contact doesn’t respond, the function escalates the process by making an outbound call to the SECOND_CONTACT_PHONE.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Handling Call Acknowledgment&lt;/strong&gt; : If an acknowledgment is received (set to ‘True’), the function stops any further escalation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  7. Wrapping Things Up
&lt;/h3&gt;

&lt;p&gt;When revisiting the Lambda function, remember to update the following variables:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsajf8tst4en317ska22q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsajf8tst4en317ska22q.png" width="463" height="157"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CONNECT_INSTANCE_ID: This is the unique Amazon Connect instance ARN (UUID) associated with your instance.&lt;/li&gt;
&lt;li&gt;CONTACT_FLOW_ID: This is the UUID of your contact flow. You can find it in the URL of your flow in Amazon Connect, located immediately after /contact-flow/.&lt;/li&gt;
&lt;li&gt;SOURCE_PHONE_NUMBER: This is the phone number you claimed in Amazon Connect.&lt;/li&gt;
&lt;li&gt;FIRST_CONTACT_PHONE: The phone number of the first person to receive the call.&lt;/li&gt;
&lt;li&gt;SECOND_CONTACT_PHONE: The phone number of the second person to receive the call if the first contact doesn’t answer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In this setup, &lt;strong&gt;SNS&lt;/strong&gt; is used to receive alerts from an &lt;strong&gt;AWS CloudWatch&lt;/strong&gt; alarm, which triggers the SNS topic. You can simulate an alert by going to SNS and publishing a message manually.&lt;/p&gt;

&lt;p&gt;However, for simplicity, we can skip that and go straight to &lt;strong&gt;Lambda&lt;/strong&gt; and trigger the function directly to test the flow.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy48hsit7my8ds34ntxr9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy48hsit7my8ds34ntxr9.png" width="658" height="180"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If everything works as expected, you should receive a call, and you’ll be able to view the call logs in DynamoDB under the CallLogs table.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F27ct5x1x2xd2vj82y2o0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F27ct5x1x2xd2vj82y2o0.png" width="443" height="951"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;While in DynamoDB, you will be able to see the CallLogs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fci7wiy4ek6xegefxnkw6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fci7wiy4ek6xegefxnkw6.png" width="800" height="375"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I hope you found this use case helpful.&lt;br&gt;&lt;br&gt;
Overall, I found it enjoyable to experiment with Aamzon Connect.&lt;/p&gt;

&lt;p&gt;You can connect with me on linkedin: &lt;a href="https://www.linkedin.com/in/marcuschanjh/" rel="noopener noreferrer"&gt;https://www.linkedin.com/in/marcuschanjh/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>amazonconnect</category>
      <category>stepbystepguide</category>
      <category>awslambda</category>
      <category>aws</category>
    </item>
  </channel>
</rss>
