Developers

API reference

Read your signals, hypotheses, mechanisms and paper portfolios from your own scripts, spreadsheets and trading tools. The API returns exactly what you see when you are logged in to Whalur — same data, same privacy rules.

Last updated: 30 September 2026

§1

Authentication with X-API-Key

Every API request is authenticated with a personal API key. Create one under Account → Settings → API keys. The full key is shown once, when you create it — Whalur stores only a hash, so copy it somewhere safe straight away. You can revoke a key from the same panel at any time; it stops working immediately.

Send the key in the X-API-Key request header:

curl https://whalur-backend.guud.ai/api/signals \
  -H "X-API-Key: YOUR_API_KEY"
const res = await fetch("https://whalur-backend.guud.ai/api/signals", {
  headers: { "X-API-Key": process.env.WHALUR_API_KEY },
});
const data = await res.json();
  • Base URL. All endpoints live under https://whalur-backend.guud.ai.
  • Read-only. A key authorises GET requests on the endpoints listed in Endpoints. It acts as you: private hypotheses and portfolios you own are included, other members’ private data never is.
  • Keep it server-side. Treat a key like a password. Do not put it in browser code, a mobile app bundle or a public repository — anyone holding it can read your data until you revoke it.
  • No key at all. A request without the header is treated as an anonymous visitor: you get only public data, exactly as a logged-out browser would.

Invalid or revoked key

If the X-API-Key header is present but the key is malformed, unknown, revoked, or belongs to an account that is no longer active, the request is rejected with 401 Unauthorized — it is never silently downgraded to anonymous, so a script with a dead key finds out straight away:

HTTP/1.1 401 Unauthorized
Content-Type: application/json

{
  "error": "invalid or revoked API key"
}

Every error response has the same shape: a JSON object with a human-readable error string. Check the HTTP status code rather than matching on the text, which may be reworded. If you see a 401, create a new key and update your integration — retrying with the same key will not help.

§2

Rate limits

Each API key may make 120 requests per minute. The window is a rolling 60 seconds, and the budget belongs to the key, not to your IP address — ten servers sharing one key share one budget, and two keys each get their own.

Go over the limit and the request is refused with 429 Too Many Requests. The response carries a Retry-After header and the same number of seconds in the JSON body as retry_after:

HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 17

{
  "error": "API key rate limit exceeded — slow down and retry",
  "retry_after": 17
}

How to handle it:

  • Wait, then retry. Pause for retry_after seconds (or the Retry-After header — they always match) before sending the next request with that key. Retrying sooner is refused again and does not shorten the wait.
  • Refused requests are free. A 429 does not count against your budget and does not update the key’s “last used” time.
  • Spread your polling. Signals are refreshed by the market monitor, not continuously, so polling every few seconds rarely gets you newer data. Once a minute is plenty for most integrations — or use webhooks to be told when something changes.
async function whalur(path) {
  for (;;) {
    const res = await fetch("https://whalur-backend.guud.ai" + path, {
      headers: { "X-API-Key": process.env.WHALUR_API_KEY },
    });
    if (res.status !== 429) return res;
    const body = await res.json().catch(() => ({}));
    const wait = Number(res.headers.get("Retry-After")) || body.retry_after || 60;
    await new Promise((r) => setTimeout(r, wait * 1000));
  }
}

§3

Endpoints

An API key can read these four endpoints:

All parameters are optional query-string parameters. An invalid value (an unknown status, a limit out of range, a malformed date) is rejected with 400 Bad Request and an error message naming the parameter — it is never silently ignored. Dates and times in responses are ISO 8601 in UTC. The sample responses below are trimmed to one row each.

GET /api/signals

Signals raised by the market monitor when a validated hypothesis’s entry conditions fire. Signals are the same for everyone, so the list does not depend on who the key belongs to. By default active signals come first, highest confidence first.

