Build an SNS Webhook with Laravel

Deploy a signed Amazon SNS webhook with Laravel, Caddy, and EC2.

Introduction

30 Second Summary

When one system sends instructions to another, the receiver needs proof that the message is genuine. Without that proof, an impostor can trigger the same action.

In this project, you will build a signed webhook in Laravel on Amazon EC2. It will verify Amazon SNS messages over HTTPS before trusted notifications reach Laravel's application log.

What You'll Build

Your demo ends with Hello from Amazon SNS arriving from the SNS console as a structured entry in Laravel's application log.

By the end of this project, you'll have:

  • A public status page at /receiver that shows the webhook is waiting. The /up health route proves your Laravel app is live without a certificate warning.
  • A secure webhook boundary at /sns that rejects malformed or unsigned requests. A TopicArn allowlist keeps other SNS topics out.
  • A live delivery demo that automatically confirms the HTTPS subscription. Each published notification creates a structured log entry with its message_id plus its message.
  • Secret Mission: Add an idempotency guard keyed by MessageId so an SNS retry cannot process the same notification twice.

Are there any prerequisites?

You need an AWS account with permission to create the cloud resources used in this project.

You also need a Cloudflare-managed domain plus SSH access to an EC2 instance.

Before We Start

Before any infrastructure exists, you need a clear picture of the event path you are building. This commitment gives every hosting, security, and application decision a shared purpose.

Set Up EC2 and Laravel

A signed webhook needs a stable host before it can receive an external request. This step creates that foundation for your Laravel application.

Amazon EC2 gives you an Ubuntu server that stays available beyond your local computer. You will prepare that server with the exact runtime versions this project expects.

In this step, get ready to:
  • Launch a secured EC2 host with a stable public address.
  • Install PHP 8.3 plus the extensions Laravel needs.
  • Scaffold the pinned Laravel application with Composer 2.10.3.
Launch and connect to the host

A security group controls which network traffic can reach your instance. An Elastic IP gives the instance a public address that remains stable.

Know what starts billing

Launching the instance creates paid AWS resources. The instance storage plus its public IPv4 address can continue accruing charges until you clean them up.

  • Sign in to the AWS Management Console.
  • Use the service search bar to open Amazon EC2.
  • Choose the AWS Region for this project from the Region selector in the top navigation bar.
  • Record the selected Region here: your AWS Region.
  • Choose Launch instance on the EC2 Dashboard.
  • Select Ubuntu Server 24.04 LTS under Application and OS Images.
  • Choose an instance type that fits your AWS account budget.
  • Select your key pair under Key pair (login).

Why record the Region?

The future Amazon SNS topic must use the same AWS Region as this EC2 instance. Keeping both resources together also avoids regional data transfer charges for their traffic.

  • Choose Edit under Network settings.
  • Select Create security group.
  • Set Allow SSH traffic from to My IP.
  • Select Allow HTTP traffic from the internet.
  • Select Allow HTTPS traffic from the internet.
  • Review the settings in the Summary panel.
  • Choose Launch instance.
  • Choose View all instances.

Provisioning can take a few minutes. Wait until the instance state shows Running before continuing.

  • Choose Network & Security in the EC2 navigation pane.
  • Choose Elastic IPs.
  • Choose Allocate Elastic IP address.
  • Keep Amazon's pool of IPv4 addresses selected under Public IPv4 address pool.
  • Choose Allocate.

You now have a static public address allocated to your AWS account. The next actions attach it to the running instance.

  • Select the new Elastic IP address.
  • Choose Actions.
  • Choose Associate Elastic IP address.
  • Choose Instance for Resource type.
  • Select your running Ubuntu instance in the Instance field.
  • Choose Associate.
  • Record the associated address here: your Elastic IP address.

Secure Shell connects Windows Terminal to the server through the restricted TCP port 22 rule. The Ubuntu AMI uses ubuntu as its default login name.

  • Press Cmd+Space on macOS or the Windows key on Windows to open your search bar.
  • Type Windows Terminal on Windows or Terminal on macOS.
  • Press Enter to open the terminal.
  • Record the full path to your private key file here: path to your .pem file.
  • Enter yes if OpenSSH asks whether to continue the first connection.
  • Connect to the Ubuntu instance by running this command:
ssh -i "[[PEMPATH="path to your .pem file"]]" ubuntu@[[ELASTIC_IP="your Elastic IP address"]]

What does this command do?

  • The -i option tells OpenSSH which private key proves your identity.
  • The ubuntu login selects the default account for the Ubuntu AMI.
  • The Elastic IP directs the encrypted connection to your EC2 instance.

You should see an Ubuntu shell prompt beginning with ubuntu@. Your cloud host is now accepting authenticated SSH connections.

Can't connect over SSH?

  • Confirm the instance state is Running.
  • Confirm the security group's SSH rule uses TCP port 22 with your current public IP as its source.
  • Confirm the private key path points to the key pair selected during launch.
  • Still stuck? Help me troubleshoot my SSH connection to an Ubuntu EC2 instance.
Install the PHP runtime and extensions

PHP executes Laravel's application code. PHP-FPM provides the process that Caddy connects to in the next step.

  • Check whether the server already has PHP by running this command:
php -v

What does this check do?

This command asks the active PHP executable to report its version. The project requires PHP 8.3.

✔️ I see the required PHP version

PHP 8.3 is already active. Install the complete package set to add PHP-FPM plus every required extension.

  • Refresh Ubuntu's package index and install the required packages by running these commands:
sudo apt update
sudo apt install -y php8.3-cli php8.3-fpm php8.3-curl php8.3-mbstring php8.3-xml php8.3-sqlite3 unzip

What do these commands install?

  • The first command refreshes Ubuntu's available package information.
  • The second command installs PHP 8.3 CLI plus PHP-FPM.
  • The extension packages add cURL, multibyte strings, XML, SQLite, plus archive extraction support.

🙋‍♀️ Package installation failed?

Check that the SSH session still has internet access. Review the package name shown near the failure.

Need another pair of eyes? Help me diagnose my PHP package installation on Ubuntu Server 24.04 LTS.

ⓧ I see an older PHP version

The active PHP version is below 8.3. Ubuntu Server 24.04 LTS supplies the required PHP 8.3 packages.

  • Refresh Ubuntu's package index and install PHP 8.3 by running these commands:
