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.
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 devStart 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-appHORIZON: Tunnel connected
URL https://my-billing-app.hrzn.run (reserved)
Forwarding http://localhost:3000
Request log https://hrzn.run/dashboard/tunnels/my-billing-appUse -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
- Go to Config, then Settings, and select Webhooks.
- Select the Send webhooks to your webhook endpoints checkbox.
- Select Add New Endpoint.
- Enter
https://my-billing-app.hrzn.run/api/webhooks/chargifyas the target URL. - Under Webhook Subscriptions, choose the events you want. All On and All Off switch every event at once.
- 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.
CHARGIFY_SHARED_KEY=replace-with-your-shared-keyRestart npm run dev so Next.js loads the new variable.
Advanced Billing also sends an X-Chargify-Webhook-Signature header. Maxio marks it deprecated and says it holds an MD5 signature. Use the -Hmac-Sha-256 header.
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:
POST 200 /api/webhooks/chargifyYour 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:
- The key.
CHARGIFY_SHARED_KEYmust 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? - The body. Hash the raw body. Don't pass it through
request.formData()orURLSearchParamsfirst. - 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
- Read Maxio's guide to webhook configuration and testing.
- Reserve a subdomain so your URL never changes: see pricing.
Test Brex webhooks locally
Receive Brex webhook events on localhost with a Horizon tunnel, and verify the Webhook-Signature header in a Next.js route handler.
Test Coinbase webhooks locally
Receive Coinbase Business checkout webhook events on localhost with a Horizon tunnel, and verify the X-Hook0-Signature header.