ParameterTypeRequiredDefaultDescription
statusstringoptionalallactive, expired, triggered or archived
symbolstringoptional—Ticker, e.g. AAPL (case-insensitive)
mechanismstringoptional—Exact mechanism name, e.g. exhaustion
directionstringoptional—long or short
minConfidencenumberoptional—0–1; only signals with at least this confidence
fromdateoptional—YYYY-MM-DD, UTC, inclusive; created on or after
todateoptional—YYYY-MM-DD, UTC, inclusive; created on or before
sortstringoptionalconfidenceconfidence or recent (newest first)
limitintegeroptional501–200 rows per page
offsetintegeroptional0Rows to skip, for paging
curl "https://whalur-backend.guud.ai/api/signals?status=active&minConfidence=0.6&limit=1" \
  -H "X-API-Key: YOUR_API_KEY"
{
  "count": 14,
  "statusCounts": { "active": 14, "expired": 212, "triggered": 37, "archived": 9 },
  "signals": [
    {
      "signal_id": 4821,
      "hypothesis_id": 312,
      "hypothesis_name": "Volume climax fade on large caps",
      "symbol": "NVDA",
      "direction": "short",
      "mechanism": "exhaustion",
      "confidence": 0.74,
      "expected_value": 0.0062,
      "price": 131.42,
      "status": "active",
      "active": true,
      "barDate": "2026-09-29",
      "sector": "Technology",
      "source": "monitor",
      "created_at": "2026-09-29T20:05:11.000Z",
      "updated_at": "2026-09-29T20:05:11.000Z"
    }
  ],
  "pagination": { "limit": 1, "offset": 0, "count": 1 },
  "meta": { "from": null, "to": null }
}

count is the total matching your filters (use it to page with offset); statusCounts ignores the status filter so you can show all four tallies at once. expected_value is the hypothesis’s expected return per trade as a fraction (0.0062 = 0.62%).

GET /api/hypotheses

Hypotheses visible to the key’s owner: every public hypothesis, plus your own, your friends’, and those of creators you have bought access from. Without a key you get public hypotheses only. Each row carries the pooled evidence from its symbol sweep (swept_* fields).

ParameterTypeRequiredDefaultDescription
statusstringoptionalalldraft, testing, validated or rejected
mechanismstringoptional—Exact mechanism name
granularitystringoptional—daily, intraday or trade
symbolstringoptional—Only hypotheses that have been tested on this ticker
qstringoptional—Text search in name and description (max 200 chars)
testedbooleanoptional—true = has at least one run, false = never run
verdictstringoptional—passed, failed or inconclusive — the majority verdict across the sweep
min_tradesintegeroptional—At least this many pooled sweep trades
minebooleanoptionalfalsetrue = only your own hypotheses (needs a key)
savedbooleanoptionalfalsetrue = only hypotheses you have starred (needs a key)
authorintegeroptional—Only hypotheses by this user id
sortstringoptionalcreatedcreated, name, last_tested, expected_value, sample_size, swept_ev, swept_trades, swept_ci_low or swept_passed
orderstringoptionaldescasc or desc
limitintegeroptional501–200 rows per page
offsetintegeroptional0Rows to skip, for paging
curl "https://whalur-backend.guud.ai/api/hypotheses?status=validated&sort=swept_ev&limit=1" \
  -H "X-API-Key: YOUR_API_KEY"
{
  "hypotheses": [
    {
      "hypothesis_id": 312,
      "name": "Volume climax fade on large caps",
      "description": "After a 3σ volume spike into a new 20-day high, fade the move next session.",
      "plain_english": "When a big stock trades far more than usual at a fresh high, it tends to give some of it back the next day.",
      "mechanism": "exhaustion",
      "status": "validated",
      "visibility": "public",
      "data_granularity": "daily",
      "user_id": 58,
      "expected_value": "0.0062",
      "sample_size": 184,
      "run_count": 41,
      "symbols_tested": 41,
      "runs_passed": 27,
      "runs_failed": 6,
      "runs_inconclusive": 8,
      "last_tested_at": "2026-09-28T03:12:44.000Z",
      "swept_trades": 184,
      "swept_symbols": 41,
      "swept_passed": 27,
      "swept_failed": 6,
      "swept_inconclusive": 8,
      "swept_ev_per_trade": 0.0062,
      "swept_ci_low": 0.0011,
      "swept_ci_high": 0.0113,
      "swept_ci_confidence": 0.95,
      "completion_pct": 100,
      "author": { "user_id": 58, "name": "Dana R.", "handle": "danar", "is_creator": true },
      "starred": false,
      "is_owner": false,
      "created_at": "2026-08-14T09:30:02.000Z",
      "updated_at": "2026-09-28T03:12:44.000Z"
    }
  ],
  "pagination": { "limit": 1, "offset": 0, "count": 1 }
}

