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 squareCreate 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.
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.
SQUARE_NOTIFICATION_URL=https://my-square-app.hrzn.run/api/webhooks/squareStart the app:
npm run devStart 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-appHORIZON: Tunnel connected
URL https://my-square-app.hrzn.run (reserved)
Forwarding http://localhost:3000
Request log https://hrzn.run/dashboard/tunnels/my-square-appUse -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.
- Open the Developer Console and choose Open for your application.
- In the left pane, under Webhooks, choose Subscriptions.
- Choose Add subscription.
- Enter a name for the webhook and set the notification URL to
https://my-square-app.hrzn.run/api/webhooks/square. - Choose an API version that includes the events you want, choose the events, and then choose Save. For a first test, choose
customer.created. - Under Subscriptions, choose the name of the webhook you created to open the Endpoint Details page.
- In Endpoint Details, choose Show in the Signature Key box and copy the key.
The notification URL is part of what Square signs. The value in SQUARE_NOTIFICATION_URL must match the URL in the subscription character for character.
Verify the signature
Add the signature key to .env.local:
SQUARE_NOTIFICATION_URL=https://my-square-app.hrzn.run/api/webhooks/square
SQUARE_WEBHOOK_SIGNATURE_KEY=replace-with-your-signature-keyRestart 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:
- Open the API Explorer from the Developer Console.
- Call the
CreateCustomerendpoint 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:
POST 200 /api/webhooks/squareYour app terminal prints:
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:
- The URL.
SQUARE_NOTIFICATION_URLmust equal the notification URL of the subscription exactly, includinghttps://and the path. - The key.
SQUARE_WEBHOOK_SIGNATURE_KEYmust be the signature key of this subscription. Each subscription has its own key. - The body. Hash the raw body. Don't call
request.json()first. - The restart. Restart
npm run devafter 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
- Read Square's guide to verifying and validating an event notification.
- See the other events you can subscribe to in the Webhook Events Reference.
- Reserve a subdomain so your URL never changes: see pricing.