Developers menu
Developers / Webhooks
Markdown copyWebhooks
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
| LIVE | ORDERS | GEO | |
|---|---|---|---|
| Sends | Level alerts, alert lines and order alerts | POSITIONS alerts | Geo records |
| Set on | Account, Level alerts, Webhook | The POSITIONS editor, Alert, Webhook | Account, Geo, Webhooks |
| Addresses | one address per account | one address per position | up to 5 endpoints on Geo API, 25 on Geo Team |
| Event name field | type | event | event |
| Signature | sha256= + hex HMAC-SHA256 of <timestamp>.<raw body> with the signing secret | The 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 |
| Headers | User-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 within | 3 seconds | 3 seconds | 10 seconds |
| Retries | No 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 off | After 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. |
| Volume | Up 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. |
| Test | type: "test", from Send test | event: "test", from Send test in the editor | event: "geo.test", from Test beside the endpoint |
| Delivery log | The 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.