dougeubanks/jsm-ha-notifier

By dougeubanks

Updated 29 days ago

Docker service that bridges Jira Service Management (JSM/OpsGenie) alerts to Home Assistant — smar

Image
Integration & delivery
Message queues
Internet of things
0

1.2K

dougeubanks/jsm-ha-notifier repository overview

JSM Home Assistant Notifier

CI GHCR image License: Apache 2.0

A lightweight Docker service that bridges Jira Service Management (JSM / OpsGenie) alerts to Home Assistant — with smart on-call routing, escalation detection, rich TTS announcements, and persistent dashboard notifications.

Jump to Quick Start ↓


Contents


How It Works

JSM alert created / escalated
         │
         ▼
  jsm-ha-notifier (Docker)
         │
         ├─ Parse alert payload
         ├─ Deduplicate (suppress retries within 60 s)
         ├─ Route decision:
         │    always_notify mode?   → NOTIFY
         │    escalated to me?      → NOTIFY
         │    I'm on-call?          → NOTIFY   (JSM API, cached 5 min)
         │    none of the above     → DROP
         │
         ▼
  Home Assistant REST API
    ├─ media_player.play_media  (TTS with rich metadata / real alert title)
    └─ persistent_notification  (visible in HA dashboard)

  On Acknowledge / Close → dismiss the persistent notification automatically

Two webhook URLs, one for each routing mode:

JSM Webhook URLBehaviour
https://your-host:8080/alert?key=YOUR_KEYNotify only when on-call
https://your-host:8080/alert?mode=always&key=YOUR_KEYAlways notify regardless of schedule

Features

  • On-call aware — queries JSM in real time and caches results; only wakes you when you are actually on-call
  • Escalation detectionEscalateNext events always notify regardless of on-call status or dedup window
  • Always-notify mode — a separate webhook path for schedules that should always page you (e.g. infrastructure monitors)
  • Rich TTS — spoken announcements include priority, alert title, system name, and a description excerpt
  • Real media player title — uses extra.metadata so HA shows the actual alert title instead of "Playing Default Media Receiver"
  • Persistent HA notifications — created on alert, auto-dismissed on Acknowledge or Close
  • Configurable announcement formats — customise the detailed and terse TTS templates with placeholders
  • Time-based quiet hours — silent windows (no TTS) and terse windows (short format), with cross-midnight support
  • Priority override for silent mode — P1/P2 alerts can bypass silent windows so critical incidents always wake you
  • Per-media-player routing — route TTS to different speakers by time of day (e.g. bedroom at night, office during the day)
  • Volume control — set media player volume before TTS playback, with separate levels for full and terse modes
  • Alert batching — combine multiple alerts arriving within a configurable window into one TTS announcement
  • TTS repeat (pager mode) — repeat TTS at intervals for critical alerts until acknowledged or max repeats hit
  • Acknowledge from HAPOST /alert/{id}/acknowledge endpoint lets HA automations ack alerts without opening JSM
  • Token health check — daily background job verifies the Atlassian API token; fires a HA TTS warning if expired (TTS suppressed during quiet hours; persistent notification still created)
  • Deep health checkGET /healthz verifies both JSM and HA API connectivity (returns 503 if either fails)
  • Startup connectivity checks — verifies JSM and HA reachability at boot, logs warnings if unreachable
  • HA automation webhooks — fire HA webhook triggers on Create, Escalate, Acknowledge, Close, Update, and SLA Breach events to control lights, scenes, scripts
  • Incident state dashboard — optional SQLite-backed GET /incidents API with status/priority filters, summary endpoint, and Grafana JSON datasource compatibility
  • JSM incident sync — optional background task to poll JSM for open alerts and keep the incident dashboard current
  • Emoji toggleENABLE_EMOJIS=false strips all emojis from notifications, metadata, and incoming alert text
  • Generic webhook support — any system that sends HTTP POST (Grafana, Uptime Kuma, shell scripts, HA automations) can trigger HA alerts
  • API key authentication — optional API key via query parameter (?key=), HTTP header (X-API-Key), or URL path prefix (/KEY/endpoint)
  • Webhook signature verification — optional HMAC-SHA256 validation via X-Hub-Signature-256
  • Request body size limit — rejects payloads over 1 MB to prevent memory exhaustion
  • Safe format templates — user-configurable announcement formats use a restricted formatter that blocks attribute/index access
  • Prometheus metricsGET /metrics exposes alert counters, credential check stats, rate limit hits, and uptime for Grafana/Prometheus dashboards
  • Structured JSON loggingLOG_FORMAT=json for Datadog, Loki, CloudWatch, ELK; default is human-readable text
  • Hot config reloadPOST /reload re-reads .env and applies changes without container restart
  • Per-IP rate limiting — 60 requests/minute on /alert to prevent webhook abuse
  • Secure container — non-root user, read-only filesystem, tmpfs at /tmp, localhost-only port binding

