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 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,.localor.internal). - WakeTrades first sends a
webhook.testevent to it. Your endpoint must answer with a2xxstatus within 5 seconds, or the link is refused (422 webhook_unreachable). - Activation step: this
webhook.testis signed with the new secret, which you only receive in the answer to this samePUT. Your endpoint can't verify it yet: answer2xxto an event oftypewebhook.testwithout checking its signature, then verify every other event. Never act on awebhook.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 istrial_expired(pay to copy, Pay With USDC).
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
{
"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) |
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, 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 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.
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
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)
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));
}