Horizon

Test Amazon SNS HTTPS subscriptions locally

Receive Amazon SNS messages on localhost with a Horizon tunnel, confirm the subscription, and verify the message signature in Next.js.

Receive Amazon SNS messages on your laptop while you build, with a public HTTPS URL SNS can reach.

Horizon has no AWS integration. SNS sends messages to an HTTPS endpoint, and Horizon provides that URL.

SNS signs differently from most webhook providers. There is no shared secret. SNS signs each message with an RSA key (SHA256 for signature version 2, SHA1 for version 1) and sends the signature and a certificate URL in the JSON body. Use a validator that fetches and checks the certificate for you.

Before you begin

  • Node.js 18 or later
  • A Horizon account and the CLI (see Getting started)
  • A reserved subdomain for -s. Reserve one on the Subdomains page.
  • An AWS account with an Amazon SNS topic
  • A Next.js app that uses the App Router and runs on port 3000

Start your app

Install sns-validator, the validator from the AWS JavaScript team, and its types:

npm install sns-validator
npm install -D @types/sns-validator

SNS sends three message types, named in the x-amz-sns-message-type header and in the Type field of the body: SubscriptionConfirmation, Notification and UnsubscribeConfirmation. The body is JSON, but the Content-Type is text/plain, so read it with request.text() and parse it yourself.

The handler validates the signature, rejects any other topic, and confirms the subscription by sending a GET request to SubscribeURL. AWS says to reject any message with an unexpected TopicArn.

app/api/webhooks/sns/route.ts
import { promisify } from "node:util";
import MessageValidator from "sns-validator";

const validator = new MessageValidator();
const validateMessage = promisify(validator.validate.bind(validator));

export async function POST(request: Request) {
  const expectedTopicArn = process.env.SNS_TOPIC_ARN;
  if (!expectedTopicArn) {
    return new Response("Missing SNS_TOPIC_ARN", { status: 500 });
  }

  let message: Record<string, unknown> | undefined;
  try {
    message = await validateMessage(JSON.parse(await request.text()));
  } catch {
    return new Response("Invalid signature", { status: 403 });
  }

  if (message?.TopicArn !== expectedTopicArn) {
    return new Response("Unexpected topic", { status: 403 });
  }

  if (message.Type === "SubscriptionConfirmation") {
    await fetch(String(message.SubscribeURL));
    console.log("Confirmed SNS subscription");
    return new Response("ok", { status: 200 });
  }

  if (message.Type === "Notification") {
    console.log(`Received SNS message ${message.MessageId}: ${message.Message}`);
  }

  return new Response("ok", { status: 200 });
}

Add your topic's ARN (Amazon Resource Name) to .env.local:

.env.local
SNS_TOPIC_ARN=arn:aws:sns:us-east-1:123456789012:my-topic

Start the app:

npm run dev

Start a tunnel

Start the tunnel before you subscribe, because SNS sends the confirmation message when you create the subscription. Use -s with a subdomain you reserved:

hrzn tunnel http://localhost:3000 -s my-sns-app
Output
HORIZON: Tunnel connected
  URL          https://my-sns-app.hrzn.run (reserved)
  Forwarding   http://localhost:3000
  Request log  https://hrzn.run/dashboard/tunnels/my-sns-app

Your public URL is https://my-sns-app.hrzn.run. Without -s the subdomain is random and changes on every run, so you would subscribe a new URL after each restart. Reserved subdomains are a paid feature, see Pricing.

Subscribe the endpoint

Your endpoint URL is the tunnel URL plus the route path: https://my-sns-app.hrzn.run/api/webhooks/sns.

  1. Sign in to the Amazon SNS console.
  2. In the navigation pane, choose Subscriptions.
  3. Choose Create subscription.
  4. For Topic ARN, choose your topic.
  5. For Protocol, select HTTPS.
  6. For Endpoint, paste the endpoint URL.
  7. Choose Create subscription.

The new subscription shows PendingConfirmation. SNS has sent a SubscriptionConfirmation message to your handler, which visits SubscribeURL. Refresh the Subscriptions page. The subscription now shows its subscription ARN.

SNS doesn't send notifications until the subscription is confirmed.

Publish a message

SNS has no resend button for notifications. Publish a new message to the topic:

  1. In the SNS console, choose Topics, then choose your topic.
  2. Choose Publish message.
  3. Enter a Subject and a Message, then choose Publish message.

Or use the AWS CLI:

aws sns publish --topic-arn arn:aws:sns:us-east-1:123456789012:my-topic --message "Hello from SNS"

Check it works

During the subscribe step, the Horizon terminal prints one line for the confirmation:

Output
  POST    200  /api/webhooks/sns

Your app terminal prints Confirmed SNS subscription. After you publish a message, Horizon prints another POST 200 line, and your app terminal prints:

Output
Received SNS message 22b80b92-fdea-4c2c-8f9d-bdfb0c7bf324: Hello from SNS

Troubleshooting

The subscription stays in PendingConfirmation

SNS sent the confirmation before your app and tunnel were ready, or the handler rejected it.

  • Check that npm run dev and the Horizon terminal are running.
  • Check that SNS_TOPIC_ARN matches your topic exactly. The handler returns 403 for any other topic.
  • Subscribe again. SNS retries a failed delivery, so you may see the confirmation arrive more than once.

The handler returns 403 for a real message

  • Check the Horizon line. A 403 means the signature check failed or the TopicArn differs from SNS_TOPIC_ARN.
  • Don't change the body before you validate it. The signature covers the original Message and Subject values.
  • If a message contains multibyte characters, set validator.encoding = "utf8", as the validator's README describes.

The URL changed after a restart

You started the tunnel without -s, so Horizon gave you a new random subdomain. The subscription still points at the old URL. Restart with -s my-sns-app, or subscribe the new URL.

Messages stop arriving

SNS treats 5xx and 429 responses as retryable. Any other error status counts as a permanent failure and SNS doesn't retry. Check that the Horizon terminal and npm run dev are running, then publish a new message.

Next steps

On this page