Test Pinwheel webhooks locally
Receive Pinwheel webhook events on localhost with a Horizon tunnel, and verify the x-pinwheel-signature header.
Receive Pinwheel events on your laptop while you build, with a URL Pinwheel can reach.
Pinwheel has no dashboard form for webhooks. You register the endpoint with the Pinwheel API.
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 Next.js app that uses the App Router and runs on port 3000
- A Pinwheel API secret for the environment you test in
Start your app
Create a route handler. Pinwheel sends two headers: x-pinwheel-signature and x-timestamp. The signature looks like v2=<hex digest>. Pinwheel builds it with HMAC-SHA256, keyed with your API secret, over the bytes of v2:<timestamp>: followed by the raw request body. Read the raw body with request.arrayBuffer() and compare with crypto.timingSafeEqual.
import { createHmac, timingSafeEqual } from "node:crypto";
export async function POST(request: Request) {
const apiSecret = process.env.PINWHEEL_API_SECRET;
if (!apiSecret) {
return new Response("Missing PINWHEEL_API_SECRET", { status: 500 });
}
const rawBody = Buffer.from(await request.arrayBuffer());
const signature = request.headers.get("x-pinwheel-signature") ?? "";
const timestamp = request.headers.get("x-timestamp") ?? "";
const message = Buffer.concat([Buffer.from(`v2:${timestamp}:`, "utf8"), rawBody]);
const digest = createHmac("sha256", Buffer.from(apiSecret, "utf8")).update(message).digest("hex");
const receivedBuffer = Buffer.from(signature);
const expectedBuffer = Buffer.from(`v2=${digest}`);
const isValid =
receivedBuffer.length === expectedBuffer.length &&
timingSafeEqual(receivedBuffer, expectedBuffer);
if (!isValid) {
return new Response("Invalid signature", { status: 401 });
}
const event = JSON.parse(rawBody.toString("utf8")) as { event_id?: string };
console.log(`Received Pinwheel event: ${event.event_id}`);
return new Response("ok", { status: 200 });
}Pinwheel gives each event a unique event_id. Use it to ignore duplicates.
Add your API secret to .env.local.
PINWHEEL_API_SECRET=replace-with-your-api-secretStart the app on port 3000.
npm run devStart a tunnel
Use -s with a subdomain you reserved. Without it, the subdomain is random and changes every run, so the webhook you register would point at a dead URL after a restart. Reserved subdomains are a paid feature, see Pricing.
hrzn tunnel http://localhost:3000 -s my-appHORIZON: Tunnel connected
URL https://my-app.hrzn.run (reserved)
Forwarding http://localhost:3000
Request log https://hrzn.run/dashboard/tunnels/my-appYour public URL is https://my-app.hrzn.run. Keep this terminal open.
Register the endpoint with the Pinwheel API
Send a POST request to /v1/webhooks with your API secret in the x-api-secret header. Set url to your endpoint, status to active, and enabled_events to the events you want. Use the API base URL for your environment from the Pinwheel docs.
curl -X POST "$PINWHEEL_API_URL/v1/webhooks" \
-H "x-api-secret: $PINWHEEL_API_SECRET" \
-H "Content-Type: application/json" \
-d '{
"url": "https://my-app.hrzn.run/api/webhooks/pinwheel",
"status": "active",
"enabled_events": ["account.added"]
}'Pinwheel allows 10 registered webhooks per API key. Delete one you don't use if the request returns a quota error.
Trigger an event
Trigger a real event in the sandbox. Complete the Pinwheel Link flow with a test user, and Pinwheel sends events such as account.added to your endpoint. In the sandbox, a job with a pending outcome moves to an error outcome 60 seconds after the Link flow completes. You get one event for each outcome.
Check it works
Your Horizon terminal prints one line for each event:
POST 200 /api/webhooks/pinwheelYour app terminal prints:
Received Pinwheel event: <event id>If the line shows [401], see Troubleshooting.
Troubleshooting
The signature doesn't match
- Check that
PINWHEEL_API_SECRETis the API secret Pinwheel signs with, with no extra spaces or newline. - Build the message as
v2:<timestamp>:plus the raw body bytes. Note the colon after the timestamp. - Read the raw bytes. Don't run
JSON.parseandJSON.stringifyfirst, because that can change the bytes. - Restart
npm run devafter you edit.env.local.
The request returns 404
The Horizon line shows [404]. The registered URL doesn't match your route. The file app/api/webhooks/pinwheel/route.ts serves /api/webhooks/pinwheel, and Pinwheel sends POST requests.
The URL changed after a restart
You started the tunnel without -s, so Horizon gave you a new random subdomain. Restart with -s my-app and the URL stays the same. -s needs a subdomain you reserved, see Pricing.
Pinwheel stopped sending events
Pinwheel pauses an endpoint that doesn't return 200 OK for 30 consecutive days. It waits up to 15 seconds for a response. For a 5xx response or a network failure, it retries up to 5 times in the 15 minutes after the first attempt. Keep the handler fast.
Nothing reaches your app
- Check that the Horizon terminal is still running. If its last line is
Connection lost. Reconnecting…, wait forReconnected. - Check that the event you trigger is in
enabled_events.
Next steps
- Read Pinwheel's webhook signature verification guide.
Test Modern Treasury webhooks locally
Receive Modern Treasury webhook events on localhost with a Horizon tunnel, and verify the X-Signature header with the official Node SDK.
Test Square webhooks locally
Receive Square webhook events on localhost with a Horizon tunnel, and verify the x-square-hmacsha256-signature header.