Prerequisites

  • Docker + Docker Compose
  • A server or device accessible from the internet (or from JSM's webhook delivery IPs)
  • Home Assistant with a Long-Lived Access Token and a TTS service configured
  • An Atlassian API token with access to JSM Ops (OpsGenie) schedules

Quick Start

git clone https://github.com/RealDougEubanks/JSM-HomeAssistant-Notifier.git
cd JSM-HomeAssistant-Notifier

cp .env.example .env
# Edit .env and fill in all required values (see Configuration below)

docker compose up -d
docker compose logs -f

Verify the service is running:

curl http://localhost:8080/health
# {"status":"ok"}
Local build testing

To build the Docker image locally instead of pulling from GHCR:

docker build -t ghcr.io/realdougeubanks/jsm-ha-notifier:latest .
docker compose up -d
docker compose logs -f

This tags the local build with the same image name the compose file expects.

If you've made code changes and Docker serves a cached layer, force a clean rebuild:

docker build --no-cache -t ghcr.io/realdougeubanks/jsm-ha-notifier:latest .
docker compose up -d
docker compose logs -f

Note: .env changes do not require a rebuild — just restart the container with docker compose up -d.


Configuration

Step 1 — Copy and edit .env
cp .env.example .env

Open .env and fill in each value. The file is fully commented with instructions for finding each value. The sections below expand on the key ones.

Step 2 — Find your Atlassian Cloud ID

Your Cloud ID is a UUID that identifies your Atlassian organisation. Retrieve it with:

curl -s -u "[email protected]:YOUR_API_TOKEN" \
  https://your-org.atlassian.net/_edge/tenant_info \
  | python3 -m json.tool

Look for the "cloudId" field. Copy it into JSM_CLOUD_ID.

Step 3 — Find your Atlassian Account ID

Your account ID (JSM_MY_USER_ID) is the UUID Atlassian uses internally for your user. The easiest way to find it:

curl -s -u "[email protected]:YOUR_API_TOKEN" \
  "https://api.atlassian.com/jsm/ops/api/YOUR_CLOUD_ID/v1/schedules/YOUR_SCHEDULE_ID/on-calls" \
  | python3 -m json.tool

Find your name in the onCallParticipants array; the "id" field is your account ID.

Step 4 — Find your exact schedule names

Schedule names are case-sensitive. List all schedules visible to your token:

curl -s -u "[email protected]:YOUR_API_TOKEN" \
  "https://api.atlassian.com/jsm/ops/api/YOUR_CLOUD_ID/v1/schedules" \
  | python3 -m json.tool | grep '"name"'

Copy the exact names into ALWAYS_NOTIFY_SCHEDULE_NAMES and/or CHECK_ONCALL_SCHEDULE_NAMES in .env.

Step 5 — Create a Home Assistant Long-Lived Access Token
  1. In Home Assistant, click your profile picture (bottom-left)
  2. Scroll to SecurityLong-Lived Access Tokens
  3. Click Create token, give it a descriptive name (e.g. JSM Notifier)
  4. Copy the token into HA_TOKEN in .env — it is only shown once
Step 6 — Find your media player entity ID

In Home Assistant go to Developer Tools → States, filter by media_player. Copy the entity_id (e.g. media_player.living_room) into HA_MEDIA_PLAYER_ENTITY.

Step 7 — Verify everything works

Once the container is running, check on-call status directly:

curl http://localhost:8080/status | python3 -m json.tool

You should see your schedules listed and an on_call field. If a schedule shows "error": "not found", the name in .env doesn't match — compare carefully against the output of the schedule listing curl above.


Making the Service Externally Accessible

JSM's servers need to reach your webhook URL over the internet.

WARNING: Do not expose this container directly to the internet. The service runs plain HTTP without TLS. All traffic — including API keys, webhook payloads, and authentication tokens — is transmitted in cleartext. Always place a TLS-terminating proxy or tunnel in front of the service. Direct internet exposure risks credential interception, replay attacks, and unauthorized access to your Home Assistant instance.

A Cloudflare Tunnel creates an encrypted outbound connection from your network to Cloudflare's edge, with no inbound ports to open on your router or firewall. Cloudflare handles TLS termination and DDoS protection automatically.

For setup instructions, see the Cloudflare Tunnel documentation.

Option 2 — Reverse proxy with TLS (NGINX / Traefik / Caddy)

Run a TLS-terminating reverse proxy on the same host and forward traffic to http://127.0.0.1:8080. Detailed reverse proxy configuration is outside the scope of this README — consult your proxy's documentation for TLS certificate setup (e.g. Let's Encrypt via Certbot or Caddy's automatic HTTPS).


JSM Webhook Configuration

Configure two outgoing webhooks in JSM Ops — one for on-call schedules and one for always-notify schedules.

Go to JSM Ops Settings

JSM project → SettingsIntegrationsAdd Integration → choose Webhook (under "Outgoing").

Webhook for On-Call Schedule(s)
FieldValue
NameHA Notifier — On-Call
Webhook URLhttps://your-host/alert?key=YOUR_API_KEY
MethodPOST
Send alert payload✅ Enabled
Alert actionsCreate, EscalateNext, Acknowledge, Close
Teams / Schedules filterYour on-call schedule's team
Webhook for Always-Notify Schedule(s)
FieldValue
NameHA Notifier — Always Notify
Webhook URLhttps://your-host/alert?mode=always&key=YOUR_API_KEY
MethodPOST
Send alert payload✅ Enabled
Alert actionsCreate, EscalateNext, Acknowledge, Close
Teams / Schedules filterYour always-notify team/schedule

The simplest way to secure your webhook endpoints. Set WEBHOOK_API_KEY in .env and pass the key using any of these methods:

MethodExampleBest for
Query parameterhttps://your-host/alert?key=YOUR_KEYJSM webhooks (URL-only config)
Path prefixhttps://your-host/YOUR_KEY/alertTools that can't add headers or query params
HTTP headerX-API-Key: YOUR_KEYScripts, HA automations, Grafana

All three methods work on every authenticated endpoint. Generate a key: openssl rand -hex 32

Requests without a valid key receive a stealth 404 (not 401), so attackers cannot confirm that authenticated endpoints exist.

⚠️ Keys in URLs leak into logs

The query-parameter and path-prefix methods exist because JSM webhooks can only be configured with a bare URL — but any URL-borne secret ends up in reverse-proxy access logs, browser history, and intermediate proxies. Prefer the X-API-Key header wherever the caller supports it (scripts, HA automations, Grafana), and if you front this service with a reverse proxy:

  • Scrub or disable access logging for the key. nginx example:

    # Redact the key query param / path segment before logging
    map $request_uri $loggable_uri {
        ~^(?<pre>.*[?&]key=)[^&]+(?<post>.*)$  "${pre}REDACTED${post}";
        default                                 $request_uri;
    }
    log_format scrubbed '$remote_addr - [$time_local] "$request_method $loggable_uri" $status';
    access_log /var/log/nginx/access.log scrubbed;
    

    Caddy: add log { format filter { request>uri query { replace key REDACTED } } } to the site block.

  • Rotate WEBHOOK_API_KEY (and update the JSM webhook URLs) if a proxy has already logged it.

The service itself never logs request URLs, and uvicorn access logging is disabled in the container.

Optional — HMAC Webhook Signature

If set, WEBHOOK_SECRET requires every POST /alert to carry an X-Hub-Signature-256: sha256=<hex> header containing the HMAC-SHA256 of the raw request body keyed with the secret.

⚠️ JSM cannot sign requests itself. JSM / OpsGenie outgoing-webhook custom headers are static strings — there is no template function to compute a per-request HMAC over the body. If JSM posts directly to this service, leave WEBHOOK_SECRET empty and rely on WEBHOOK_API_KEY.

Use WEBHOOK_SECRET when a signing-capable caller sits in front of the service:

CallerHow
Forwarding proxy you control (Cloudflare Worker, AWS Lambda, nginx+Lua)Receive the JSM webhook, compute sha256= HMAC of the body, forward with the header
Your own scripts / automationsCompute the HMAC at send time (e.g. openssl dgst -sha256 -hmac "$SECRET")

You can use both WEBHOOK_API_KEY and WEBHOOK_SECRET together for defense in depth when your caller supports signing.


Testing With curl

Send a test alert (on-call path)
curl -X POST http://localhost:8080/alert \
  -H "Content-Type: application/json" \
  -d '{
    "action": "Create",
    "alert": {
      "alertId": "test-001",
      "message": "Test Alert — please ignore",
      "priority": "P3",
      "entity": "dev-server",
      "description": "This is a test alert sent manually."
    }
  }'