sudo apt update
sudo apt install -y php8.3-cli php8.3-fpm php8.3-curl php8.3-mbstring php8.3-xml php8.3-sqlite3 unzip

What do these commands install?

  • The first command refreshes Ubuntu's available package information.
  • The second command installs the pinned PHP CLI plus PHP-FPM.
  • The extension packages satisfy Laravel's runtime requirements.

🙋‍♀️ Still seeing the older version?

Start a fresh SSH session after the package installation. Check whether a custom PHP installation earlier in your executable path is shadowing Ubuntu's package.

Need help checking the active executable? Help me make Ubuntu use PHP 8.3 after installing it.

ⓧ Command not found

PHP is not installed yet. The fresh Ubuntu host is ready for the pinned package set.

  • Refresh Ubuntu's package index and install PHP 8.3 by running these commands:
sudo apt update
sudo apt install -y php8.3-cli php8.3-fpm php8.3-curl php8.3-mbstring php8.3-xml php8.3-sqlite3 unzip

What do these commands install?

  • The first command refreshes Ubuntu's available package information.
  • The second command installs PHP 8.3 CLI plus PHP-FPM.
  • The remaining packages add Laravel's required extensions plus archive extraction support.

🙋‍♀️ Can't find the PHP packages?

Confirm the instance uses Ubuntu Server 24.04 LTS. Confirm the package index refresh completed successfully.

Still blocked? Help me install PHP 8.3 packages on Ubuntu Server 24.04 LTS.

Before you check, which two details should prove the runtime is ready?

  • Verify the active PHP version plus the loaded extensions by running these commands:
php -v
php -m | grep -E 'curl|dom|mbstring|openssl|pdo|xml'

What should the checks prove?

The first command should identify PHP 8.3. The second command should list the matching Laravel extensions that PHP loaded.

You now have the runtime that Laravel requires. PHP-FPM is also installed for the public web path you create next.

Missing an extension?

Rerun the package installation command for the full package set. Check the installation output for the extension package that failed.

Need help reading the module output? Help me identify the missing Laravel PHP extension.

Install Composer and scaffold Laravel

Composer installs PHP packages from declared versions. Pinning Composer plus Laravel makes the server reproducible.

  • Check whether Composer is already available by running this command:
composer --version

What does this check do?

This command reports the Composer release available through the server's executable path. This project uses Composer 2.10.3.

✔️ I see the required Composer version

Composer 2.10.3 is ready. Continue below to verify the installation from the main flow.

ⓧ I see an older Composer version

Replace the older executable with the pinned Composer release. The checksum protects the installer before it runs.

  • Install Composer 2.10.3 at /usr/local/bin/composer by running this verified installer block:
php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
php -r "if (hash_file('sha384', 'composer-setup.php') === 'c8b085408188070d5f52bcfe4ecfbee5f727afa458b2573b8eaaf77b3419b0bf2768dc67c86944da1544f06fa544fd47') { echo 'Installer verified'.PHP_EOL; } else { echo 'Installer corrupt'.PHP_EOL; unlink('composer-setup.php'); exit(1); }"
sudo php composer-setup.php --version=2.10.3 --install-dir=/usr/local/bin --filename=composer
php -r "unlink('composer-setup.php');"

What does the installer do?

  • The first command downloads Composer's installer.
  • The second command compares the installer with its official SHA-384 checksum.
  • The third command installs the pinned release as a global executable.
  • The final command removes the temporary installer.

🙋‍♀️ Composer installation failed?

Stop if the checksum check reports a corrupt installer. Download a fresh copy before trying again.

Need help with the installation? Help me install Composer 2.10.3 globally on Ubuntu.

ⓧ Command not found

Composer is not installed yet. The verified installer creates the global executable required for Laravel.

  • Install Composer 2.10.3 at /usr/local/bin/composer by running this verified installer block:
php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
php -r "if (hash_file('sha384', 'composer-setup.php') === 'c8b085408188070d5f52bcfe4ecfbee5f727afa458b2573b8eaaf77b3419b0bf2768dc67c86944da1544f06fa544fd47') { echo 'Installer verified'.PHP_EOL; } else { echo 'Installer corrupt'.PHP_EOL; unlink('composer-setup.php'); exit(1); }"
sudo php composer-setup.php --version=2.10.3 --install-dir=/usr/local/bin --filename=composer
php -r "unlink('composer-setup.php');"

What does the installer do?

  • The first command downloads Composer's installer.
  • The second command verifies the official installer checksum.
  • The third command installs Composer 2.10.3 globally.
  • The final command removes the temporary installer.

🙋‍♀️ Composer installation failed?

Confirm PHP 8.3 is active. Confirm the server can reach the Composer download site.

Still blocked? Help me diagnose the Composer installer on my EC2 host.

  • Confirm the pinned Composer release by running this command:
composer --version

What should Composer report?

The output should identify Composer version 2.10.3. This confirms the global executable is ready to create the application.

The Laravel application skeleton supplies the default project structure plus the built-in health route. Creating it through Composer also resolves its initial PHP dependencies.

The scaffold can take a few minutes while Composer downloads packages. Let it finish until the terminal returns to the shell prompt.

  • Create the Laravel application scaffold by running this command:
composer create-project laravel/laravel sns-webhook 13.11.0 --no-interaction

What does this command create?

Composer creates a sns-webhook directory from Laravel's application skeleton version 13.11.0. The non-interactive option lets the scaffold complete without pausing for input.

You should see Composer complete the dependency installation without returning an error. The sns-webhook application directory now exists under /home/ubuntu.

Scaffold download failed?

Confirm the server still has internet access. Check whether the failure mentions a missing PHP extension from the previous substep.

Need help interpreting the output? Help me troubleshoot the Laravel scaffold command.

  • Enter the new application directory and pin Laravel Framework by running these commands:
cd /home/ubuntu/sns-webhook
composer require laravel/framework:13.38.0 --with-all-dependencies

Why update the framework separately?

The application skeleton defines the project layout. The second command updates its framework dependency to Laravel Framework 13.38.0 plus compatible supporting packages.

You should see Composer finish updating the dependency set. The terminal should return to the prompt inside /home/ubuntu/sns-webhook.

Framework update failed?

Review Composer's output for a PHP version conflict or a missing extension. Confirm the earlier runtime checks still report PHP 8.3.

