Horizon

Test Chargify webhooks locally

Receive Chargify (Maxio Advanced Billing) webhook events on localhost with a Horizon tunnel, and verify the HMAC-SHA-256 signature.

Receive Chargify events on your laptop while you build, with a public HTTPS URL Chargify can reach. Chargify is now called Maxio Advanced Billing, and this guide covers both names.

Horizon has no Chargify integration. Chargify 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.
  • An Advanced Billing site where you can open Config, Settings
  • A Next.js app that uses the App Router and runs on port 3000

Start your app

Advanced Billing signs the raw body of each webhook with HMAC-SHA-256 (a keyed hash), using your site's shared key as the secret. The hex digest arrives in the X-Chargify-Webhook-Signature-Hmac-Sha-256 header. The body is form-encoded, not JSON. It carries an id, an event and a payload in bracket notation, such as payload[subscription][product][name].

Create the route handler. Read the raw body with request.text() and hash it before you parse it. Compare with crypto.timingSafeEqual.

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

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

  const body = await request.text();
  const received = request.headers.get("x-chargify-webhook-signature-hmac-sha-256") ?? "";
  const expected = createHmac("sha256", sharedKey).update(body).digest("hex");

  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 });
  }

  const fields = new URLSearchParams(body);
  const webhookId = request.headers.get("x-chargify-webhook-id");
  console.log(`Received Chargify event: ${fields.get("event")} (webhook ${webhookId})`);

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

Chargify wants an HTTP 200 OK as quickly as possible. Any other response, or a timeout, starts the retries. Keep the handler fast.

Start the app:

npm run dev

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

Use -s. Without it, the subdomain is random and changes on every run, and you would have to edit the endpoint in Advanced Billing each time you restart. Reserved subdomains are a paid feature, see Pricing.

Add the endpoint in Advanced Billing

  1. Go to Config, then Settings, and select Webhooks.
  2. Select the Send webhooks to your webhook endpoints checkbox.
  3. Select Add New Endpoint.
  4. Enter https://my-billing-app.hrzn.run/api/webhooks/chargify as the target URL.
  5. Under Webhook Subscriptions, choose the events you want. All On and All Off switch every event at once.
  6. Select Save.

A site can have up to five endpoints. Advanced Billing accepts plain HTTP endpoints only while the site is in test mode. The tunnel URL is HTTPS, so it works in both modes.

Add the shared key

Find the key in Advanced Billing: open the site switcher, select Edit Current Site, and copy the Shared Key field.

.env.local
CHARGIFY_SHARED_KEY=replace-with-your-shared-key

Restart npm run dev so Next.js loads the new variable.

Check it works

Send a test event from Advanced Billing. Two options exist:

  • To test one endpoint, open the endpoint's Actions dropdown and select Test. This sends a minimal payload.
  • To test an event type, go to Tools, then Webhook Testing. Select an event type and an endpoint, then send it. Advanced Billing sends a realistic payload for that event.

The Horizon terminal prints one line for the request:

Output
  POST    200  /api/webhooks/chargify

Your app terminal prints one line such as Received Chargify event: test (webhook 12345). Maxio's Webhooks Panel shows the delivery from their side.

To check the signature code without Advanced Billing, use the example from Maxio's docs. Set CHARGIFY_SHARED_KEY=123, restart npm run dev, and send the example body with its documented signature:

curl -X POST https://my-billing-app.hrzn.run/api/webhooks/chargify \
  -H "X-Chargify-Webhook-Signature-Hmac-Sha-256: 19826d51b9f866b26eda1f154de192593360f8d0bcb63df8a28540a5dcf733f1" \
  -d 'payload[chargify]=testing&event=test'

Put your real shared key back afterwards.

Troubleshooting

The signature doesn't match

The Horizon line shows [401]. Check these in order:

  1. The key. CHARGIFY_SHARED_KEY must equal the Shared Key of the site that owns the endpoint. Each site has its own key. Did you restart the dev server after you edited .env.local?
  2. The body. Hash the raw body. Don't pass it through request.formData() or URLSearchParams first.
  3. The header. Read X-Chargify-Webhook-Signature-Hmac-Sha-256, the HMAC-SHA-256 one.

The request returns 404

The Horizon line shows [404]. The route file app/api/webhooks/chargify/route.ts serves /api/webhooks/chargify. Check the target URL for typos, and make sure the file exports POST.

The URL changed after a restart

You started the tunnel without -s, so Horizon gave you a new random subdomain. Advanced Billing still sends events to the old URL. Restart with -s, and edit the endpoint if the URL differs.

Advanced Billing sends the same webhook again

Anything other than 200 OK, and any timeout, triggers up to six attempts in total. The gaps run from about 10 seconds up to about 15 minutes. Return 200 first and do slow work afterwards.

Next steps

On this page