If you are currently on-call, this will trigger a TTS announcement and create a persistent notification in HA.

Send a test alert (always-notify path)
curl -X POST "http://localhost:8080/alert?mode=always" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "Create",
    "alert": {
      "alertId": "always-test-001",
      "message": "Infrastructure Monitor Test",
      "priority": "P2",
      "entity": "prod-server-01"
    }
  }'

This path always notifies regardless of on-call status.

Send a test escalation
curl -X POST "http://localhost:8080/alert?mode=always" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "EscalateNext",
    "alert": {
      "alertId": "test-001",
      "message": "Test Alert — please ignore",
      "priority": "P1",
      "entity": "prod-db-01"
    }
  }'
Check on-call status
curl http://localhost:8080/status | python3 -m json.tool
Invalidate on-call cache
curl -X POST http://localhost:8080/cache/invalidate
Test with webhook signature

If WEBHOOK_SECRET is set, generate the signature before sending:

SECRET="your-webhook-secret"
BODY='{"action":"Create","alert":{"alertId":"sig-test","message":"Signed test","priority":"P3"}}'
SIG="sha256=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')"

curl -X POST http://localhost:8080/alert \
  -H "Content-Type: application/json" \
  -H "X-Hub-Signature-256: $SIG" \
  -d "$BODY"