Still stuck? Help me resolve the Laravel Framework dependency update.

  • Move the completed application to its permanent server path by running this command:
sudo mv /home/ubuntu/sns-webhook /srv/laravel-sns

Why move the application?

The /srv/laravel-sns path gives the server application a predictable home outside the login user's directory. Later configuration points PHP-FPM plus Caddy to this location.

Before the final check, which route do you expect Laravel to list automatically?

  • Verify the Laravel installation from its permanent path by running this command:
cd /srv/laravel-sns && php artisan route:list

What should the route list prove?

You should see the built-in /up health route in the command output. That route proves the pinned Laravel application can boot successfully.

That's the server foundation complete. Your EC2 host now runs the pinned Laravel application from /srv/laravel-sns.

Don't see the health route?

Confirm the prompt is inside /srv/laravel-sns. Confirm the directory contains the generated Laravel scaffold.

Need help finding the boot failure? Help me diagnose why Laravel's route list does not show the health route.

Your application is installed on a stable cloud host. Next, you will place Caddy in front of PHP-FPM so the health route works over trusted HTTPS.

Put Laravel Online with Caddy and PHP-FPM

Your Laravel application now runs on your Amazon EC2 instance. However, Amazon SNS needs a public endpoint with trusted encryption before it can deliver webhook requests.

Caddy will accept public HTTPS requests at your domain. PHP-FPM will execute Laravel behind it.

In this step, get ready to:
  • Point a DNS-only hostname to your EC2 Elastic IP.
  • Install Caddy and give it access to PHP-FPM.
  • Serve Laravel over trusted HTTPS.
Prepare DNS and Laravel permissions

A public hostname gives Caddy an address where it can request a trusted certificate. A DNS-only record sends certificate traffic directly to your server.

  • Sign in to your Cloudflare DNS account.
  • Select the domain you are using for this project.
  • Select DNS in the left menu.
  • Select Records.
  • Click Add record.

Why Use DNS Only?

DNS only lets Caddy receive certificate requests directly on ports 80 and 443. This keeps the first HTTPS setup path simple.

  • Select A under Type.
  • Enter sns under Name.
  • Enter your EC2 Elastic IP under IPv4 address.
  • Select DNS only under Proxy status.
  • Click Save.

You should see an A record for sns.your-domain.com with the gray DNS-only cloud.

Laravel also needs writable cache and log directories. The PHP-FPM group will use these permissions when it handles web requests.

  • Switch back to the Windows Terminal SSH session from earlier.
  • Give the Laravel directory the required ownership and writable paths by running these commands:
sudo chown -R ubuntu:www-data /srv/laravel-sns
sudo chmod -R ug+rwX /srv/laravel-sns/storage /srv/laravel-sns/bootstrap/cache

What Do These Permissions Do?

  • The ownership command keeps ubuntu as the application owner. It assigns www-data as the group.
  • The permission command gives the owner and group write access to storage.
  • The same permission command makes bootstrap/cache writable for Laravel's generated files.

The terminal should return to the prompt without reporting a permission problem.

Seeing a Permission Problem?

Confirm that your SSH prompt shows the ubuntu user. Confirm that the application still exists at /srv/laravel-sns.

Still stuck? Help me diagnose the Laravel directory permission problem.

Install Caddy and connect PHP-FPM

The official Caddy package installs its repository configuration. It also starts a system service that reads /etc/caddy/Caddyfile.

  • Approve the installation if the package manager asks for confirmation.
  • Install the stable Caddy package by running the official command block:
sudo apt install --yes debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg
sudo chmod o+r /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy

What Does This Install Block Do?

The first commands add the signing key and stable package repository. The final commands refresh the package index before installing Caddy.

The package creates a system user named caddy. It also starts the caddy service.

  • Check the installed Caddy version by running this command:
caddy version

What Should You See?

The output should begin with v2.11.7. This confirms the pinned Caddy release is available on the server.

🙋‍♀️ Caddy Installation Failed?

Check that the instance can reach the internet. Confirm that every command in the stable release block completed before retrying the version check.

Still stuck? Help me troubleshoot the Caddy package installation.

Caddy passes PHP requests through a Unix socket. The socket permissions decide which system users can reach PHP-FPM.

  • Inspect the PHP-FPM socket owner and group by running this command:
stat -c '%U %G %a' /var/run/php/php8.3-fpm.sock

How To Read the Socket Check

The command prints the socket owner first. It prints the socket group second.

On the Ubuntu package default, the group is www-data. Caddy needs membership in that group to send requests through the socket.

  • Give Caddy access to the socket group by running these commands:
sudo usermod -aG www-data caddy
sudo systemctl restart php8.3-fpm caddy

Why Restart Both Services?

The membership change applies when the Caddy service starts a new process. Restarting PHP-FPM also confirms that its PHP 8.3 socket is available at the expected path.

The commands should return to the prompt without reporting a service failure.

Socket Access Still Failing?

Compare the group printed by the socket check with the group added to the caddy user. Use the printed group if your package configuration differs.

Still stuck? Help me troubleshoot access to the PHP-FPM socket.

Serve Laravel over trusted HTTPS

Caddy needs the public hostname and Laravel's public directory in one configuration file. Its PHP directive forwards dynamic requests to PHP-FPM.

  • Open /etc/caddy/Caddyfile in Nano by running this command:
sudo nano /etc/caddy/Caddyfile

What Does This Command Do?

The command opens Caddy's configuration file with administrator permissions. Nano lets you replace the package's default configuration from the SSH session.

  • Replace the existing file contents with this configuration:
sns.your-domain.com {
    root /srv/laravel-sns/public
    php_fastcgi unix//var/run/php/php8.3-fpm.sock
    file_server
}

What Does This Configuration Do?

  • The hostname tells Caddy where to enable automatic HTTPS.
  • The root directive exposes only Laravel's public directory.
  • The php_fastcgi directive sends PHP requests to the installed PHP-FPM socket.
  • The file_server directive serves Laravel's public static files.
  • Press Ctrl+O to write the file.
  • Press Enter to confirm the filename.
  • Press Ctrl+X to close Nano.

✔️ Awesome, I've got everything!

Great. Confirm that you saved /etc/caddy/Caddyfile before continuing.

ⓧ I'd like to double check the full code

