Horizon

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.

app/api/webhooks/pinwheel/route.ts
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.

.env.local
PINWHEEL_API_SECRET=replace-with-your-api-secret

Start the app on port 3000.

npm run dev

Start 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-app
Output
HORIZON: Tunnel connected
  URL          https://my-app.hrzn.run (reserved)
  Forwarding   http://localhost:3000
  Request log  https://hrzn.run/dashboard/tunnels/my-app

Your 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:

Output
  POST    200  /api/webhooks/pinwheel

Your app terminal prints:

Output
Received Pinwheel event: <event id>

If the line shows [401], see Troubleshooting.

Troubleshooting

The signature doesn't match

  • Check that PINWHEEL_API_SECRET is 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.parse and JSON.stringify first, because that can change the bytes.
  • Restart npm run dev after 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 for Reconnected.
  • Check that the event you trigger is in enabled_events.

Next steps

On this page