Using With Other Webhook Sources

The /alert endpoint accepts any JSON payload matching the OpsGenie webhook format. You don't need JSM — any monitoring system, script, or automation that can send HTTP POST requests can trigger HA alerts.

Required Payload Format
{
  "action": "Create",
  "alert": {
    "alertId": "unique-id-123",
    "message": "Your alert title here",
    "priority": "P1",
    "entity": "optional-system-name",
    "description": "Optional longer description text"
  }
}
FieldRequiredDescription
actionYesCreate, EscalateNext, Acknowledge, or Close
alert.alertIdYesUnique identifier (used for dedup and notification tracking)
alert.messageYesAlert title / summary (spoken by TTS)
alert.priorityNoP1P5 (default: P3)
alert.entityNoSystem / host name
alert.descriptionNoLonger details (first 200 chars used in TTS)
Example: Uptime Kuma

Configure a webhook notification in Uptime Kuma with the Notification Type set to "Webhook" / custom JSON:

# Uptime Kuma → Settings → Notifications → Add → Webhook
# URL: http://your-notifier:8080/alert?mode=always&key=YOUR_KEY
# Method: POST
# Body:
{
  "action": "Create",
  "alert": {
    "alertId": "uptime-kuma-{{ monitorJSON.id }}",
    "message": "{{ monitorJSON.name }} is {{ heartbeatJSON.status == 1 ? 'UP' : 'DOWN' }}",
    "priority": "P2",
    "entity": "{{ monitorJSON.hostname }}"
  }
}
Example: Grafana Alerting

Use a Grafana "webhook" contact point with the OpsGenie payload format:

# Grafana → Alerting → Contact Points → New → Webhook
# URL: http://your-notifier:8080/alert?mode=always&key=YOUR_KEY
# Method: POST
#
# Or use curl to forward Grafana alerts via a script:
curl -X POST "http://your-notifier:8080/alert?mode=always&key=YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "Create",
    "alert": {
      "alertId": "grafana-cpu-alert-prod01",
      "message": "CPU usage above 95% on prod-01",
      "priority": "P1",
      "entity": "prod-01",
      "description": "CPU has been above 95% for the last 5 minutes. Current: 98.2%."
    }
  }'
Example: Prometheus Alertmanager

Use Alertmanager's webhook receiver to POST to the notifier:

# alertmanager.yml
receivers:
  - name: ha-notifier
    webhook_configs:
      - url: "http://your-notifier:8080/alert?mode=always&key=YOUR_KEY"
        send_resolved: true

Then use a small relay script or Alertmanager template to transform alerts into the expected format.

Example: Home Assistant Automation

Trigger an alert from HA itself (e.g. a sensor threshold):

# HA automation action
service: rest_command.trigger_notifier_alert
data:
  alert_id: "ha-temp-alert-{{ now().isoformat() }}"
  message: "Temperature sensor above threshold"
  priority: "P2"
  entity: "sensor.living_room_temperature"
  description: "Current temperature: {{ states('sensor.living_room_temperature') }}°C"
# configuration.yaml
rest_command:
  trigger_notifier_alert:
    url: "http://your-notifier:8080/alert?mode=always&key=YOUR_KEY"
    method: POST
    content_type: "application/json"
    payload: >
      {"action":"Create","alert":{"alertId":"{{ alert_id }}","message":"{{ message }}","priority":"{{ priority }}","entity":"{{ entity }}","description":"{{ description }}"}}
