Developers menu

Webhooks

FirmTape posts signed JSON to an https address you own when something you asked for happens. Three sources send webhooks today: the level alerts (LIVE, also alert lines and order alerts), POSITIONS (ORDERS) and Geo. They share the transport rules but not yet one format; the table below says exactly how each one signs and retries, and the target format all three move to.

For every webhook

  • HTTPS only. An address that resolves to a private, loopback or link-local address is refused, before the send and again at the connection.
  • Redirects are not followed: a 3xx counts as a failure.
  • Answer with any 2xx fast; do the work after answering.
  • Verify the signature over the raw bytes before parsing the body, and refuse a timestamp more than five minutes old.
  • Drop an event whose id you have already handled: a retry carries the same id.

The three sources today

LIVEORDERSGEO
SendsLevel alerts, alert lines and order alertsPOSITIONS alertsGeo records
Set onAccount, Level alerts, WebhookThe POSITIONS editor, Alert, WebhookAccount, Geo, Webhooks
Addressesone address per accountone address per positionup to 5 endpoints on Geo API, 25 on Geo Team
Event name fieldtypeeventevent
Signaturesha256= + hex HMAC-SHA256 of <timestamp>.<raw body> with the signing secretThe same as the alerts: sha256= + hex HMAC-SHA256 of <timestamp>.<raw body>. X-FirmTape-Signature-V0 signs the raw body alone and is deprecated.v1 is hex HMAC-SHA256 of <t>.<raw body>; the timestamp rides inside the signature header
HeadersUser-Agent: FirmTape-Webhooks/1, X-FirmTape-Event: <type>, X-FirmTape-Event-Id: <id>, X-FirmTape-Timestamp: <unix seconds>, X-FirmTape-Signature: sha256=<hex>User-Agent: FirmTape-Positions/1, X-FirmTape-Timestamp: <unix seconds>, X-FirmTape-Signature: sha256=<hex>, X-FirmTape-Signature-V0: sha256=<hex> (deprecated)X-FirmTape-Webhook-Id: <id>, X-FirmTape-Signature: t=<unix seconds>,v1=<hex>
Answer within3 seconds3 seconds10 seconds
RetriesNo answer, a 429 or a 5xx: tried again after 1 s and 4 s, three tries in all, the same id each time.One try; no retry.One attempt; a failed delivery can be retried by hand from the account page.
Switched offAfter 10 failed events in a row; the account page shows why. Saving the address switches it on again.Never switched off; a failed send is logged.Not switched off.
VolumeUp to 30 events a minute per account; past that they are dropped and counted on the account page.One event per label change per side per day, and one opening read.Up to 600 records an hour per account.
Testtype: "test", from Send testevent: "test", from Send test in the editorevent: "geo.test", from Test beside the endpoint
Delivery logThe last delivery (time, status, tries) on the account page.None yet.Every delivery's status and time on the account page, with a retry button.

Verify the signature

Level alerts and POSITIONS: recompute sha256= plus the hex HMAC-SHA256 of the timestamp header, a dot and the raw body, compare in constant time, refuse a timestamp more than five minutes old.

const crypto = require("node:crypto");
// rawBody: the request body as received, a Buffer or a string, before any JSON parsing
function verify(rawBody, headers, secret) {
  const t = headers["x-firmtape-timestamp"];
  const got = String(headers["x-firmtape-signature"] || "");
  if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const want = "sha256=" + crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return got.length === want.length && crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want));
}
import hmac, hashlib, time

def verify(raw_body: bytes, headers, secret: str) -> bool:
    t = headers.get("X-FirmTape-Timestamp", "")
    got = headers.get("X-FirmTape-Signature", "")
    if not t or abs(time.time() - int(t)) > 300:
        return False
    want = "sha256=" + hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(got, want)

Geo signs as t=<unix>,v1=<hex> in one header:

const crypto = require("node:crypto");
function verifyGeo(rawBody, header, secret) {
  const { t, v1 } = Object.fromEntries(String(header).split(",").map((p) => p.split("=")));
  if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const mac = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return Boolean(v1) && mac.length === v1.length && crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(v1));
}

Test

Each source has a test button that posts a test event signed the same way: LIVE, type: "test", from Send test; ORDERS, event: "test", from Send test in the editor; GEO, event: "geo.test", from Test beside the endpoint.

Every event

flip_cross · wall_break · regime_change · wall_move · flip_move · level_near · price_alert · level_line · order · test · label_changed · opening_read · test · geo.record · geo.test

The target format (planned)

All three sources move to one envelope and one header set. The level alerts already send it; POSITIONS and Geo will send it beside their current format for 30 days, then the old format stops.

{ "version": 1, "id": "evt_...", "type": "<type>", "ts": "<ISO 8601, New York time>", ...the event's fields }
X-FirmTape-Event: <type>
X-FirmTape-Event-Id: <id>
X-FirmTape-Timestamp: <unix seconds>
X-FirmTape-Signature: sha256=<hex HMAC-SHA256 of <timestamp>.<raw body>>

Answer within 3 seconds; no answer, a 429 or a 5xx retried after 1 s and 4 s; switched off after 10 failed events in a row; a test event of type test.

The 30-day plan: Versioning, Planned. A delivery log for the alerts webhook is planned too.

Measurements of dealer positioning, never a forecast or a trade call. This page is built from FirmTape's public contract, the same files the OpenAPI document is built from; the tests hold them against the route code. Markdown: /docs/developers/webhooks.md.