Webhooks
A webhook sends each alert to an https address you choose, as a signed POST. This page covers setup, a test send and the signature check.
Webhooks come with the Pro plan. You can get Pro only while paid plans are open. Plans says whether they are and lists what each plan includes. The full reference is Webhooks.
Add a webhook destination
In the app, open Alerts and quiet hours. Under Where messages go, find the webhook row. Choose a Message format, paste the Webhook URL, then choose Save.
The next screen says Copy your signing secret now. Copy it. This is the only time Terminal shows it.
With an API key, send the URL to the destinations route. The key needs the destinations:write scope. Create one at API keys.
The samples read your key from TERMINAL_API_KEY and the site from TERMINAL_BASE. Set TERMINAL_BASE to https://terminal.tokensmarketcap.com.
curl -sS --fail-with-body "$TERMINAL_BASE/v1/destinations" \
-H "Authorization: Bearer $TERMINAL_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(openssl rand -hex 16)" \
-d '{"kind":"webhook","format":"terminal","url":"https://example.com/terminal-hook","label":"My receiver"}'
The answer is 201. It holds the destination with its id, and signing_secret. Store the secret as TERMINAL_WEBHOOK_SECRET. Later reads never show it again.
Every POST that writes needs its own Idempotency-Key. The samples make one with openssl rand. Read Errors for how a repeated key behaves.
Send a test
A terminal or template destination stays Not tested yet until a test gets a 2xx answer from your receiver. A preset starts active. In the app, choose Send a test on the destination.
With an API key, replace <id> with the destination's id:
curl -sS --fail-with-body "$TERMINAL_BASE/v1/destinations/<id>/test" \
-H "Authorization: Bearer $TERMINAL_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(openssl rand -hex 16)" \
-d '{}'
The answer is 202 with a delivery_id. Terminal then sends a test event to your address. A test has no Terminal-Sequence header.
A template or a preset sends the same body a real alert would. For those, send {"confirm":true} as the body instead. Turn off any bot that acts on it first.
Check the signature
Every POST carries a Terminal-Signature header, such as t=1760277780,v1= and a hex string. v1 is an HMAC-SHA256 of t, a dot and the exact body bytes. The key is the whole signing secret, whsec_ included.
Refuse a delivery when the signature does not match. Also refuse it when t is more than 300 seconds from your clock. Read the raw body before you parse it. Parsing and writing it again changes the bytes.
The reference explains each step.
Shell
This script checks one saved delivery by hand. Save the raw body to a file with no added newline. Save the script as check-signature.sh. Run it with the body file and the header value, as its first line shows. It prints event or refuse.
#!/usr/bin/env bash
# Usage: bash check-signature.sh body.json "t=...,v1=..."
# Prints "event" when the signature matches and t is recent, else "refuse".
set -u
secret="${TERMINAL_WEBHOOK_SECRET:?Set TERMINAL_WEBHOOK_SECRET to the whole signing secret}"
body_file="$1"
header="${2-}"
tolerance=300
refuse() {
echo refuse
exit 1
}
[ -n "$header" ] || refuse
t=""
v1=""
IFS=',' read -r -a parts <<< "$header"
for part in "${parts[@]}"; do
case "$part" in
*=*) ;;
*) refuse ;;
esac
name="${part%%=*}"
value="${part#*=}"
case "$name" in
t) [ -z "$t" ] || refuse; t="$value" ;;
v1) [ -z "$v1" ] || refuse; v1="$value" ;;
esac
done
[[ "$t" =~ ^[0123456789]{1,15}$ ]] || refuse
[[ "$v1" =~ ^[0123456789abcdef]{64}$ ]] || refuse
now=$(date +%s)
age=$(( now - 10#$t ))
[ "$age" -lt 0 ] && age=$(( -age ))
[ "$age" -le "$tolerance" ] || refuse
expected=$({ printf '%s.' "$t"; cat "$body_file"; } | openssl dgst -sha256 -hmac "$secret" | awk '{print $NF}')
[ "${#expected}" -eq 64 ] || refuse
[ "${#v1}" -eq "${#expected}" ] || refuse
[ "$v1" = "$expected" ] || refuse
echo event
The script needs bash and openssl. It allows 300 seconds of clock difference. The secret shows in the process list while openssl runs, so use the Node or Python check on a shared machine.
Node
rawBody is the exact body as a Buffer. secret is the whole signing secret.
import { createHmac, timingSafeEqual } from "node:crypto"
const TOLERANCE_SECONDS = 300
const seen = new Set()
function verifySignature(secret, rawBody, header, now = Date.now()) {
const fields = {}
for (const part of String(header || "").split(",")) {
const index = part.indexOf("=")
if (index === -1) return false
const name = part.slice(0, index).trim()
if (Object.hasOwn(fields, name)) return false
fields[name] = part.slice(index + 1).trim()
}
if (!/^[0-9]{1,15}$/.test(fields.t || "") || !/^[0-9a-f]{64}$/.test(fields.v1 || "")) return false
const t = Number(fields.t)
if (Math.abs(now / 1000 - t) > TOLERANCE_SECONDS) return false
const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest()
return timingSafeEqual(expected, Buffer.from(fields.v1, "hex"))
}
function checkDelivery(secret, rawBody, header, now) {
if (!verifySignature(secret, rawBody, header, now)) return { refuse: true }
let event
try {
event = JSON.parse(rawBody.toString("utf8"))
} catch {
return { refuse: true }
}
if (typeof event?.id !== "string") return { refuse: true }
if (seen.has(event.id)) return { repeat: true }
return { event }
}
Python
raw_body is the exact bytes. now_ms is your clock in milliseconds. secret is the whole signing secret.
import hashlib
import hmac
import json
import re
TOLERANCE_SECONDS = 300
seen = set()
def verify_signature(secret, raw_body, header, now_ms):
fields = {}
for part in str(header or "").split(","):
name, sep, value = part.partition("=")
name = name.strip()
if not sep or name in fields:
return False
fields[name] = value.strip()
t, given = fields.get("t", ""), fields.get("v1", "")
if not re.fullmatch(r"[0-9]{1,15}", t, re.ASCII) or not re.fullmatch(r"[0-9a-f]{64}", given, re.ASCII):
return False
if abs(now_ms / 1000 - int(t)) > TOLERANCE_SECONDS:
return False
signed = f"{t}.".encode("utf-8") + raw_body
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(given, expected)
def check_delivery(secret, raw_body, header, now_ms):
if not verify_signature(secret, raw_body, header, now_ms):
return {"refuse": True}
try:
event = json.loads(raw_body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return {"refuse": True}
if not isinstance(event, dict) or not isinstance(event.get("id"), str):
return {"refuse": True}
if event["id"] in seen:
return {"repeat": True}
return {"event": event}
Each function returns one of three answers. Refuse means answer 401. Repeat means answer 200 and do nothing. Otherwise handle the event, add its id to seen, then answer 200.
Complete receivers with a file of recorded deliveries are in the Node receiver and the Python receiver.
Repeats and order
Terminal can send the same alert more than once. A timeout, a 5xx, a 408 or a 429 makes it try a terminal body again. Drop a delivery whose event id you have already handled.
Take the id from the checked body, not from the Terminal-Event-Id header. The signature does not cover that header.
Alerts can arrive out of order. Terminal-Sequence counts up for each destination. A gap means a send is still being tried or failed for good. Read the events feed from your saved cursor to fill it.
A template or a preset is not sent again when your receiver may already have it. The reference covers each case.
Troubleshooting
- The test says your receiver did not answer. Terminal waits a few seconds and does not follow redirects. Answer 2xx at once, then do slow work after.
- Every signature fails. Check that you used the whole secret,
whsec_included. Check that you signed the raw body, not a parsed copy. - Signatures fail now and then. Your server clock may be off. Sync it with a time server.
- The destination shows Failing. Several sends in a row failed. Fix the receiver, then send a test.
- The destination shows Disconnected. Your receiver answered 410, so Terminal stopped sending. Fix the receiver, then add the destination again.
- The create call answers 402. Your plan has no webhooks, or its webhook count is full. See Plans.
- An alert did not arrive at all. A muted or snoozed rule sends nothing. Quiet hours do not delay a webhook.
Updated Oct 24, 2026