Horizon

Test Square webhooks locally

Receive Square webhook events on localhost with a Horizon tunnel, and verify the x-square-hmacsha256-signature header.

Receive Square events on your laptop while you build, with a public HTTPS URL Square can reach.

Horizon has no Square integration. Square sends webhooks to a public URL, and Horizon provides that URL.

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.
  • A Square developer account with an application in the Developer Console
  • A Next.js app that uses the App Router and runs on port 3000

Start your app

Square signs every notification with HMAC-SHA-256 (a keyed hash). It hashes three things: your subscription's signature key, the notification URL and the raw request body. The result arrives in the x-square-hmacsha256-signature header. The Square Node.js SDK ships WebhooksHelper.verifySignature to check it, so install the square package:

npm install square

Create the route handler. Read the raw body with request.text() before you parse it. The helper needs the exact notification URL that you register in Square, so you keep it in an environment variable.

app/api/webhooks/square/route.ts
import { WebhooksHelper } from "square";

export async function POST(request: Request) {
  const signatureKey = process.env.SQUARE_WEBHOOK_SIGNATURE_KEY;
  const notificationUrl = process.env.SQUARE_NOTIFICATION_URL;
  if (!signatureKey || !notificationUrl) {
    return new Response("Missing Square webhook settings", { status: 500 });
  }

  const body = await request.text();
  const signature = request.headers.get("x-square-hmacsha256-signature") ?? "";

  const isFromSquare = await WebhooksHelper.verifySignature({
    requestBody: body,
    signatureHeader: signature,
    signatureKey,
    notificationUrl,
  });

  if (!isFromSquare) {
    return new Response("Invalid signature", { status: 403 });
  }

  const event = JSON.parse(body) as { type: string; event_id: string };
  console.log(`Received Square event: ${event.type} (${event.event_id})`);

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

Square asks for a 2xx response as soon as possible. If it gets none within 10 seconds, it sends the event again. Keep the handler fast.

Add the notification URL to .env.local. You add the signature key in a later step.

.env.local
SQUARE_NOTIFICATION_URL=https://my-square-app.hrzn.run/api/webhooks/square

Start the app:

npm run dev

Start a tunnel

In a second terminal, open a tunnel to port 3000 on a subdomain you reserved, with -s:

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

Use -s. Without it, the subdomain is random and changes on every run, and you would have to edit the Square subscription each time you restart. Reserved subdomains are a paid feature, see Pricing.

Add the subscription in Square

Square checks that the notification URL is reachable when you save. Keep the tunnel and npm run dev running.

  1. Open the Developer Console and choose Open for your application.
  2. In the left pane, under Webhooks, choose Subscriptions.
  3. Choose Add subscription.
  4. Enter a name for the webhook and set the notification URL to https://my-square-app.hrzn.run/api/webhooks/square.
  5. Choose an API version that includes the events you want, choose the events, and then choose Save. For a first test, choose customer.created.
  6. Under Subscriptions, choose the name of the webhook you created to open the Endpoint Details page.
  7. In Endpoint Details, choose Show in the Signature Key box and copy the key.

Verify the signature

Add the signature key to .env.local:

.env.local
SQUARE_NOTIFICATION_URL=https://my-square-app.hrzn.run/api/webhooks/square
SQUARE_WEBHOOK_SIGNATURE_KEY=replace-with-your-signature-key

Restart npm run dev so Next.js loads the new variable.

Check it works

Square's tutorial triggers an event from the API Explorer. Use it to send a customer.created event:

  1. Open the API Explorer from the Developer Console.
  2. Call the CreateCustomer endpoint in the Customers API. Provide a first or last name, a company name, an email address, or a phone number.

The Horizon terminal prints one line for the request:

Output
  POST    200  /api/webhooks/square

Your app terminal prints:

Output
Received Square event: customer.created (<event id>)

Every Square notification carries a square-environment header. Its value is Production or Sandbox.

Troubleshooting

The signature doesn't match

The Horizon line shows [403]. Check these in order:

  1. The URL. SQUARE_NOTIFICATION_URL must equal the notification URL of the subscription exactly, including https:// and the path.
  2. The key. SQUARE_WEBHOOK_SIGNATURE_KEY must be the signature key of this subscription. Each subscription has its own key.
  3. The body. Hash the raw body. Don't call request.json() first.
  4. The restart. Restart npm run dev after you edit .env.local.

Square says the URL is not valid

Square shows Your webhook for webhook was not created. URL is not valid. when you choose Save. The notification URL must be formatted correctly, use HTTPS and be reachable. Check that hrzn tunnel and npm run dev are both running.

The URL changed after a restart

You started the tunnel without -s, so Horizon gave you a new random subdomain. Square still sends events to the old URL. Restart with -s, and update the subscription and SQUARE_NOTIFICATION_URL if the URL differs.

Square sends the same event again

Square resends an event when it gets no 2xx response within 10 seconds. Retried notifications carry the square-retry-number and square-retry-reason headers. The reason http_timeout means your server took longer than 10 seconds. Return the response first and do slow work afterwards.

Next steps

On this page