pagination.count is the number of rows on this page, not a grand total — keep requesting with a larger offset until a page comes back with fewer than limit rows. expected_value may arrive as a decimal string; parse it as a number.

GET /api/mechanisms

Every market mechanism Whalur tracks — the built-in taxonomy plus any the research loop has discovered — with how many hypotheses test it and the evidence pooled across their latest backtests. The list is short and shared by everyone, so it is returned in one response, sorted discovered-first then by pooled EV per trade.

Query parameters: none. Any parameters you send are ignored.

curl https://whalur-backend.guud.ai/api/mechanisms \
  -H "X-API-Key: YOUR_API_KEY"
{
  "count": 18,
  "discoveredCount": 3,
  "mechanisms": [
    {
      "mechanism": "exhaustion",
      "description": "Exhaustion after an extreme volume spike tends to reverse the move.",
      "inTaxonomy": true,
      "discovered": false,
      "hypotheses": {
        "total": 12,
        "byStatus": { "draft": 1, "testing": 4, "validated": 5, "rejected": 2 }
      },
      "evidence": {
        "testedHypotheses": 11,
        "runCount": 356,
        "symbolsTested": 58,
        "sectorsCovered": ["Consumer", "Energy", "Financials", "Technology"],
        "tradeCount": 1742,
        "pooledEvPerTrade": 0.0041,
        "bestEvPerTrade": 0.0093,
        "avgProfitablePeriodRate": 0.61,
        "lastTestedAt": "2026-09-29T02:48:10.000Z"
      },
      "activeSignals": 3
    }
  ]
}

GET /api/portfolios

Your own paper portfolios, newest first. This endpoint always needs a key — a request without one gets 401 — and only ever returns portfolios that belong to the key’s owner. For a portfolio’s trades use GET /api/portfolios/{id}/trades?format=csv, the same file as the Download trades (CSV) button on the portfolio page.

ParameterTypeRequiredDefaultDescription
statusstringoptionalallactive, paused or closed
limitintegeroptional25Rows per page; values above 100 are capped at 100
offsetintegeroptional0Rows to skip, for paging
curl "https://whalur-backend.guud.ai/api/portfolios?status=active" \
  -H "X-API-Key: YOUR_API_KEY"
{
  "portfolios": [
    {
      "portfolio_id": 77,
      "user_id": 58,
      "strategy_id": 19,
      "strategy_name": "Large-cap reversals",
      "name": "Reversals — paper",
      "status": "active",
      "starting_cash": 100000,
      "position_size_pct": 5,
      "max_positions": 10,
      "stop_pct": 3,
      "target_pct": 6,
      "is_public": false,
      "open_positions": 4,
      "blocked_reason": null,
      "paused_at": null,
      "closed_at": null,
      "created_at": "2026-09-02T14:21:07.000Z",
      "updated_at": "2026-09-29T20:05:13.000Z"
    }
  ],
  "total": 2,
  "limit": 25,
  "offset": 0
}

total is the number of your portfolios matching status. position_size_pct, stop_pct and target_pct are percentages (5 = 5%). blocked_reason says why the portfolio is not trading right now — "execution disarmed", "no active signals for this portfolio's hypotheses", "paused" or "closed" — and is null when nothing is in the way.

§4

Webhooks

Rather than polling, you can have Whalur send a signed HTTPS request to your server when a signal is generated or expires, or a paper-portfolio position opens or closes. Add an endpoint under Account → Settings → Webhooks, choose the events it should receive, and copy its signing secret — it is shown once, when the endpoint is created or its secret is rotated.

Events

EventSent whenSent to
signal.generatedThe market monitor fires a new signal for a hypothesisEvery active endpoint subscribed to it
signal.expiredA live signal is no longer detected and is archivedEvery active endpoint subscribed to it
position.openedOne of your paper portfolios opens a positionYour own endpoints only
position.closedOne of your paper portfolios closes a positionYour own endpoints only
pingYou press Send test on an endpointThat endpoint