sns.your-domain.com {
    root /srv/laravel-sns/public
    php_fastcgi unix//var/run/php/php8.3-fpm.sock
    file_server
}
  • Load the new Caddy configuration by running this command:
sudo systemctl reload caddy

What Happens After the Reload?

Caddy reads the hostname from the saved configuration. It requests a trusted certificate before serving Laravel through HTTPS.

🙋‍♀️ HTTPS Not Ready?

  • Inspect the latest Caddy service logs by running this command:
sudo journalctl -u caddy --no-pager -n 50

The output shows the latest certificate, DNS, configuration, or socket details recorded by the Caddy service.

Confirm that sns.your-domain.com points to the Elastic IP. Confirm that the EC2 security group still allows public traffic on ports 80 and 443.

Still stuck? Help me diagnose why Caddy cannot serve Laravel over HTTPS.

Before you run this, predict whether the public Laravel health route returns HTTP 200.

  • Check the public health route by running this command:
curl -I https://sns.your-domain.com/up

What Should You See?

You should see an HTTP 200 response. This proves that Caddy can reach PHP-FPM and boot Laravel through the public document root.

  • Open https://sns.your-domain.com/up in your browser.

You should see Laravel's health response without a certificate warning.

That completes the public edge of your application. Laravel is now reachable through a trusted HTTPS endpoint.

Health Route Not Returning 200?

A certificate problem usually points to DNS or blocked ports. A server response problem usually points to the Caddy document root or PHP-FPM socket access.

Still stuck? Help me troubleshoot the Laravel HTTPS health check.

Your Laravel application is online through Caddy and PHP-FPM. Next, you'll add a visible receiver route before testing how Laravel handles an external webhook POST.

Feel the Webhook Failure

Your Laravel application now answers public requests over trusted HTTPS. Caddy passes dynamic requests to PHP-FPM.

Amazon SNS sends webhook requests outside a browser session. Laravel's web routes expect a CSRF token from browser forms.

You'll create the smallest receiver that could accept the event. You'll test its browser security boundary from an external request.

In this step, get ready to:
  • Create the visible receiver route plus the naive SNS POST route.
  • Confirm Laravel registered both routes.
  • Compare their public responses.
Create the visible receiver routes

The receiver route exposes a waiting state you can check safely. The naive POST route represents the first version of the SNS entry point.

  • In the SSH session from earlier, replace the contents of routes/web.php with this code:
<?php

use Illuminate\Support\Facades\Route;

Route::get('/receiver', function () {
    return [
        'name' => 'Laravel SNS Receiver',
        'status' => 'waiting',
    ];
});

Route::post('/sns', function () {
    return response('Received', 202);
});

What do these routes do?

  • The GET route returns the receiver name plus its current waiting state.
  • The POST route prepares a Received response with status 202.
  • Laravel converts the returned array into a JSON response.
  • Save routes/web.php.
Confirm Laravel registered the routes

Laravel's route list shows which public paths the application recognizes. This check catches file mistakes before you test the public hostname.

  • Check Laravel's registered routes from /srv/laravel-sns by running:
cd /srv/laravel-sns && php artisan route:list

What does this command check?

The command loads Laravel's current route table from the application directory. It confirms that the framework can parse your updated route file.

You should see GET|HEAD receiver plus POST sns in the output.

Good, both public entry points now exist in Laravel's route table.

Routes missing from the list?

  • Confirm the saved file path is /srv/laravel-sns/routes/web.php.
  • Compare every brace plus parenthesis in your file with the complete reference below.
  • Still stuck? Help me find why Laravel is not listing my receiver routes.

✔️ Awesome, I've got everything!

Both route entries appear in the route list. Your temporary receiver file is ready for public testing.

ⓧ I'd like to double check the full code

<?php

use Illuminate\Support\Facades\Route;

Route::get('/receiver', function () {
    return [
        'name' => 'Laravel SNS Receiver',
        'status' => 'waiting',
    ];
});

Route::post('/sns', function () {
    return response('Received', 202);
});

What should match?

The file should contain one route import. It should contain exactly two route closures.

Test the public webhook boundary

The GET request confirms that the new route works through the public hostname. The POST request then tests the same middleware path used by an external publisher.

  • Request the public receiver status by running:
curl https://sns.your-domain.com/receiver

What does this request check?

The request travels through Caddy before Laravel handles the receiver route. A successful response proves the new route is publicly reachable.

You'll see {"name":"Laravel SNS Receiver","status":"waiting"} in the terminal.

  • Open https://sns.your-domain.com/receiver in your browser.

Great, the waiting state is now visible from the public hostname. That response proves the request traveled through Caddy to Laravel.

Receiver page not showing JSON?

  • Confirm the browser address ends with /receiver.
  • Confirm the route list still includes GET|HEAD receiver.
  • Still stuck? Help me troubleshoot the public receiver response.

Before you send the POST, do you think Laravel will let it reach the Received response?

  • Test the naive /sns endpoint from outside the browser by running:
curl -i -X POST https://sns.your-domain.com/sns -H 'Content-Type: text/plain' --data '{"test":true}'

What does this request test?

The command sends a text/plain POST body to /sns without a browser session. It tests whether the naive route can cross Laravel's web middleware.

You'll see an HTTP 419 response.

That is the intended shortfall. Laravel's request-forgery protection stops an external POST without a browser CSRF token.

  • Do not add a fake CSRF token to the request.

POST not returning 419?

  • Confirm the request URL ends with /sns.
  • Confirm routes/web.php still contains the naive POST closure from this step.
  • Still seeing a different result? Help me diagnose the POST response.

You've proved that a public Laravel route still has a browser security boundary. Next, you'll replace that boundary with SNS signature validation. You'll also restrict the receiver to the expected topic.

Build the Signed SNS Receiver

Your public Laravel route is alive. The previous test exposed its browser-oriented CSRF boundary because an external publisher cannot provide Laravel's token.

This step replaces that boundary with controls designed for Amazon SNS. The receiver verifies each signature. It also restricts requests to one expected topic before taking action.

In this step, get ready to:
  • Install the standalone SNS message validator.
  • Build a controller that verifies signatures and restricts trusted messages.
  • Exclude only the SNS route from request-forgery protection and test rejection.
Install the validator and add configuration

