Webhooks
Signed event delivery, retries for 72 hours, the delivery log, resend, secret rotation and the event catalog.
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
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"}'
{ "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,*.internaland*.local, and any host that is or resolves to a loopback, private (RFC 1918, IPv6 ULA), link-local (including cloud metadata169.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
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 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.
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(","))
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 spec, so
the official libraries verify Tickwire deliveries unchanged. Pass them your secret as it is:
from standardwebhooks.webhooks import Webhook
payload = Webhook(secret).verify(raw_body, headers) # raises on a bad signature or old timestamp
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
2xxwithin 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 isfailedwithtimeoutand 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. A3xx(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(orwebhook-id), and usecreated_at(and the health snapshot insidedata) 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_disabledevent; - your organisation’s owners and admins get an email.
Fix the endpoint, then re-enable it:
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 *.
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.closedis sent at once when the terminal’s history has the closing deal ("evidence": "closing_deal", withclosing_dealfilled 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.closedis 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.filledfor 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_callandaccount.stop_outare sent only when the terminal reports them; we don’t calculate them ourselves.account.connectedis 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.
curl -s "https://api.tickwire.io/v1/webhooks/wh_91c/deliveries?status=failed&limit=20" \
-H "X-API-Key: $TW_KEY"
{
"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
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
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:
- Rotate.
- Deploy the new secret to your receiver, verifying against
X-Tickwire-Signaturesorwebhook-signature. - 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.