# Webhooks

> Signed event delivery, retries for 72 hours, the delivery log, resend, secret rotation and the event catalog.

Source: https://tickwire.io/docs/webhooks/

Register an HTTPS endpoint and we'll POST events to it. You don't need to poll to find out that an
account went down. Every delivery is stored: you can see each attempt, why it failed, and resend it
for 30 days.

## Register

```bash
curl -s -X POST https://api.tickwire.io/v1/webhooks \
  -H "X-API-Key: $TW_KEY" -H "content-type: application/json" \
  -d '{"url": "https://example.com/hooks/tickwire"}'
```

```json
{ "id": "wh_91c", "url": "https://example.com/hooks/tickwire", "secret": "whsec_…", "active": true, "created_at": "2026-09-23T11:00:00+00:00" }
```

Store the `secret`. You can also supply your own. Endpoint management needs a full API key: read-only
keys are refused, because responses contain the secret.

### Which URLs are allowed

- `https://` only.
- The host must resolve only to public internet addresses. We refuse `localhost`, `*.localhost`,
  `*.internal` and `*.local`, and any host that is or resolves to a loopback, private (RFC 1918,
  IPv6 ULA), link-local (including cloud metadata `169.254.169.254`), carrier-grade NAT, multicast
  or reserved address. If any one of its addresses is non-public, the URL is refused.
- No username or password in the URL.

A refused URL gets `400 invalid_url` with the same message whatever the reason. We check again on
**every delivery attempt** and send to the address we checked, so a DNS change to a private address
is refused too. Redirects are never followed: a `3xx` is a failed delivery.

To test with a receiver on your own machine, expose it over HTTPS with a tunnel, then send a
`webhook.test` event from the dashboard.

## What a delivery looks like

```http
POST /hooks/tickwire
Content-Type: application/json
X-Tickwire-Event: account.health_changed
X-Tickwire-Event-Id: 5f0c7a0e9b1d4c2e8f3a6b7c8d9e0f1a
X-Tickwire-Delivery-Id: dlv_4be01f2a9c7d5e3b1a0f8c6d
X-Tickwire-Attempt: 1
X-Tickwire-Timestamp: 1790161333
X-Tickwire-Signature: sha256=3b9f…
X-Tickwire-Signatures: sha256=3b9f…
webhook-id: 5f0c7a0e9b1d4c2e8f3a6b7c8d9e0f1a
webhook-timestamp: 1790161333
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

{"id":"5f0c7a0e9b1d4c2e8f3a6b7c8d9e0f1a","type":"account.health_changed","created_at":"2026-09-23T11:02:13+00:00","data":{…}}
```

| Header | Meaning |
|---|---|
| `X-Tickwire-Event` | The event type |
| `X-Tickwire-Event-Id`, `webhook-id` | The event id. Stays the same on every retry and every resend: de-duplicate on it |
| `X-Tickwire-Delivery-Id` | This delivery (a resend is a new delivery of the same event) |
| `X-Tickwire-Attempt` | 1 for the first try, then 2, 3, … |
| `X-Tickwire-Timestamp`, `webhook-timestamp` | Unix seconds when this attempt was signed (fresh on every retry) |
| `X-Tickwire-Signature` | `sha256=<hex>` under your **current** secret |
| `X-Tickwire-Signatures` | `sha256=<hex>` for **every active** secret, comma-separated (two during a rotation) |
| `webhook-signature` | [Standard Webhooks](https://www.standardwebhooks.com/) signatures, `v1,<base64>`, space-separated, one per active secret |

We strip broker passwords, API keys, tokens and secrets from every event before it is stored or
sent.

## Verify the signature

You can verify either header set. Both use HMAC-SHA256 with your secret.

### Tickwire headers

The signature covers the timestamp, a dot, and the **raw** request body. Verify before parsing, and
reject old timestamps to block replays. Checking every entry of `X-Tickwire-Signatures` lets you
rotate secrets without downtime.

```python
import hashlib, hmac, time

def verify(secret: str, headers: dict, raw_body: bytes, tolerance_s: int = 300) -> bool:
    ts = headers["X-Tickwire-Timestamp"]
    if abs(time.time() - int(ts)) > tolerance_s:
        return False
    expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
    given = headers.get("X-Tickwire-Signatures") or headers["X-Tickwire-Signature"]
    return any(hmac.compare_digest(expected, s.strip().removeprefix("sha256="))
               for s in given.split(","))
```

```js
import crypto from "node:crypto";

export function verify(secret, headers, rawBody, toleranceS = 300) {
  const ts = headers["x-tickwire-timestamp"];
  if (Math.abs(Date.now() / 1000 - Number(ts)) > toleranceS) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${ts}.`).update(rawBody).digest("hex");
  const given = String(headers["x-tickwire-signatures"] || headers["x-tickwire-signature"]);
  return given.split(",").some((s) => {
    const sig = s.trim().replace(/^sha256=/, "");
    return sig.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
  });
}
```

Verifiers that only read `X-Tickwire-Signature` keep working. That header always carries the
current secret's signature.

### Standard Webhooks

The `webhook-*` headers follow the [Standard Webhooks](https://www.standardwebhooks.com/) spec, so
the official libraries verify Tickwire deliveries unchanged. Pass them your secret as it is:

```python
from standardwebhooks.webhooks import Webhook