Signature validation proves that AWS created the message. The standalone validator provides this check without adding the full AWS SDK.

  • Install aws/aws-php-sns-message-validator version 1.10.2 by running this command in your existing SSH session:
cd /srv/laravel-sns && composer require aws/aws-php-sns-message-validator:1.10.2

What does this command do?

  • Composer adds the SNS message validator to the Laravel application.
  • The exact version keeps your dependency setup reproducible.

Composer should finish the dependency installation without reporting an error.

Why use the standalone validator?

This application receives SNS messages. It does not need the broader set of AWS service clients included in the full SDK.

The smaller package focuses on parsing SNS payloads and verifying their signatures with OpenSSL.

🙋‍♀️ Composer installation failed?

  • Confirm your EC2 instance can reach the internet.
  • Confirm the PHP OpenSSL extension is enabled.
  • Use the terminal output when you ask for help with the validator installation.

The controller needs one application-owned setting for the permitted topic. A temporary value keeps the receiver closed until you create the real topic in the next step.

  • Open config/sns.php in nano by running this command:
nano config/sns.php

What does this command do?

Nano opens the named configuration file for editing. It creates the file when it does not already exist.

  • Paste this configuration into config/sns.php.
<?php

return [
    'topic_arn' => 'your-topic-arn-here',
];

What does this configuration do?

  • The topic_arn key stores the only topic ARN that the receiver accepts.
  • The temporary value prevents an unknown topic from passing the allowlist check.
  • Save config/sns.php using nano's on-screen controls.
  • Exit nano after the file is saved.
  • Check the configuration syntax by running this command:
php -l config/sns.php

What should I see?

PHP should report No syntax errors detected for config/sns.php.

🙋‍♀️ Seeing a configuration syntax error?

  • Confirm the array ends with ];.
  • Confirm your-topic-arn-here remains inside single quotes.
  • Use the reported line number when you ask for help with config/sns.php.
Build the controller and secure route

The controller applies trust checks before it processes a message. A valid signature proves the message came from AWS. The topic allowlist proves it came through your expected topic.

Why check the confirmation URL?

A subscription confirmation makes an outbound request to the message's SubscribeURL. The controller permits HTTPS URLs on the expected SNS hostname shape.

Redirects stay disabled. This keeps the outbound request on the address that passed validation.

  • Open app/Http/Controllers/SnsWebhookController.php in nano by running this command:
nano app/Http/Controllers/SnsWebhookController.php

What does this command do?

Nano opens the new controller file inside the existing Laravel controllers directory.

  • Paste the next five code chunks into app/Http/Controllers/SnsWebhookController.php in the order shown.
  • Keep nano open until every chunk is present.
<?php

namespace App\Http\Controllers;

use Aws\Sns\Message;
use Aws\Sns\MessageValidator;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;

class SnsWebhookController extends Controller
{
    public function __invoke()
    {
        try {
            $message = Message::fromRawPostData();
            (new MessageValidator())->validate($message);
        } catch (Throwable $exception) {
            Log::warning('Rejected SNS message.', [
                'reason' => $exception->getMessage(),
            ]);

            return response('Not Found', 404);
        }

How does validation start?

  • The imports provide the SNS parser and signature validator.
  • The try block parses the raw request before validating its signature.
  • The catch block logs malformed or unsigned requests before returning HTTP 404.
  • Paste the topic and message-type checks directly below the previous chunk:
        if (! hash_equals((string) config('sns.topic_arn'), (string) $message['TopicArn'])) {
            Log::warning('Rejected SNS message from an unexpected topic.');

            return response('Not Found', 404);
        }

        if ($message['Type'] === 'SubscriptionConfirmation') {
            return $this->confirmSubscription($message);
        }

        if ($message['Type'] === 'Notification') {
            Log::info('SNS notification received.', [
                'message_id' => $message['MessageId'],
                'subject' => $message['Subject'],
                'message' => $message['Message'],
            ]);

            return response('OK', 200);
        }

        return response('Ignored', 200);
    }

How are trusted messages handled?

  • The hash_equals() comparison rejects messages from any topic outside the configuration.
  • A SubscriptionConfirmation message moves to the confirmation method.
  • A Notification message creates a structured log entry.
  • Paste the first half of the subscription confirmation method below __invoke():
    private function confirmSubscription(Message $message)
    {
        $subscribeUrl = (string) $message['SubscribeURL'];

        if (! $this->isTrustedSnsUrl($subscribeUrl)) {
            Log::warning('Rejected an untrusted SNS subscription URL.');

            return response('Not Found', 404);
        }

        try {
            $response = Http::timeout(5)
                ->withOptions(['allow_redirects' => false])
                ->get($subscribeUrl);
        } catch (Throwable $exception) {
            Log::error('SNS subscription confirmation request failed.', [
                'reason' => $exception->getMessage(),
            ]);

            return response('Confirmation Failed', 502);
        }

How does confirmation stay controlled?

  • The method rejects the confirmation URL before making an outbound request when the URL fails the trust check.
  • The HTTP request uses a five-second timeout.
  • The client disables redirects so the request cannot move to another destination.
  • Complete the confirmation method by pasting this code directly below the previous chunk:
        if (! $response->successful()) {
            Log::error('SNS subscription confirmation returned an error.', [
                'status' => $response->status(),
            ]);

            return response('Confirmation Failed', 502);
        }

        Log::info('SNS subscription confirmed.', [
            'message_id' => $message['MessageId'],
            'topic_arn' => $message['TopicArn'],
        ]);

        return response('OK', 200);
    }

What happens after the request?

  • An unsuccessful confirmation request produces an error log with the returned status.
  • A successful confirmation produces a structured log with the message ID and topic ARN.
  • HTTP 200 tells SNS that the webhook handled the confirmation message.
  • Finish the controller by pasting the trusted URL method below confirmSubscription():
    private function isTrustedSnsUrl(string $url): bool
    {
        $scheme = parse_url($url, PHP_URL_SCHEME);
        $host = parse_url($url, PHP_URL_HOST);
        $port = parse_url($url, PHP_URL_PORT);

        return $scheme === 'https'
            && is_string($host)
            && ($port === null || $port === 443)
            && preg_match('/^sns\.[a-z0-9-]+\.amazonaws\.com$/iD', $host) === 1;
    }
}

