Horizon

Test AfterShip webhooks locally

Receive AfterShip Tracking webhook events on localhost with a Horizon tunnel, and verify the aftership-hmac-sha256 signature.

Receive AfterShip Tracking events on your laptop while you build, with a URL AfterShip can reach.

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
  • An AfterShip Tracking account

Start your app

Create a route handler that checks the aftership-hmac-sha256 header. AfterShip computes an HMAC (a keyed hash) of the request body with SHA-256 and your webhook secret, and sends the digest base64-encoded. Read the raw body with request.text() and compare with crypto.timingSafeEqual.

AfterShip treats any response outside 200 to 299 as a failure.

app/api/webhooks/aftership/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";

export async function POST(request: Request) {
  const secret = process.env.AFTERSHIP_WEBHOOK_SECRET;
  if (!secret) {
    return new Response("Missing AFTERSHIP_WEBHOOK_SECRET", { status: 500 });
  }

  const body = await request.text();
  const received = request.headers.get("aftership-hmac-sha256") ?? "";
  const expected = createHmac("sha256", secret).update(body).digest("base64");

  const receivedBuffer = Buffer.from(received);
  const expectedBuffer = Buffer.from(expected);
  const isValid =
    receivedBuffer.length === expectedBuffer.length &&
    timingSafeEqual(receivedBuffer, expectedBuffer);

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

  console.log(`Received AfterShip webhook: ${body.length} bytes`);

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

Add your webhook secret to .env.local. You find it in a later step.

.env.local
AFTERSHIP_WEBHOOK_SECRET=replace-with-your-webhook-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 your AfterShip webhook 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.

Add the webhook in AfterShip

Your webhook URL is the tunnel URL plus the route path: https://my-app.hrzn.run/api/webhooks/aftership.

  1. Open the notification settings in your AfterShip Tracking admin and go to webhooks.
  2. Add the webhook URL. AfterShip allows up to 10.
  3. Select the events you want to receive.
  4. Select the webhook version. AfterShip prefers the latest.
  5. Copy your webhook secret from the notification settings into AFTERSHIP_WEBHOOK_SECRET in .env.local, then restart npm run dev.

AfterShip lets you add custom headers to the URL. A signature check does the same job and proves the body is intact, so use the signature.

Send a test webhook

AfterShip sends a test delivery from the admin. Select Send test webhook next to your URL. AfterShip validates the URL when you do.

Check it works

Your Horizon terminal prints one line for the test delivery:

Output
  POST    200  /api/webhooks/aftership

Your app terminal prints:

Output
Received AfterShip webhook: <number> bytes

If the line shows [401], see Troubleshooting.

Troubleshooting

The signature doesn't match

  • Check that AFTERSHIP_WEBHOOK_SECRET is identical to the secret in AfterShip, with no extra spaces or newline.
  • Use the secret as a plain string. Don't base64-decode it.
  • Compute the HMAC over the raw body. Don't run JSON.parse and JSON.stringify first, because that can change the bytes.
  • Compare against a base64 digest, not hex.
  • Restart npm run dev after you edit .env.local.

AfterShip says the URL failed validation

AfterShip needs a response between 200 and 299. Check that the tunnel and npm run dev are running, and that the URL ends with /api/webhooks/aftership. A 401 from a wrong secret also fails validation.

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.

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 you selected events on the webhook, and that the tracking you update belongs to this AfterShip account.

Next steps

On this page