Test Square Webhooks Locally
Your handler returns 403 and the forum thread you found is titled "Webhook Signature Mismatch". On Square that is usually not the key, it is the URL, because Square hashes the notification URL together with the body. Get one HTTPS URL that stays the same, read the exact x-square-hmacsha256-signature and raw bytes Square sent, and replay that delivery until the compare returns true.
30-second setup
From a stuck handler to a live Square delivery you can read and replay.
- 1Start LocalCan and point it at your local server:
localcan http 8000. It opens the tunnel and prints your.localdomains plus a public HTTPS URL. - 2Grab your persistent URL, something like
your-app-12.localcan.dev. It is saved to your project file and comes back identical after every restart, so you wire it up once. - 3Open the Developer Console (
developer.squareup.com/apps), choose Open on your application, and set the Sandbox / Production toggle at the top of the page. In the left pane, under Webhooks, choose Subscriptions → Add subscription. Enter a name and your LocalCan URL with your route, e.g.https://your-app-12.localcan.dev/square/webhook, pick an API version that covers the events you want, select them, and choose Save. Then reopen the subscription and choose Show in the Signature Key box on Endpoint Details. - 4On the Webhook subscriptions page for your application (
developer.squareup.com/apps), choose your subscription, and in the Endpoint details pane choose More → Send test event. Pick an event and choose Send: the window reports the response code and the body it delivered. For real events, drive the Sandbox from API Explorer. ACreatePaymentcall fires the matching notification. - 5Open Inspect Traffic in LocalCan. The delivery lands with full headers and body. Expand it, fix your handler, and hit Replay to send the exact same request again. No new test event needed.
Why localhost can't receive Square webhooks
Square delivers notifications from its own servers, out of a fixed set of published IP addresses, to whatever URL your subscription registers. localhost:8000 resolves only on your machine, so there is nothing at the far end to POST to.
Square is stricter than most providers here, and it tells you early: the Console validates the URL as you press Save, so a localhost address never gets as far as a failed delivery, and ssl_error is a documented retry reason, which rules out a self-signed certificate.
Register the endpoint (and pass Square’s validation)
Square saysYour webhook for webhook was not created. URL is not valid
Square checks the notification URL synchronously when you choose Save: it "must be formatted correctly, use the HTTPS protocol, and be reachable". Miss any of the three and the subscription is never created, so there is nothing to debug against. Start your app, run localcan http 8000, confirm the …localcan.dev URL answers, then choose Add subscription.
Changing that URL later is not editing a field, it is re-validating a new one. A LocalCan URL is written into your project file and comes back identical after a restart, so you clear this gate once.
What makes the Square loop fast
The URL is part of the signature
Square is one of the few providers where the notification URL is an input to the HMAC, not just an address. Change the host and deliveries keep arriving perfectly (Square logs your 2xx, the payload is intact) while every verification call returns false, because the digest was computed over a URL string your code no longer builds.
That is the failure behind most of the "signature mismatch" threads on Square’s own forums. A persistent …localcan.dev address pins the string in Add subscription to the string in your verify call, once. Pin a custom domain and it survives the move to staging too.
Read the header, the bytes, and the environment
Diagnosing a mismatch needs three things at once, and the inspector puts them on one screen: the literal x-square-hmacsha256-signature, the body exactly as it arrived before any parser touched it, and square-environment.
That last header settles an argument you cannot win from a stack trace. Sandbox and Production are separate subscriptions with separate keys, and a second subscription on the same application will deliver the same event signed with the other one. From inside the handler both look identical. square-retry-number is worth a glance too: if it is there, you are seeing a resend of something you already failed.
Square has no resend button
Webhook Logs in the Developer Console are read-only: 28 rolling days of headers, payload, status code and retry reason, with no way to send any of it again. Re-running one payment.created against a fixed handler otherwise means taking another Sandbox payment.
Replay closes that gap, re-sending the captured request byte for byte on demand. It also takes the pressure off the response budget: Square records http_timeout past ten seconds and then retries for 24 hours, so a breakpoint in the handler both fails the delivery and keeps re-firing it.
Inspect & replay Square events
Common events
payment.createda payment is taken, in person or onlinepayment.updatedstatus moves, e.g. APPROVED → COMPLETEDrefund.createda refund is initiated against a paymentorder.fulfillment.updatedpickup or delivery state changesinvoice.payment_madean invoice is paid, in full or partinventory.count.updatedstock moves at a locationoauth.authorization.revokeda seller disconnects your app
These are the subscriptions most Square integrations start with. The last is the trap: nobody tests a seller disconnecting, so oauth.authorization.revoked tends to be discovered in production.
Fire one with Send test event or from API Explorer and it lands in Inspect Traffic with the full header set and JSON body. Expand it for type, data.object, and event_id, the idempotency value Square expects you to key on. When the handler throws, fix it and hit Replay, which is also how you test that idempotency rule: re-send the identical event_id and confirm the second pass is a no-op.
- Timeout
- Square publishes no timeout for the first attempt beyond "as soon as possible". The one number it documents is the
http_timeoutretry reason: the server took longer than 10 seconds to respond. - Retries
- 11 attempts with exponential backoff over 24 hours: 1, 2, 4, 8, 16, 32 minutes, then 60 minutes, 2h, 4h, 8h, 8h. After 24 hours the notification is discarded.
- Auto-disable
- None documented. A failing subscription keeps receiving events, and the documented way to recover missed ones is the Events API, not a resend.
Verify the Square request
- Header
- x-square-hmacsha256-signature
- Algorithm
- HMAC-SHA-256, base64-encoded
- What's signed
- Square signs the notification URL immediately followed by the raw request body. No separator, nothing time-based anywhere in the input. The key is your subscription’s signature key as plain UTF-8 bytes, no prefix to strip and no base64 to decode, and the digest is base64. The URL must be byte-identical to the one saved on the subscription, so a
httpforhttpsor an added trailing slash breaks the compare while the request looks perfect. Square names the three inputs but leaves the order to the SDK helper: this concatenation is whatWebhooksHelper.verifySignaturecomputes. - Secret
- The signature key from Developer Console → your application → Webhooks → Subscriptions → your subscription → Endpoint Details, where you choose Show in the Signature Key box (
developer.squareup.com/apps). No prefix, one key per subscription, and Sandbox and Production hold entirely different ones behind the environment toggle.
import express from 'express'
import crypto from 'crypto'
// If you already have the SDK, this is the one-liner Square documents:
// await WebhooksHelper.verifySignature({ requestBody, signatureHeader,
// signatureKey, notificationUrl })
// The code below is the same computation spelled out, because Square documents
// the helper rather than the string it hashes.
// Must match the subscription's notification URL byte for byte. It is hashed.
const NOTIFICATION_URL = 'https://your-app-12.localcan.dev/square/webhook'
const SIGNATURE_KEY = process.env.SQUARE_WEBHOOK_SIGNATURE_KEY
function isFromSquare(rawBody, signatureHeader) {
if (!signatureHeader) return false
// notification URL + raw body, concatenated with no separator. No timestamp.
// The signature key is the HMAC key as-is: no prefix strip, no base64 decode.
const payload = NOTIFICATION_URL + rawBody
const expected = crypto
.createHmac('sha256', SIGNATURE_KEY)
.update(payload, 'utf8')
.digest('base64')
// Square's docs ask for a constant-time compare here; its own SDK uses ===.
const a = Buffer.from(expected)
const b = Buffer.from(signatureHeader)
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
const app = express()
// Raw bytes only. express.json() parses and re-serializes, which changes the
// string being hashed. The classic case is a description field with newlines.
app.post('/square/webhook', express.raw({ type: '*/*' }), (req, res) => {
const rawBody = req.body.toString('utf8')
const signature = req.headers['x-square-hmacsha256-signature']
if (!isFromSquare(rawBody, signature)) {
return res.status(403).end() // discard: it did not come from Square
}
const event = JSON.parse(rawBody)
// Acknowledge inside the 10-second budget, then do slow work off the request.
// event.event_id is the idempotency value: key your writes on it.
res.status(200).end()
// ... then handle event.type / event.data.object off the request path
})LocalCan does not verify the signature for you. isFromSquare above, or WebhooksHelper.verifySignature, does that. What it does is show every input the digest depends on, which is what separates the three failures that all surface as one 403: the header and the untouched body are on screen, square-environment says which key applied, and the URL is the one printed in your LocalCan project rather than a guess. Fix the handler, hit Replay, and the same request answers the question again.
A replayed capture verifies indefinitely. Square signs no timestamp and documents no tolerance window (square-initial-delivery-timestamp is a header, not an HMAC input), so a delivery captured last week still passes today, the opposite of Stripe or Svix. Two things do invalidate it: rotating the subscription’s signature key, and changing the notification URL, since the old digest was computed over the old string.
LocalCan vs the Square Developer Console
There is no Square equivalent of stripe listen. Nothing on Square’s own Developer Tools page relays an event to a local port, and Square conceded the point years ago by publishing a tunnel walkthrough on its developer blog. The square-cli in search results is a community project for calling Connect APIs, with no commits since 2023.
So the real comparison is against what the Console does give you: Send test event, and the read-only Webhook Logs. Both win rows below.
| LocalCan | the Square Developer Console | |
|---|---|---|
| Stable notification URL | Persistent …localcan.dev, so the hashed URL never moves | Stores whatever URL you paste; you supply it |
| Fire a synthetic event | Nothing of its own: use the Console | Send test event, one synthetic delivery |
| Re-send a real delivery | One-click Replay of the exact captured request | No resend button anywhere in the Console |
| Deliveries while your machine was off | Not captured: nothing was listening | Webhook Logs keeps a rolling 28 days |
| Why Square marked it failed | Sees your response, not Square’s verdict | square-retry-reason: http_timeout, ssl_error, … |
| Inspect the raw signature + body | GUI inspector, live, headers and untouched bytes | Logs show headers and payload, after the fact |
| Works across providers | One URL for Square, Stripe, GitHub… | Square only |
| Share with a teammate | Shareable URL + password or secret link | Needs Console access to your application |
Square webhook FAQ
Test webhooks for other providers
- Test Stripe Webhooks Locally →
- Test GitHub Webhooks Locally →
- Test Shopify Webhooks Locally →
- Test Twilio Webhooks Locally →
- Test Slack Webhooks Locally →
- Test Clerk Webhooks Locally →
- Test Supabase Webhooks Locally →
- Test GitLab Webhooks Locally →
- Test OpenAI Webhooks Locally →
- Test Discord Webhooks Locally →
- Test Resend Webhooks Locally →