How does the URL allowlist work?

  • The method separates the URL into its scheme and host components.
  • The port must be absent or use the standard HTTPS port.
  • The hostname must match the commercial SNS regional hostname pattern.
  • Save app/Http/Controllers/SnsWebhookController.php using nano's on-screen controls.
  • Exit nano after the file is saved.
  • Check the completed controller syntax by running this command:
php -l app/Http/Controllers/SnsWebhookController.php

What should I see?

PHP should report No syntax errors detected for app/Http/Controllers/SnsWebhookController.php.

🙋‍♀️ Controller syntax check failed?

  • Confirm all five chunks appear once in the order shown.
  • Confirm the controller ends with two closing braces.
  • Use the reported line number when you ask for help with the controller syntax.

The controller now exists. Laravel still needs a route that sends SNS requests to it.

  • Open the existing routes/web.php file in nano by running this command:
nano routes/web.php

What does this command do?

Nano opens the route file that currently contains the waiting receiver and naive SNS closure.

  • Replace the entire contents of routes/web.php with this code:
<?php

use App\Http\Controllers\SnsWebhookController;
use Illuminate\Support\Facades\Route;

Route::get('/receiver', function () {
    return [
        'name' => 'Laravel SNS Receiver',
        'status' => 'waiting',
    ];
});

Route::post('/sns', SnsWebhookController::class);

What does this route file do?

  • The /receiver route keeps the visible waiting response from the previous step.
  • The /sns route now invokes SnsWebhookController for every POST request.
  • Save routes/web.php using nano's on-screen controls.
  • Exit nano after the file is saved.
  • Check the route file syntax by running this command:
php -l routes/web.php

What should I see?

PHP should report No syntax errors detected for routes/web.php.

The route now reaches the secure controller. Laravel must also permit SNS to cross the request-forgery boundary without opening that exception to other paths.

  • Open the existing bootstrap/app.php file in nano by running this command:
nano bootstrap/app.php

What does this command do?

Nano opens Laravel's application bootstrap file. This file configures routing and middleware before requests reach your controller.

  • Replace the entire contents of bootstrap/app.php with this code:
<?php

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware): void {
        $middleware->preventRequestForgery(except: [
            'sns',
        ]);
    })
    ->withExceptions(function (Exceptions $exceptions): void {
        $exceptions->shouldRenderJsonWhen(
            fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
        );
    })->create();

What does this bootstrap configuration do?

  • The existing web routes and health route remain registered.
  • The request-forgery exception applies only to the sns URI.
  • Requests that expect JSON receive JSON exception rendering.
  • Save bootstrap/app.php using nano's on-screen controls.
  • Exit nano after the file is saved.
  • Check the bootstrap file syntax by running this command:
php -l bootstrap/app.php

What should I see?

PHP should report No syntax errors detected for bootstrap/app.php.

✔️ Awesome, I've got everything!

Great. Confirm that all four application files are saved before rebuilding Laravel's optimized caches.

ⓧ I'd like to double check the full code

<?php

return [
    'topic_arn' => 'your-topic-arn-here',
];

This is the complete temporary SNS configuration for this step.

<?php

namespace App\Http\Controllers;

use Aws\Sns\Message;
use Aws\Sns\MessageValidator;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;

class SnsWebhookController extends Controller
{
    public function __invoke()
    {
        try {
            $message = Message::fromRawPostData();
            (new MessageValidator())->validate($message);
        } catch (Throwable $exception) {
            Log::warning('Rejected SNS message.', [
                'reason' => $exception->getMessage(),
            ]);

            return response('Not Found', 404);
        }

        if (! hash_equals((string) config('sns.topic_arn'), (string) $message['TopicArn'])) {
            Log::warning('Rejected SNS message from an unexpected topic.');

            return response('Not Found', 404);
        }

        if ($message['Type'] === 'SubscriptionConfirmation') {
            return $this->confirmSubscription($message);
        }

        if ($message['Type'] === 'Notification') {
            Log::info('SNS notification received.', [
                'message_id' => $message['MessageId'],
                'subject' => $message['Subject'],
                'message' => $message['Message'],
            ]);

            return response('OK', 200);
        }

        return response('Ignored', 200);
    }

    private function confirmSubscription(Message $message)
    {
        $subscribeUrl = (string) $message['SubscribeURL'];

        if (! $this->isTrustedSnsUrl($subscribeUrl)) {
            Log::warning('Rejected an untrusted SNS subscription URL.');

            return response('Not Found', 404);
        }

        try {
            $response = Http::timeout(5)
                ->withOptions(['allow_redirects' => false])
                ->get($subscribeUrl);
        } catch (Throwable $exception) {
            Log::error('SNS subscription confirmation request failed.', [
                'reason' => $exception->getMessage(),
            ]);

            return response('Confirmation Failed', 502);
        }

        if (! $response->successful()) {
            Log::error('SNS subscription confirmation returned an error.', [
                'status' => $response->status(),
            ]);

            return response('Confirmation Failed', 502);
        }

        Log::info('SNS subscription confirmed.', [
            'message_id' => $message['MessageId'],
            'topic_arn' => $message['TopicArn'],
        ]);

        return response('OK', 200);
    }

    private function isTrustedSnsUrl(string $url): bool
    {
        $scheme = parse_url($url, PHP_URL_SCHEME);
        $host = parse_url($url, PHP_URL_HOST);
        $port = parse_url($url, PHP_URL_PORT);

        return $scheme === 'https'
            && is_string($host)
            && ($port === null || $port === 443)
            && preg_match('/^sns\.[a-z0-9-]+\.amazonaws\.com$/iD', $host) === 1;
    }
}

This is the complete signed SNS controller for this step.

<?php

use App\Http\Controllers\SnsWebhookController;
use Illuminate\Support\Facades\Route;

Route::get('/receiver', function () {
    return [
        'name' => 'Laravel SNS Receiver',
        'status' => 'waiting',
    ];
});

Route::post('/sns', SnsWebhookController::class);

This route file preserves the waiting route and directs SNS requests to the controller.

<?php

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware): void {
        $middleware->preventRequestForgery(except: [
            'sns',
        ]);
    })
    ->withExceptions(function (Exceptions $exceptions): void {
        $exceptions->shouldRenderJsonWhen(
            fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
        );
    })->create();

This bootstrap file excludes only the SNS URI from request-forgery protection.

