§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.
§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.
| Parameter | Type | Required | Default | Description |
status | string | optional | all | active, expired, triggered or archived |
symbol | string | optional | — | Ticker, e.g. AAPL (case-insensitive) |
mechanism | string | optional | — | Exact mechanism name, e.g. exhaustion |
direction | string | optional | — | long or short |
minConfidence | number | optional | — | 0–1; only signals with at least this confidence |
from | date | optional | — | YYYY-MM-DD, UTC, inclusive; created on or after |
to | date | optional | — | YYYY-MM-DD, UTC, inclusive; created on or before |
sort | string | optional | confidence | confidence or recent (newest first) |
limit | integer | optional | 50 | 1–200 rows per page |
offset | integer | optional | 0 | Rows 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).
| Parameter | Type | Required | Default | Description |
status | string | optional | all | draft, testing, validated or rejected |
mechanism | string | optional | — | Exact mechanism name |
granularity | string | optional | — | daily, intraday or trade |
symbol | string | optional | — | Only hypotheses that have been tested on this ticker |
q | string | optional | — | Text search in name and description (max 200 chars) |
tested | boolean | optional | — | true = has at least one run, false = never run |
verdict | string | optional | — | passed, failed or inconclusive — the majority verdict across the sweep |
min_trades | integer | optional | — | At least this many pooled sweep trades |
mine | boolean | optional | false | true = only your own hypotheses (needs a key) |
saved | boolean | optional | false | true = only hypotheses you have starred (needs a key) |
author | integer | optional | — | Only hypotheses by this user id |
sort | string | optional | created | created, name, last_tested, expected_value, sample_size, swept_ev, swept_trades, swept_ci_low or swept_passed |
order | string | optional | desc | asc or desc |
limit | integer | optional | 50 | 1–200 rows per page |
offset | integer | optional | 0 | Rows 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.
| Parameter | Type | Required | Default | Description |
status | string | optional | all | active, paused or closed |
limit | integer | optional | 25 | Rows per page; values above 100 are capped at 100 |
offset | integer | optional | 0 | Rows 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
| Event | Sent when | Sent to |
signal.generated | The market monitor fires a new signal for a hypothesis | Every active endpoint subscribed to it |
signal.expired | A live signal is no longer detected and is archived | Every active endpoint subscribed to it |
position.opened | One of your paper portfolios opens a position | Your own endpoints only |
position.closed | One of your paper portfolios closes a position | Your own endpoints only |
ping | You press Send test on an endpoint | That 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.