Test Jira Cloud webhooks locally
Receive Jira Cloud webhook events on localhost with a Horizon tunnel, and verify the X-Hub-Signature header.
Receive Jira Cloud events on your laptop while you build, with a URL Jira can reach.
Horizon has no Jira integration. Jira sends webhooks to a public URL, and Horizon provides that URL.
This guide covers admin webhooks, which you register in Jira Administration or with the REST 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 Jira Cloud site, and a user with the Administer Jira global permission
Start your app
When you give an admin webhook a secret, Jira signs each delivery. It sends an X-Hub-Signature header in the form method=signature, following the WebSub standard. The method names the hash. Jira's own test values use sha256. You compute an HMAC (a keyed hash) of the body with your secret and that method, then compare.
The handler below accepts only sha256, and compares hex digests with crypto.timingSafeEqual.
import { createHmac, timingSafeEqual } from "node:crypto";
export async function POST(request: Request) {
const secret = process.env.JIRA_WEBHOOK_SECRET;
if (!secret) {
return new Response("Missing JIRA_WEBHOOK_SECRET", { status: 500 });
}
const body = await request.text();
const received = request.headers.get("x-hub-signature") ?? "";
const expected = `sha256=${createHmac("sha256", secret).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 payload = JSON.parse(body);
console.log(`Received Jira event: ${payload.webhookEvent}`);
return new Response("ok", { status: 200 });
}Pick a secret and store it in an environment variable. Use a random, high-entropy string.
JIRA_WEBHOOK_SECRET=replace-with-a-long-random-stringStart 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 your Jira 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-jira-appHORIZON: Tunnel connected
URL https://my-jira-app.hrzn.run (reserved)
Forwarding http://localhost:3000
Request log https://hrzn.run/dashboard/tunnels/my-jira-appYour public URL is https://my-jira-app.hrzn.run. Keep this terminal open.
Add the webhook in Jira
- In Jira, log in as a user with the Administer Jira global permission.
- Select Settings, then System.
- Under Advanced, select WebHooks.
- Select Create a WebHook.
- Enter a name, set the URL to
https://my-jira-app.hrzn.run/api/webhooks/jira, and enter the same secret asJIRA_WEBHOOK_SECRET. - Choose the events you want, then save.
You can instead register the webhook with a POST to /rest/webhooks/1.0/webhook. The body takes a name, a URL, events and an optional secret.
The secret is optional. Without it, Jira sends no X-Hub-Signature header and the handler above rejects every request.
Trigger an event
Jira documents no test button for admin webhooks. Cause a real event instead. Edit an issue to send jira:issue_updated, if your webhook subscribes to issue updates.
Check it works
In the terminal that runs hrzn, you see one line for the delivery:
POST 200 /api/webhooks/jiraYour app terminal prints a line such as:
Received Jira event: jira:issue_updatedIf the line shows [401], see Troubleshooting.
Troubleshooting
The signature doesn't match
- Check that
JIRA_WEBHOOK_SECRETis identical to the secret on the webhook, with no extra spaces or newline. - Compute the HMAC over the raw body. Don't run
JSON.parseandJSON.stringifyfirst, because that can change the bytes. - Check the method in the header. The handler accepts
sha256only. - Restart
npm run devafter you edit.env.local.
Jira sends the same event again
Jira retries up to five times, with a random delay of 5 to 15 minutes between attempts. It retries on a connection failure and on status 408, 409, 425, 429 and 5xx. Use the X-Atlassian-Webhook-Identifier header, which stays the same across retries, to spot duplicates. X-Atlassian-Webhook-Retry holds the retry count.
The URL changed after a restart
You started the tunnel without -s, so Horizon gave you a new random subdomain. Restart with -s my-jira-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 forReconnected. - Check that the webhook URL ends with
/api/webhooks/jira. - Check that the webhook subscribes to the event you trigger.
Next steps
- Read Atlassian's Jira Cloud webhooks reference.
Test Heroku app webhooks locally
Receive Heroku app webhook notifications on localhost with a Horizon tunnel, and verify the Heroku-Webhook-Hmac-SHA256 header.
Test LaunchDarkly webhooks locally
Receive LaunchDarkly webhook events on localhost with a Horizon tunnel, and verify the X-LD-Signature header.