payload = Webhook(secret).verify(raw_body, headers)   # raises on a bad signature or old timestamp
```

```js
import { Webhook } from "standardwebhooks";

const payload = new Webhook(secret).verify(rawBody, headers);
```

This works for the secrets Tickwire generates (`whsec_…`). If you chose your own secret, use the
`standardSecret` value that `POST /v1/webhooks/{id}/reveal-secret` and `rotate-secret` return
(`whsec_` plus the base64 of your secret).

## Delivery and retries

- Return any `2xx` within 5 seconds. Do slow work after you've responded. Each attempt has a hard
  limit of 10 seconds in total (connecting, sending, and reading your status line, headers and up to
  2 KiB of body). An attempt that takes longer is `failed` with `timeout` and is retried.
- At most 2 deliveries per organisation are in flight at once, so one slow endpoint delays only its
  own organisation's deliveries.
- A delivery counts as **delivered only on a `2xx`**. A `3xx` (we don't follow redirects), `4xx`,
  `5xx`, timeout or connection error is **failed**, and we retry it.
- Retries use exponential backoff with ±20 % jitter: about 5 s, 30 s, 2 min, 10 min and 1 h after
  the first try, then every 3 to 6 hours, until 72 hours after the event. The last try is at the
  72-hour mark.
- Retries are stored in our database, so they survive our deploys and restarts.
- Deliveries can arrive more than once or out of order. De-duplicate on `X-Tickwire-Event-Id` (or
  `webhook-id`), and use `created_at` (and the health snapshot inside `data`) to ignore older news.

### Endpoints that keep failing

If every delivery to an endpoint has failed for **72 hours** without a single `2xx`, we disable the
endpoint:

- its unfinished deliveries stop (you can resend them after you fix it);
- your other endpoints receive a `webhook.endpoint_disabled` event;
- your organisation's owners and admins get an email.

Fix the endpoint, then re-enable it:

```bash
curl -s -X POST https://api.tickwire.io/v1/webhooks/wh_91c/enable \
  -H "X-API-Key: $TW_KEY" -H "X-Reason: fixed TLS certificate"
```

Any `2xx` resets the 72-hour clock.

## Choose which events an endpoint gets

By default an endpoint receives every event (`"*"`). Narrow it with `enabledEvents`: exact types,
family wildcards such as `trade.*`, or `*`.

```bash
curl -s -X PATCH https://api.tickwire.io/v1/webhooks/wh_91c \
  -H "X-API-Key: $TW_KEY" -H "content-type: application/json" \
  -d '{"enabledEvents": ["account.health_changed", "trade.*"]}'
