Skip to content
Terminal
All pages

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

Was this page helpful?