Example: Simple Shell Script

Trigger an alert from any script or cron job:

#!/bin/bash
# notify-ha.sh — send an alert to the JSM-HA Notifier
NOTIFIER_URL="http://your-notifier:8080/alert?mode=always&key=YOUR_KEY"

curl -s -X POST "$NOTIFIER_URL" \
  -H "Content-Type: application/json" \
  -d "{
    \"action\": \"Create\",
    \"alert\": {
      \"alertId\": \"script-$(date +%s)\",
      \"message\": \"$1\",
      \"priority\": \"${2:-P3}\",
      \"entity\": \"$(hostname)\"
    }
  }"

Usage: ./notify-ha.sh "Backup failed on NAS" P2

Closing / Acknowledging Alerts

To dismiss the persistent HA notification and stop TTS repeats, send a Close or Acknowledge action with the same alertId:

curl -X POST "http://your-notifier:8080/alert?mode=always&key=YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"action": "Close", "alert": {"alertId": "the-original-alert-id", "message": "resolved"}}'

Or use the dedicated acknowledge endpoint:

curl -X POST "http://your-notifier:8080/alert/the-original-alert-id/acknowledge?key=YOUR_KEY"

JSM Webhook Events Reference

JSM sends different action values for each alert lifecycle event. The notifier handles all of them:

JSM ActionNotifier BehaviourHA Webhook Config
CreateTTS + persistent notification (if on-call/always-notify)HA_WEBHOOK_ON_CREATE
EscalateNextTTS + persistent notification (always if targeted at you)HA_WEBHOOK_ON_ESCALATE
AcknowledgeDismiss HA notification, cancel TTS repeatHA_WEBHOOK_ON_ACKNOWLEDGE
CloseDismiss HA notification, cancel TTS repeatHA_WEBHOOK_ON_CLOSE
AddNoteFire HA webhook only (no TTS)HA_WEBHOOK_ON_UPDATE
UnAcknowledgeFire HA webhook onlyHA_WEBHOOK_ON_UPDATE
AssignOwnershipFire HA webhook onlyHA_WEBHOOK_ON_UPDATE
SlaBreachedFire HA webhook onlyHA_WEBHOOK_ON_SLA_BREACH
JSM Webhook Configuration for All Events

In JSM, configure your outgoing webhook to include all action types you want to handle:

FieldValue
Alert actionsCreate, EscalateNext, Acknowledge, Close, AddNote, UnAcknowledge, AssignOwnership
Webhook URLhttps://your-host/alert?mode=always&key=YOUR_API_KEY
Generic Webhook Payloads for Each Event Type

These work with JSM, Uptime Kuma, Grafana, scripts, or any HTTP client:

New alert:

{"action": "Create", "alert": {"alertId": "inc-001", "message": "Server down", "priority": "P1", "entity": "prod-01"}}

Escalation:

{"action": "EscalateNext", "alert": {"alertId": "inc-001", "message": "Server down", "priority": "P1", "entity": "prod-01"}}

Acknowledged:

{"action": "Acknowledge", "alert": {"alertId": "inc-001", "message": "Server down"}}

Resolved / Closed:

{"action": "Close", "alert": {"alertId": "inc-001", "message": "Server down"}}

Updated (note added):

{"action": "AddNote", "alert": {"alertId": "inc-001", "message": "Server down", "description": "Restarting services..."}}

SLA Breached:

{"action": "SlaBreached", "alert": {"alertId": "inc-001", "message": "Server down", "priority": "P1"}}
Complete Lifecycle Script

Send a full alert lifecycle from the command line for testing:

URL="http://localhost:8080/alert?mode=always&key=YOUR_KEY"
ID="test-lifecycle-$(date +%s)"

# Create
curl -s -X POST "$URL" -H "Content-Type: application/json" \
  -d "{\"action\":\"Create\",\"alert\":{\"alertId\":\"$ID\",\"message\":\"Test lifecycle alert\",\"priority\":\"P2\",\"entity\":\"test-server\"}}"

sleep 5

# Acknowledge
curl -s -X POST "$URL" -H "Content-Type: application/json" \
  -d "{\"action\":\"Acknowledge\",\"alert\":{\"alertId\":\"$ID\",\"message\":\"Test lifecycle alert\"}}"

sleep 5

#

Tag summary

Content type

Image

Digest

sha256:0b38cbbef

Size

58.4 MB

Last updated

29 days ago

docker pull dougeubanks/jsm-ha-notifier