sns.your-domain.com {
    root /srv/laravel-sns/public
    php_fastcgi unix//var/run/php/php8.3-fpm.sock
    file_server
}

The existing Caddy configuration remains unchanged in this step.

Rebuild Laravel and test rejection

Laravel can cache configuration and routes for production. Rebuilding those caches ensures the running application uses the new controller and middleware boundary.

  • Restore the application ownership and writable directories by running these commands:
sudo chown -R ubuntu:www-data /srv/laravel-sns
sudo chmod -R ug+rwX storage bootstrap/cache
php artisan optimize

What do these commands do?

  • The ownership command keeps the SSH user and PHP-FPM group aligned.
  • The permission command lets Laravel write logs and cache files.
  • The optimization command rebuilds Laravel's production caches.

You should see Laravel complete its optimization tasks without a permissions error.

Optimization failed?

  • Confirm the command runs from /srv/laravel-sns.
  • Confirm the storage directory belongs to the www-data group.
  • Use the optimization output when you ask for help with Laravel permissions.
  • Confirm the SNS route points to the controller by running this command:
php artisan route:list --path=sns

What should I see?

The filtered route list should show POST sns pointing to SnsWebhookController.

The previous unsigned request reached Laravel's CSRF boundary. Before you repeat it, do you expect the secure controller to accept or reject the same payload?

  • Send the same unsigned request to the public SNS endpoint by running this command:
curl -i -X POST https://sns.your-domain.com/sns -H 'Content-Type: text/plain' --data '{"test":true}'

What should I see?

You should see HTTP 404. The request now crosses the CSRF boundary and reaches the controller.

The SNS validator rejects the unsigned payload before any topic or message handling occurs.

  • Inspect the latest Laravel log entries by running this command:
tail -n 20 storage/logs/laravel.log

What should I see?

You should see a log entry containing Rejected SNS message..

The response and log prove that an unsigned request cannot reach the notification behavior.

🙋‍♀️ Missing the 404 or rejection log?

  • Run the route check again to confirm POST sns points to the controller.
  • Run the optimization command again after saving every file.
  • Confirm PHP-FPM can write to storage/logs.
  • Share the route output and HTTP status when you ask for help with the rejection test.

That is the security boundary working. Your webhook now rejects traffic that cannot prove it came from SNS.

Your signed receiver is ready for real traffic. Next, you will create an SNS topic and deliver a verified notification through the public endpoint.

Connect Amazon SNS and Deliver a Message

Your signed Laravel webhook is secure and publicly reachable. It remains idle because no trusted publisher has sent an event.

Amazon SNS provides the signed confirmation and notification that complete the delivery path. In this step, you'll connect a topic from the same Region as your Amazon EC2 instance.

In this step, get ready to:
  • Create an Amazon SNS topic and allowlist its exact ARN.
  • Confirm an HTTPS subscription through the signed Laravel receiver.
  • Publish a message and find its structured Laravel log entry.
Create and allowlist the SNS topic

A topic is the source that publishes messages to subscribers. Its exact TopicArn becomes an allowlist value so your controller accepts events from only this topic.

Publishing through this topic uses Amazon SNS's usage-based billing. This step sends only a handful of test requests.

  • Confirm that the selected AWS Region matches the Region containing your EC2 instance.
  • Enter Amazon SNS in the AWS console search bar.
  • Choose Amazon SNS from the search results.

You'll see the Amazon SNS dashboard for the selected Region.

  • Select Topics in the Amazon SNS navigation.
  • Choose Create topic.
  • Select Standard as the topic type.

You'll see the settings for a new Standard topic.

  • Enter laravel-webhook-events in the Name field.
  • Choose Create topic.

The topic's Details section now displays its ARN.

  • Copy the displayed ARN into this field: exact ARN of the laravel-webhook-events SNS topic.
  • Replace your-topic-arn-here in config/sns.php with exact ARN of the laravel-webhook-events SNS topic using the terminal editor from earlier.
  • Save config/sns.php.
  • Clear the old optimized configuration and rebuild Laravel's caches by running:
cd /srv/laravel-sns && php artisan optimize:clear && php artisan optimize

What does this command do?

  • The optimize:clear command removes the cached temporary topic ARN.
  • The optimize command rebuilds Laravel's optimized caches with your exact topic ARN.
  • The controller can now compare each signed message against the current allowlist.
  • Confirm that the terminal reports successful cache rebuilding.

Topic ARN not taking effect?

  • Check that config/sns.php contains the complete ARN shown in the topic's Details section.
  • Check that your-topic-arn-here no longer appears in the file.
  • Still stuck? Help me troubleshoot why Laravel is not using my Amazon SNS topic ARN.

✔️ Awesome, I've got everything!

Your exact topic ARN is saved in config/sns.php and loaded into Laravel's optimized configuration.

ⓧ I'd like to double check the full code

  • Compare your config/sns.php file with this complete reference.
<?php

return [
    'topic_arn' => '[[SNS_TOPIC_ARN="exact ARN of the laravel-webhook-events SNS topic"]]',
];
Create the HTTPS subscription

An HTTPS subscription connects the topic to your public /sns endpoint. Amazon SNS first sends a signed SubscriptionConfirmation message so your controller can request its trusted SubscribeURL.

  • Return to the Amazon SNS console from earlier.
  • Select Subscriptions in the Amazon SNS navigation.
  • Choose Create subscription.
  • Select exact ARN of the laravel-webhook-events SNS topic in the Topic ARN field.
  • Choose HTTPS under Protocol.
  • Enter https://sns.your-domain.com/sns in the Endpoint field.
  • Choose Create subscription.

The confirmation handshake can take a moment while Amazon SNS calls your endpoint. Your controller validates the signed request before following its trusted confirmation URL.

  • Refresh the subscription list.

You'll see a subscription ID in place of PendingConfirmation.

That's the handshake complete. Your topic can now deliver signed notifications to Laravel.

Subscription still pending?

  • Check that the endpoint is exactly https://sns.your-domain.com/sns.
  • Compare the selected topic ARN with the value saved in config/sns.php.
  • Review the Laravel log for a rejected confirmation or a failed confirmation request.
  • Still stuck? Help me troubleshoot an Amazon SNS HTTPS subscription that remains pending.
Publish and inspect a notification