```

An unknown type returns `400 invalid_request`. `GET /v1/webhooks/{id}/settings` shows the filter,
status, `failingSince`, `lastSuccessAt` and the rotation state.

## Events

`GET /v1/webhook-event-types` lists every type with a description and an example payload. No key
is needed.

| Event | When |
|---|---|
| `account.created` | An account was added |
| `account.health_changed` | Health moved between `healthy`, `stale`, `disconnected` and `down`, or the `connectionStatus` on `/users/current/…` changed. Delivered within 60 s |
| `account.state_changed` | Deployment state changed (`DEPLOYING`, `DEPLOYED`, …) |
| `account.engine_state_changed` | The terminal engine on the host changed state |
| `account.live` | The terminal logged in and is serving for the first time |
| `subscription.updated` | Your subscription changed |
| `risk.tracker_exceeded` | A risk tracker crossed its threshold |
| `trade.outcome` | Outcome of an order sent through the agent API: filled, rejected, or `unknown` when the terminal couldn't confirm it |
| `trade.denied`, `trade.held`, `trade.hold_expired`, `trade.hold_released` | Pre-trade hook decisions |
| `pretrade.hook_failed_open`, `pretrade.hook_unavailable` | Your pre-trade hook failed |
| `agent.kill_switch_changed` | An agent kill switch was engaged or released |
| `webhook.endpoint_disabled` | One of your endpoints was disabled: after 72 h of failures (`failing_72h`) or because its URL is not allowed (`url_not_allowed`) |
| `webhook.test` | A test event you sent from the dashboard |
| `account.connected`, `account.disconnected` | The terminal's broker link came up or went down, as the account's health reports it |
| `account.login_failed` | The terminal tried to log in and the broker refused it. The payload never contains credentials |
| `account.margin_call` | The terminal-reported margin level fell to or below the broker's margin-call level (percent mode only; once per crossing) |
| `account.stop_out` | The terminal reports a deal closed by stop-out (MT5 deal reason `SO`; on MT4, the broker's `so:` comment) |
| `position.opened`, `position.modified`, `position.closed` | A position appeared, had its SL, TP or volume changed, or left the position list |
| `order.filled`, `order.cancelled`, `order.rejected` | A pending order filled or was cancelled/expired (from the terminal's order history), or an order Tickwire sent was rejected |
| `trade.outcome_unknown` | An order Tickwire sent could not be confirmed either way. Reconcile with the same idempotency key; never resend with a new one |
| `dedicated_ip.active`, `dedicated_ip.released` | A dedicated IP became active (route confirmed), or was released for good (it is never reassigned) |

### When account and trade events are sent

Tickwire sends these only when the terminal shows the change:

- Position and order events come from comparing two successful reads of the terminal, about every
  2 seconds. Reads taken while the terminal is not connected to its broker, or that fail, are
  skipped, so a reconnect never looks like every position closing and reopening. The first good read
  after a restart is the baseline and sends nothing.
- `position.closed` is sent at once when the terminal's history has the closing deal
  (`"evidence": "closing_deal"`, with `closing_deal` filled in). A position that disappears without
  a closing deal is held back: if it is back on the next read, nothing is sent; if two consecutive
  good reads lack it and there is still no deal, `position.closed` is sent with
  `"evidence": "unknown"` and `"closing_deal": null`. Treat that as "gone, cause unknown" and
  check the history before acting on it.
- A pending order that disappears is reported only from the terminal's order history. With no
  history record after two reads, nothing is sent.
- `order.filled` for a market order comes from the broker's confirmed deal; for a pending order,
  from the order history or from the order's ticket becoming a position.
- `account.margin_call` and `account.stop_out` are sent only when the terminal reports them; we don't
  calculate them ourselves.
- `account.connected` is sent only when the terminal itself reports the broker link up.

All of them work the same on MT4 and MT5 accounts. Each type is in `GET /v1/webhook-event-types`
with an example payload.

### How trade events reach you

Position, order, margin-call and stop-out events start in your account's terminal, a few seconds
after the change. If our event service can't be reached, the terminal keeps up to 1,000 events per
account and retries them, oldest first, every few seconds (backing off to once a minute). Trading
never waits for this.

Delivery is **at least once**. Every event keeps one id across retries, and we usually recognise a
retry of an event we already have and don't send it again. That check covers only recent events and
doesn't survive a restart of our service, so the same event can still reach you twice. It then has
the same `X-Tickwire-Event-Id` (and `webhook-id`): de-duplicate on it.

Some events can still be lost:

- if more than 1,000 pile up during an outage, the oldest are dropped first;
- events that were still queued when the terminal restarted are gone;
- if our event service refuses the terminal's credentials (for example after we reset them), each
  event is retried for 15 minutes and then dropped. Redeploying the account fixes it.

A webhook tells you something changed. After an outage, re-read positions, orders and history to
catch up.

Sandbox accounts don't send position, order, margin-call or stop-out events. To test your handler,
use `POST /v1/webhook-events/trigger` with a sandbox key.

## Delivery log

Every delivery and every attempt is kept for 30 days.

```bash
curl -s "https://api.tickwire.io/v1/webhooks/wh_91c/deliveries?status=failed&limit=20" \
  -H "X-API-Key: $TW_KEY"