Every delivery is a POST with a JSON body of the same shape — event, a unique id, created_at and the event’s data — and these headers:

  • X-Whalur-Event — the event name, same as event in the body.
  • X-Whalur-Delivery — the delivery id, same as id. It stays the same across retries, so use it to ignore duplicates.
  • X-Whalur-Attempt — 1, 2 or 3.
  • X-Whalur-Timestamp — the unix time (seconds) the attempt was signed.
  • X-Whalur-Signature — sha256= followed by the hex HMAC-SHA256 of <timestamp>.<raw body>, keyed with your endpoint’s secret.

Signal payloads

{
  "event": "signal.generated",
  "id": "evt_3f1c9a2e-5b7d-4c61-9e0a-8d2f4b6a1c73",
  "created_at": "2026-09-29T20:15:04.512Z",
  "data": {
    "signal_id": 4812,
    "hypothesis_id": 231,
    "symbol": "NVDA",
    "direction": "long",
    "mechanism": "exhaustion",
    "confidence": 0.72,
    "expected_value": 0.0058,
    "price": 118.42,
    "bar_date": "2026-09-29"
  }
}

signal.expired carries the same data plus a reason, for example "no longer detected".

Position payloads

{
  "event": "position.closed",
  "id": "evt_9b2e4d10-7a3c-4f58-b1e6-2c0d8f5a9e41",
  "created_at": "2026-10-02T14:30:11.087Z",
  "data": {
    "portfolio_id": 77,
    "portfolio_position_id": 1406,
    "signal_id": 4812,
    "hypothesis_id": 231,
    "symbol": "NVDA",
    "direction": "long",
    "qty": 42,
    "price": 125.53,
    "stop_price": 114.87,
    "target_price": 125.53,
    "pnl": 298.62,
    "return_pct": 6.0042,
    "exit_reason": "target"
  }
}

position.opened has the same fields; price is the fill price and pnl, return_pct and exit_reason are null until the position closes. On close, price is the exit price, return_pct is a percentage (6.0042 = 6.0042%) and exit_reason is "stop", "target", "signal_exit" or "max_hold". The ping test event’s data holds webhook_id, the endpoint’s subscribed events and a short message.

Verifying the signature (Node.js)

Compute the HMAC over the raw request body exactly as it arrived — not over JSON.parse’d and re-serialised JSON, which can reorder keys or change number formatting and will never match. Compare in constant time, and reject timestamps more than five minutes old to stop replays.

const crypto = require("crypto");
const express = require("express");

const SECRET = process.env.WHALUR_WEBHOOK_SECRET; // from Account → Settings → Webhooks
const TOLERANCE_SECONDS = 5 * 60;

function verifyWhalurSignature(rawBody, timestamp, signature) {
  if (!timestamp || !signature) return false;
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;

  const expected = "sha256=" + crypto
    .createHmac("sha256", SECRET)
    .update(`${timestamp}.${rawBody}`, "utf8")
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(String(signature));
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

// express.raw keeps the body as a Buffer so we sign exactly what was sent.
app.post("/whalur-webhook", express.raw({ type: "application/json" }), (req, res) => {
  const ok = verifyWhalurSignature(
    req.body.toString("utf8"),
    req.get("X-Whalur-Timestamp"),
    req.get("X-Whalur-Signature")
  );
  if (!ok) return res.status(401).send("bad signature");

  const event = JSON.parse(req.body);
  // De-dup on event.id (== X-Whalur-Delivery): retries reuse it.
  console.log(event.event, event.data);
  res.sendStatus(204); // answer fast; do slow work after responding
});

app.listen(3000);

Responses and retries

Any 2xx within 10 seconds counts as delivered. A timeout, a network error, a 429 or a 5xx is retried — up to three attempts in total, a few seconds apart. Other 4xx responses are not retried: we take them to mean the request itself was refused. Every attempt is logged, and an endpoint that fails 20 events in a row is paused; turn it back on from the Webhooks panel once it is fixed.

Powered by guud.ai