# 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.

Source: https://firmtape.com/docs/developers/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

|  | 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

```js
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));
}
```

```python
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:

```js
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));
}
```

## Every event

- [flip_cross](https://firmtape.com/docs/developers/live/events/flip_cross)
- [wall_break](https://firmtape.com/docs/developers/live/events/wall_break)
- [regime_change](https://firmtape.com/docs/developers/live/events/regime_change)
- [wall_move](https://firmtape.com/docs/developers/live/events/wall_move)
- [flip_move](https://firmtape.com/docs/developers/live/events/flip_move)
- [level_near](https://firmtape.com/docs/developers/live/events/level_near)
- [price_alert](https://firmtape.com/docs/developers/live/events/price_alert)
- [level_line](https://firmtape.com/docs/developers/live/events/level_line)
- [order](https://firmtape.com/docs/developers/live/events/order)
- [test](https://firmtape.com/docs/developers/live/events/test)
- [label_changed](https://firmtape.com/docs/developers/orders/events/label_changed)
- [opening_read](https://firmtape.com/docs/developers/orders/events/opening_read)
- [test](https://firmtape.com/docs/developers/orders/events/test)
- [geo.record](https://firmtape.com/docs/developers/geo/events/geo.record)
- [geo.test](https://firmtape.com/docs/developers/geo/events/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.

```json
{ "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`.