A confirmed subscription turns each publication into a signed Notification request. Your controller records the message fields as structured log context after the signature and topic checks pass.

  • Return to Topics in Amazon SNS.
  • Select laravel-webhook-events.
  • Choose Publish message.
  • Choose Identical payload for all delivery protocols.
  • Enter Hello from Amazon SNS in the message body.
  • Choose Publish message.
  • Switch back to the EC2 SSH session from earlier.

Before you inspect the log, do you expect the newest entry to show a rejection or an accepted notification?

  • Inspect the latest Laravel log entries by running:
tail -n 30 /srv/laravel-sns/storage/logs/laravel.log

What does this command do?

  • The tail command reads entries from the end of Laravel's application log.
  • The -n 30 option keeps the output focused on the newest thirty lines.
  • This view shows the notification generated by your latest publication.

You'll see SNS notification received. with structured context containing message_id, subject, and message. The message value contains Hello from Amazon SNS.

You now have the complete delivery path working. Amazon SNS can publish a signed event that your topic-restricted Laravel controller accepts and logs.

Notification missing from the log?

  • Confirm that the subscription shows an ID instead of PendingConfirmation.
  • Confirm that you published from the laravel-webhook-events topic.
  • Compare the topic ARN in Amazon SNS with the value in config/sns.php.
  • Still stuck? Help me find why my signed Amazon SNS notification is missing from Laravel's log.

Secret mission

Ignore Duplicate SNS Deliveries

Amazon SNS can retry a failed delivery with the same MessageId. Add an atomic cache guard that acknowledges repeated deliveries without processing them twice.

Clean Up Your Resources

Clean Up Your Resources

Your AWS infrastructure can continue accruing charges after you finish this project. Decide whether to keep the webhook live, stop the EC2 instance for a break, or delete the resources entirely.

Cost warning

Amazon EC2 charges vary by Region and instance choice. Amazon EBS storage charges continue while the root volume exists.

Your Elastic IP costs $0.005 per hour while it remains allocated.

Amazon SNS has no upfront commitment. Its requests and deliveries are usage-based.

Choose Pause to reduce compute charges. Choose Delete to stop charges from the project resources.

Resources you used:

  • Ubuntu Server 24.04 LTS EC2 instance with its EBS root volume.
  • Security group permitting SSH from your IP plus public HTTP and HTTPS traffic.
  • Elastic IP associated with the EC2 instance.
  • Cloudflare DNS record for sns.your-domain.com.
  • SNS Standard topic named laravel-webhook-events.
  • Confirmed SNS HTTPS subscription targeting https://sns.your-domain.com/sns.
  • Laravel application at /srv/laravel-sns with its Caddy configuration and PHP-FPM service.

Keep everything running

No action is needed if you want to keep demonstrating the webhook. Your Laravel endpoint remains live at https://sns.your-domain.com/sns.

  • The EC2 instance continues serving the application through Caddy and PHP-FPM.
  • The EBS root volume keeps the Laravel application at /srv/laravel-sns.
  • The Elastic IP continues incurring the public IPv4 charge.
  • The laravel-webhook-events topic remains connected to its confirmed HTTPS subscription.
  • The Cloudflare DNS-only record continues directing sns.your-domain.com to the Elastic IP.

Pause - I'll come back to this later

Stopping the EC2 instance pauses its compute workload while preserving the server. The webhook stays offline until you start the instance again.

  • Return to Amazon EC2 in the same AWS Region as your SNS topic.
  • Select the Ubuntu Server 24.04 LTS instance.
  • Use the instance state controls to stop the instance.
  • Confirm the stop request.
  • Refresh the instance list until the instance reaches its stopped state.

You'll see the instance enter its stopped state. The public webhook becomes unavailable.

The EBS root volume continues incurring storage charges. The Elastic IP continues incurring the public IPv4 charge while the instance is stopped.

  • Start the same instance from its state controls when you return.
  • Visit https://sns.your-domain.com/up after the instance reaches its running state.

You'll see Laravel's health response once Caddy and PHP-FPM are serving requests again.

Delete - I don't want to use this again

Deletion is permanent. This ordered cleanup removes the public webhook without leaving the project resources behind.

  • Return to Amazon SNS in the same AWS Region as the EC2 instance.
  • Select Subscriptions.
  • Select the confirmed subscription targeting https://sns.your-domain.com/sns.
  • Delete the selected subscription.
  • Select Topics.
  • Select the laravel-webhook-events topic.
  • Delete the selected topic.

The subscription and topic no longer appear in Amazon SNS. SNS can no longer send new requests to the webhook.

  • Return to DNS Records for your domain in Cloudflare.
  • Find the DNS-only A record for sns.your-domain.com.
  • Delete that DNS record.

The hostname no longer points visitors or webhook traffic to the Elastic IP.

  • Return to Amazon EC2 in the same AWS Region.
  • Select the Ubuntu Server 24.04 LTS instance.
  • Inspect the root volume's deletion-on-termination setting.
  • Enable deletion on termination if the setting is disabled.
  • Terminate the EC2 instance.
  • Confirm the termination request.
  • Refresh the instance list until the instance reaches its terminated state.

The instance termination removes the Laravel application at /srv/laravel-sns plus the Caddy configuration at /etc/caddy/Caddyfile.

  • Check the Amazon EBS volume list after the instance terminates.
  • Delete the former root volume if it remains in the volume list.
  • Release the Elastic IP from Amazon EC2.
  • Delete the project security group from Amazon EC2.

You should see no remaining root volume, Elastic IP, or project security group for this webhook. The project can no longer incur charges from those deleted resources.

Nice Work!

Nice Work!

You made it! Your signed Laravel webhook on Amazon EC2 now receives verified Amazon SNS notifications over trusted HTTPS.

You've learned how to:

  • Run Laravel on Amazon EC2 through PHP-FPM. Serve its public directory with Caddy over trusted HTTPS.
  • Protect the webhook boundary by excluding only /sns from CSRF protection. Validate the SNS signature before checking the allowlisted TopicArn. Restrict subscription confirmation to a trusted SubscribeURL.
  • Publish a real message through publish-subscribe messaging. Trace the delivery through the confirmed HTTPS subscription. Find the structured notification in Laravel's application log.
  • Complete the optional Secret Mission by adding an atomic MessageId cache guard. A repeated delivery now receives HTTP 200 without running notification processing twice.

Ready to quiz yourself?