```

```json
{
  "data": [{
    "id": "dlv_4be01f2a9c7d5e3b1a0f8c6d",
    "webhookId": "wh_91c",
    "eventId": "5f0c7a0e9b1d4c2e8f3a6b7c8d9e0f1a",
    "eventType": "account.health_changed",
    "url": "https://example.com/hooks/tickwire",
    "status": "failed",
    "willRetry": true,
    "attempts": 3,
    "nextAttemptAt": "2026-09-23T11:14:40Z",
    "lastStatusCode": 502,
    "lastLatencyMs": 181,
    "lastError": "http 502",
    "createdAt": "2026-09-23T11:02:13Z",
    "deliveredAt": null,
    "resendOf": null,
    "resendableUntil": "2026-10-23T11:02:13Z"
  }],
  "hasMore": false,
  "nextCursor": null
}
```

| Query | Meaning |
|---|---|
| `status` | `pending`, `sending`, `delivered`, `failed`, or `retrying` (failed, with a retry scheduled) |
| `eventType`, `eventId` | Filter by event |
| `since`, `until` | ISO-8601 times, on `createdAt` |
| `limit`, `cursor` | Page size (1–200, default 50) and the `nextCursor` of the previous page. Newest first |

`lastError` is `http <code>`, `timeout`, `connection_error: …`, `url_not_allowed` (the URL failed the
rules above at send time; retried, since DNS may change back), or, for deliveries we stopped without
sending, `endpoint_disabled` or `endpoint_deleted`.

An endpoint whose URL can never pass the rules (for example one registered before they applied) gets
no deliveries. Its settings show `status: disabled` and `disabledReason: url_not_allowed`, and your
other endpoints receive `webhook.endpoint_disabled`. Change the URL with `PATCH /v1/webhooks/{id}`,
then enable it.

`GET /v1/webhooks/{id}/deliveries/{deliveryId}` adds `attemptLog` (each attempt's time, outcome,
status code, latency, error and the first 2 KiB of your response), `lastResponseBody`, and the exact
`payload` we signed.

## Resend

```bash
curl -s -X POST https://api.tickwire.io/v1/webhooks/wh_91c/deliveries/dlv_4be0…/resend \
  -H "X-API-Key: $TW_KEY" -H "Idempotency-Key: $(uuidgen)" -H "X-Reason: replay after outage"
```

This queues the same event (same event id, fresh signature) as a new delivery and returns it with
`202`. `POST /v1/events/{eventId}/resend` does the same for every endpoint that received the event
(add `?webhookId=` for one). Resend works for 30 days after the event (`410
resend_window_expired` after that) and only while the endpoint is enabled (`409 conflict`
otherwise).

## Rotate the secret

```bash
curl -s -X POST https://api.tickwire.io/v1/webhooks/wh_91c/rotate-secret \
  -H "X-API-Key: $TW_KEY" -H "Idempotency-Key: rotate-2026-09" \
  -H "content-type: application/json" -d '{"overlapHours": 24}'
```

The response holds the new `secret` and `standardSecret`, once. For `overlapHours` (0–24, default
24) the old secret stays valid: every delivery carries a signature for each secret in
`X-Tickwire-Signatures` and `webhook-signature`, while `X-Tickwire-Signature` switches to the new
secret at once. To rotate without dropping a delivery:

1. Rotate.
2. Deploy the new secret to your receiver, verifying against `X-Tickwire-Signatures` or
   `webhook-signature`.
3. The old secret stops signing when the overlap ends.

Send `{"secret": "…"}` (at least 16 characters) to choose the new secret yourself.
`POST /v1/webhooks/{id}/reveal-secret` shows the current secret, and the previous one while its
overlap lasts.

## Changes and audit

`PATCH /v1/webhooks/{id}`, `enable`, `disable`, `rotate-secret`, `reveal-secret` and both resend
calls accept `Idempotency-Key` (a replay returns the first response), `X-Actor` and `X-Reason`
(percent-encode non-ASCII). Each one is written to your organisation's audit log as a `webhook.*`
action. Audit entries never contain secrets.

## Dashboard API

The dashboard uses the same operations with a dashboard session instead of an API key. Reads need
the `viewer` role and changes need `admin`. The JSON shapes are identical.

| Method and path | Use |
|---|---|
| `GET /dashboard/webhooks` | Endpoints, with a masked secret |
| `GET /dashboard/webhooks/catalog` | Event types with example payloads, for the filter picker |
| `GET /dashboard/webhooks/{id}/settings` | Status, `disabledReason`, `failingSince`, `lastSuccessAt`, `enabledEvents`, rotation state |
| `PATCH /dashboard/webhooks/{id}` | `{enabledEvents?, url?, description?}` |
| `POST /dashboard/webhooks/{id}/enable`, `/disable` | Re-enable (for example after an automatic disable), or pause |
| `POST /dashboard/webhooks/{id}/rotate-secret` | `{overlapHours?, secret?}`. Show the returned `secret` once |
| `GET /dashboard/webhooks/{id}/deliveries` | The delivery table (same filters and cursor as above) |
| `GET /dashboard/webhooks/{id}/deliveries/{deliveryId}` | The detail drawer: attempts, response body, payload |
| `POST /dashboard/webhooks/{id}/deliveries/{deliveryId}/resend` | The Resend button (`202`) |
| `POST /dashboard/webhooks/{id}/test` | Send a `webhook.test` event |

Suggested UI: show `status` and `failingSince` on each endpoint, a banner with an Enable button when
`disabledReason` is `failing_72h`, and in the delivery table the status, `lastStatusCode`,
`lastLatencyMs`, `attempts` and `nextAttemptAt`. Show a Resend button while `resendableUntil` is in
the future.
