Skip to content
Terminal

Webhooks

A webhook destination is an https address you choose. Terminal POSTs an alert there. Delivery to software is best effort. Quiet hours and the daily message limit do not hold a webhook. A muted or snoozed rule does not send.

Pro includes 5 webhook destinations. Free and Trader include 0. You can get Pro only while paid plans are open. Plans says whether they are. Create one with destinations:write, or in Alerts and quiet hours. The signing secret is returned once, on create.

Formats

A template may contain names you chose. Terminal stores that template as a string. It does not choose those values for you.

GET /v1/destinations returns your destinations. The address is not in the object. host is the hostname only. secrets_set lists secret names, never the values. last_sequence is the last sequence number assigned, or null when none has been assigned yet.

Headers

Every POST, in every format, sends these headers.

v1 is HMAC-SHA256 over the string <t>. plus the exact body bytes, hex encoded. The key is the whole signing secret, whsec_ included, as UTF-8 text. Do not strip the prefix or decode it. Compare the hex in constant time. Use the raw body. Do not parse it and serialize it again.

Reject a t that is more than 300 seconds from your clock. Drop a delivery whose event id you have already handled. The signature covers t and the body only, not the other headers, so take the id from the checked body: the id field of a terminal body, or {{id}} placed in your template. Terminal-Event-Id is not signed. Do not drop repeats by it alone.

Check the signature

Each function returns whether to refuse the delivery (answer 401), whether it is a repeat (answer 200 and do nothing), or the event to handle. Add its id to seen once your code has handled it, then answer 200. If your code fails, answer 500 and Terminal sends a terminal body again.

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}

Complete receivers that use these checks, with a test file of recorded deliveries, are in Webhook receivers for Node and Python.

Shell

This script checks one saved delivery by hand, with bash and openssl. Save the raw body to a file with no added newline. Set TERMINAL_WEBHOOK_SECRET to the whole signing secret. Run the script with the body file and the Terminal-Signature value. It prints event or refuse. It checks the signature and the 300-second window, not repeats.

#!/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 secret shows in the process list while openssl runs. On a shared machine, use the Node or Python check.

Sequence

The first send attempt for an alert takes the next number for that destination. Later attempts of the same delivery reuse it. Tests take no number.

A receiver can see a gap, such as 41 then 43. Number 42 was assigned and has not succeeded, or it failed for good. A later number can arrive first when an earlier one is tried again. When you see a gap, read the events feed from the cursor you stored, or read the alert by id, and drop duplicates by id.

last_sequence on the destination is one less than the next number to assign, once a number has been assigned. A destination whose next number is still 1 reports last_sequence null.

Send once

Terminal waits 8 seconds for an answer and does not follow redirects. A 2xx answer is a delivery.

A terminal body is sent up to 8 times: after a timeout, a refused connection, a 5xx, a 408 or a 429, the next try waits 5 seconds, then twice as long each time, up to 5 minutes, give or take a fifth. A 429 with Retry-After waits that long instead. Your check drops a repeat by id. A 410 turns the destination off. Any other 4xx fails at once and is not sent again. After 3 failed sends in a row the destination shows as failing in Settings.

A preset is not sent again when the receiver may already have the body: after a timeout once the request was written, a 5xx or a 408. A template is the same unless you set retries to true. A send that never got an outcome is treated as one the receiver may have. Terminal tries again only when the body surely did not arrive, such as a refused connection or a 429. A missed preset or template stays in the delivery log and in the events feed.

Test sends

POST /v1/destinations/{id}/test queues one test. The answer is 202 and { "delivery_id": <id> }. A template or a preset also needs { "confirm": true } in the body, because the body is the one an alert would send. A paused or revoked destination is refused. A test does not add Terminal-Sequence, and the event sequence is null.

Until a terminal or template destination has had a test that returned a 2xx, its status stays unverified.

Presets

The preset names in the catalog are 3Commas Signal Bot and Cryptohopper. Neither is released, so the list is empty unless your account is on the allowlist.

A preset or a template body is in the receiver's own format, so it carries no notice and no terms unless your template adds {{notice}}.

Example. These are not live prices.

GET /v1/destinations/presets HTTP/1.1
Authorization: Bearer $TERMINAL_API_KEY

HTTP/1.1 200 OK
{
  "presets": []
}

Your destinations

Example. These are not live prices.

GET /v1/destinations HTTP/1.1
Authorization: Bearer $TERMINAL_API_KEY

HTTP/1.1 200 OK
{
  "destinations": [
    {
      "id": "…",
      "kind": "webhook",
      "label": "Signed webhook",
      "status": "unverified",
      "format": "terminal",
      "preset": null,
      "host": "hooks.example.com",
      "inputs": null,
      "secrets_set": [],
      "template": null,
      "retries": false,
      "last_sequence": 42,
      "last_test": null
    }
  ]
}

Example. These are not live prices.

GET /v1/destinations/$DESTINATION_ID HTTP/1.1
Authorization: Bearer $TERMINAL_API_KEY

HTTP/1.1 200 OK
{
  "destination": {
    "id": "…",
    "kind": "webhook",
    "label": "Signed webhook",
    "status": "unverified",
    "paused_reason": null,
    "format": "terminal",
    "host": "hooks.example.com",
    "secrets_set": [],
    "retries": false,
    "last_sequence": 42,
    "verified_at": null,
    "last_test": null
  }
}

A create answer adds signing_secret once. Later reads do not include it. This object is the shape of that answer. It is not a second response to the requests above.

{
  "destination": { "id": "…", "status": "unverified", "format": "terminal" },
  "signing_secret": "…"
}

Create a free account

All developer pages

Terminal reports market events for conditions you set. It does not tell you to buy, sell, or hold, and it does not place trades.