View as Markdown

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, *.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

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":{…}}
HeaderMeaning
X-Tickwire-EventThe event type
X-Tickwire-Event-Id, webhook-idThe event id. Stays the same on every retry and every resend: de-duplicate on it
X-Tickwire-Delivery-IdThis delivery (a resend is a new delivery of the same event)
X-Tickwire-Attempt1 for the first try, then 2, 3, …
X-Tickwire-Timestamp, webhook-timestampUnix seconds when this attempt was signed (fresh on every retry)
X-Tickwire-Signaturesha256=<hex> under your current secret
X-Tickwire-Signaturessha256=<hex> for every active secret, comma-separated (two during a rotation)
webhook-signatureStandard 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 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:

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.

EventWhen
account.createdAn account was added
account.health_changedHealth moved between healthy, stale, disconnected and down, or the connectionStatus on /users/current/… changed. Delivered within 60 s
account.state_changedDeployment state changed (DEPLOYING, DEPLOYED, …)
account.engine_state_changedThe terminal engine on the host changed state
account.liveThe terminal logged in and is serving for the first time
subscription.updatedYour subscription changed
risk.tracker_exceededA risk tracker crossed its threshold
trade.outcomeOutcome 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_releasedPre-trade hook decisions
pretrade.hook_failed_open, pretrade.hook_unavailableYour pre-trade hook failed
agent.kill_switch_changedAn agent kill switch was engaged or released
webhook.endpoint_disabledOne of your endpoints was disabled: after 72 h of failures (failing_72h) or because its URL is not allowed (url_not_allowed)
webhook.testA test event you sent from the dashboard
account.connected, account.disconnectedThe terminal’s broker link came up or went down, as the account’s health reports it
account.login_failedThe terminal tried to log in and the broker refused it. The payload never contains credentials
account.margin_callThe terminal-reported margin level fell to or below the broker’s margin-call level (percent mode only; once per crossing)
account.stop_outThe terminal reports a deal closed by stop-out (MT5 deal reason SO; on MT4, the broker’s so: comment)
position.opened, position.modified, position.closedA position appeared, had its SL, TP or volume changed, or left the position list
order.filled, order.cancelled, order.rejectedA pending order filled or was cancelled/expired (from the terminal’s order history), or an order Tickwire sent was rejected
trade.outcome_unknownAn 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.releasedA 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.

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
}
QueryMeaning
statuspending, sending, delivered, failed, or retrying (failed, with a retry scheduled)
eventType, eventIdFilter by event
since, untilISO-8601 times, on createdAt
limit, cursorPage 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:

  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 pathUse
GET /dashboard/webhooksEndpoints, with a masked secret
GET /dashboard/webhooks/catalogEvent types with example payloads, for the filter picker
GET /dashboard/webhooks/{id}/settingsStatus, disabledReason, failingSince, lastSuccessAt, enabledEvents, rotation state
PATCH /dashboard/webhooks/{id}{enabledEvents?, url?, description?}
POST /dashboard/webhooks/{id}/enable, /disableRe-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}/deliveriesThe 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}/resendThe Resend button (202)
POST /dashboard/webhooks/{id}/testSend 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.