# Webhooks

> Receive WakeTrades alerts as signed JSON on an HTTPS endpoint instead of Telegram. Link it with PUT /api/v1/webhook, verify the WakeTrades-Signature header, and the free trial starts.

A webhook is the alert channel for agents: WakeTrades sends each copy event (a position opened or closed, a stop hit, the key about to expire…) as a signed JSON `POST` to an HTTPS address the agent chooses. It replaces Telegram; both can be linked at the same time.

Through the API, a webhook or Telegram is required to start copying; an open [stream](https://waketrades.com/docs/api/stream.md) doesn't count. No public HTTPS server? Have your human link Telegram instead: `POST /api/v1/telegram/link` returns a link they open in Telegram.

## Link a Webhook

`PUT /api/v1/webhook` with `{ "url": "https://agent.example.com/waketrades" }`

- The address must be `https`, on port 443, with a public host name (no IP address, `localhost`, `.local` or `.internal`).
- WakeTrades first sends a `webhook.test` event to it. Your endpoint must answer with a `2xx` status within 5 seconds, or the link is refused (`422 webhook_unreachable`).
- **Activation step**: this `webhook.test` is signed with the new secret, which you only receive in the answer to this same `PUT`. Your endpoint can't verify it yet: answer `2xx` to an event of `type` `webhook.test` without checking its signature, then verify every other event. Never act on a `webhook.test`.
- The answer holds the **signing secret** (`whsec_…`). It is shown **once**: store it. Linking again gives a new secret.
- If the trading key is already signed, copying and the 14-day free trial start now (`"state": "trial"`). A new wallet whose webhook host already had a free trial gets none: the answer carries `"noTrial": "webhook_host_used"` and the state is `trial_expired` (pay to copy, [Pay With USDC](https://waketrades.com/docs/api/payments.md)).

`GET /api/v1/webhook` shows the linked address (never the secret). `DELETE /api/v1/webhook` removes it; if no Telegram is linked either, copying pauses until a channel is linked again.

## Events

```json
{
  "id": "evt_mg8k2l1c_9f3a1b2c",
  "type": "open",
  "created": 1791633600000,
  "account": "0xYOUR_WALLET",
  "data": { "coin": "ETH", "side": 1, "sz": 0.4, "px": 2450.5, "notional": 980.2, "leader": "0xLEADER…" }
}
```

| `type` | When |
| --- | --- |
| `open`, `add`, `cut`, `close` | A copy is opened, increased, reduced or closed (`data`: coin, side, size, price, notional, leader, `pnl` on reductions and closes, `reason` on closes) |
| `stop_hit`, `tp_hit` | A stop or take profit of a copy was filled |
| `manual` | You changed a copied position yourself on Hyperliquid |
| `slots_full`, `limit_missed` | A leader's trade wasn't copied: your maximum of open positions is reached (Advanced), or the limit order expired |
| `drawdown_alert` | Your account fell from its highest balance past your level's threshold, deposits and withdrawals excluded. `data`: `{ "level": "info" \| "alert", "drawdownPct", "actions": ["close_losing", "close_all", "pause", "none"] }`. Nothing happens unless you act: `POST /api/v1/close-losing`, `/close-all` or `/pause` |
| `auto_stop` | Advanced only: your automatic stop was reached (drop from your highest balance, deposits and withdrawals excluded). Every copy is closed and copying is paused. `data`: `{ "drawdownPct", "stopPct", "equity", "peak" }`; `peak` is your highest balance adjusted for deposits and withdrawals made since. Resume with `POST /api/v1/resume` |
| `daily_loss_limit` | Advanced only: your balance fell past your daily loss limit (`dailyLossPct`) since its first reading of the UTC day, deposits and withdrawals excluded. No new copies until `until` (24 hours); open copies stay managed. `data`: `{ "equity", "dayStartEquity", "lossPct", "limitPct", "until", "netFlowUsd" }`; `equity` is the balance read, `netFlowUsd` the deposits minus withdrawals since the day's first reading (USDC, plan payment excluded), and `lossPct` is computed on `equity − netFlowUsd` |
| `account_blocked` | New copies are on hold at Prudent, Balanced or Risky. `data`: `{ "reason": "not_unified" \| "manual_positions", "coins" }`: switch with `POST /account/unified`, or close the positions opened outside WakeTrades (on Hyperliquid, MetaMask Perps or another app). Sent at most once a day |
| `trial_started` | Copying and the 14-day free trial started. `data`: `{ "state": "trial", "trialEndsAt", "via": "webhook" \| "telegram", "equity" }` |
| `plan_paid` | A plan payment was applied. `data`: `{ "planKind": "1m" \| "3m" \| "6m", "amount", "until", "state" }` (`until`: the plan's new end, in ms) |
| `account_unified` | WakeTrades switched your Hyperliquid account to a Unified Account (`POST /account/unified`, or the button). `data`: `{ "from", "mode" }` |
| `paused` | Copying paused: no new copies, open ones stay managed. `data`: `{ "state", "reason": "pause" \| "close_all" \| … }` (`close_all` also covers the emergency stop and Advanced's automatic stop) |
| `resumed` | Copying resumed. `data`: `{ "state" }` |
| `trial_j3`, `trial_j1`, `trial_end`, `plan_end` | Free trial or plan ending, then ended |
| `plan_expiring` | Your paid plan ends soon: 30 days before the end of a 6-month plan, 14 days for 3 months, 7 days for 1 month, then the day before. `data`: `{ "days", "until", "planKind", "founderPrice" }` (`founderPrice`: renewing before `until` keeps your founder price). Also in `GET /api/v1/me` as `renewal` while it applies |
| `key_expiring`, `key_invalid` | The trading key ends in `days` days, or no longer works |
| `close_failed`, `error` | A close or another order failed. Closes are retried for 24 hours, more and more spaced out |
| `close_released` | A copy still couldn't be closed after 24 hours (no buyer or seller at a fair price). WakeTrades no longer manages it: close it yourself on Hyperliquid. Its stop stays in place |
| `hip3_unified` | Stock and commodity perps need a unified Hyperliquid account |
| `agent_linked`, `agent_unlinked` | An AI agent was connected to this wallet from the Profile, or disconnected. Sent to Telegram even when notifications are muted ([Connect Your AI Agent](https://waketrades.com/docs/account/connect-ai-agent.md)) |
| `agent_code_expiring` | Your agent's code from the Profile reaches its 180 days in 14 days, 7 days or tomorrow (`days`, `until`). Sent to Telegram even when notifications are muted. Copying is not affected. |
| `announcement` | A message from the WakeTrades team to its clients (maintenance, incident, news). `data`: `{ "id", "text" }`. Also shown on the dashboard until dismissed, and sent to Telegram even when notifications are muted |
| `webhook.test` | Sent once when the webhook is linked |

Trades skipped for another reason are not pushed; they are in `GET /api/v1/orders` and on the [real-time stream](https://waketrades.com/docs/api/stream.md), with a stable `skip` code (`too_small`, `full`, `late`, `not_unified`…; at Advanced also `market_filtered`, `leverage_filtered`, `side_filtered`, `daily_loss_limit`); `reason` is a free internal text that may change. With `PUT /api/v1/notifications { "muted": true }`, position moves are not pushed; action events and `trial_started`, `plan_paid`, `account_unified`, `paused`, `resumed` still are. These five go to the webhook, the [real-time stream](https://waketrades.com/docs/api/stream.md) and `GET /api/v1/journal`, not to Telegram.

## Retries and Duplicates

If your endpoint doesn't answer `2xx` within 5 seconds, the event is sent again after about 1, 5 and 15 minutes, 1 hour, then every 3 hours, and dropped 24 hours after it happened. Each request carries `WakeTrades-Event-Id`, the same as the event's `id` on every retry: store the ids you processed and ignore a repeat. If your endpoint was down longer, catch up with `GET /api/v1/journal`. To get every event and your leaders' moves live, open the [real-time stream](https://waketrades.com/docs/api/stream.md).

Before each delivery, WakeTrades checks where your host name points: an address that resolves to a private network is never called.

## Verify the Signature

Each request carries `WakeTrades-Signature: t=<timestamp in ms>,v1=<hex>`, where `v1` is the HMAC-SHA256 of `<t>.<raw body>` with your secret. Reject requests whose signature doesn't match or whose `t` is more than 5 minutes old.

**Python**

```python
import hmac, hashlib, time

def verify(secret: str, header: str, raw_body: bytes) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t, v1 = parts.get("t"), parts.get("v1")
    if not t or not v1 or abs(time.time() * 1000 - int(t)) > 5 * 60 * 1000:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)
```

**JavaScript (Node)**

```js
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(secret, header, rawBody) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  if (!parts.t || !parts.v1 || Math.abs(Date.now() - Number(parts.t)) > 5 * 60_000) return false;
  const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
  return expected.length === parts.v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
```
