Alerts and webhooks
Rules that POST a signed webhook to your URL the moment a matching article arrives.
Reading rules and deliveries and dry runs need alerts:read; creating, changing and deleting rules and retrying deliveries need
alerts:write (which includes alerts:read). Every endpoint only ever sees your own organization's rules. You can also manage rules
and inspect deliveries in the dashboard.
Manage rules
| Method | Path | |
|---|---|---|
| GET | /v1/alerts/rules | List your rules |
| POST | /v1/alerts/rules | Create a rule. The response includes its webhook_secret, shown once. 409 when your plan's rule limit is reached |
| PATCH | /v1/alerts/rules/{id} | Replace name, criteria, URL or enabled |
| DELETE | /v1/alerts/rules/{id} | Delete |
| POST | /v1/alerts/rules/{id}/test | Dry run against the last 24 hours of articles |
| GET | /v1/alerts/deliveries?status= | Delivery log |
| POST | /v1/alerts/deliveries/{id}/retry | Send a failed (or delivered) webhook again, resetting its attempts. 10 per minute |
Rule fields
Body of POST and PATCH (PATCH replaces the whole rule, so send every field).
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
name | string | yes | 1 to 200 characters | |
criteria | object | yes | See Criteria. Needs at least one condition | |
webhook_url | URL | yes | Must resolve to a public address | |
enabled | boolean | no | true | Disabled rules never fire |
Criteria
All conditions you set must hold (logical AND), and a rule needs at least one of keywords_any, keywords_all, symbols,
source_ids, sentiment, min_abs_sentiment or min_impact. Every field is optional.
| Field | Type | Values | Default | Matches when |
|---|---|---|---|---|
keywords_any | string[] | words, phrases, prefix* | [] | At least one keyword appears |
keywords_all | string[] | words, phrases, prefix* | [] | Every keyword appears |
exclude | string[] | words, phrases, prefix* | [] | None of them appears. Checked on its own, so it does not count as a condition |
symbols | string[] | tickers as listed by /v1/assets, case-insensitive, e.g. BTC, EURUSD, XAUUSD | [] | The article is tagged with at least one of them |
sentiment | string | positive, negative, neutral | none | The article's sentiment label equals it |
min_abs_sentiment | number | 0 to 1 | none | The sentiment score is at least this strong in either direction |
min_impact | number | 0 to 1 | none | The article's impact score is at least this |
source_ids | string[] | source ids (the source_id field on articles from news search) | [] | The article comes from one of these sources |
include_duplicates | boolean | true, false | false | false skips articles flagged as duplicates of an earlier story |
Out-of-range numbers or an unknown sentiment value are rejected with 422.
Keyword matching
- Matching runs over the title and summary, case-insensitively.
- A plain word matches whole words only:
secmatches "SEC sues" but not "sector". - A phrase matches as written:
"rate cut". - A trailing
*matches any word starting with the text:hack*matches "hack", "hacked" and "hackers".
Examples
Set KEY to your API key. Each example is one POST /v1/alerts/rules call.
1. Simplest: one keyword
curl -s https://news.trademcp.dev/v1/alerts/rules \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"name": "Anything on ETFs",
"criteria": {"keywords_any": ["etf"]},
"webhook_url": "https://example.com/hooks/datap"}'The response contains the rule id and its webhook_secret. Store the secret now, it is not shown again.
2. One asset, strong moves only
{"name": "BTC negative news",
"criteria": {"symbols": ["BTC"], "sentiment": "negative", "min_abs_sentiment": 0.5},
"webhook_url": "https://example.com/hooks/datap"}3. Keywords with exclusions
{"name": "Exchange security incidents",
"criteria": {"keywords_any": ["hack*", "exploit*", "breach*", "outage"],
"keywords_all": ["exchange"],
"exclude": ["rumour*", "unconfirmed"]},
"webhook_url": "https://example.com/hooks/datap"}4. Real-world: FX desk, high-impact central bank news
Alert when a major central bank story touches your pairs, from chosen sources only, with duplicates and rumours filtered out.
{"name": "FX desk: central bank surprises",
"criteria": {
"keywords_any": ["rate cut", "rate hike", "rate decision", "intervention", "hawkish", "dovish"],
"exclude": ["rumour*", "speculat*", "opinion"],
"symbols": ["EURUSD", "GBPUSD", "USDJPY", "USD"],
"min_impact": 0.6,
"min_abs_sentiment": 0.4,
"source_ids": ["<reuters-source-id>", "<bloomberg-source-id>"],
"include_duplicates": false},
"webhook_url": "https://trading.example.com/hooks/datap",
"enabled": true}5. Real-world: crypto risk monitor, one rule per concern
Rules are independent, so split concerns into separate rules and route each to its own URL:
{"name": "Regulatory: SEC and ETFs",
"criteria": {"keywords_any": ["sec", "etf*", "lawsuit", "ban*"], "symbols": ["BTC", "ETH"], "min_impact": 0.4},
"webhook_url": "https://example.com/hooks/compliance"}{"name": "Security: hacks and depegs",
"criteria": {"keywords_any": ["hack*", "exploit*", "depeg*", "drain*"], "sentiment": "negative", "min_impact": 0.3},
"webhook_url": "https://example.com/hooks/security-pager"}Try it before going live
Dry-run any rule against the last 24 hours of articles. It returns the match count and up to 20 sample hits, and sends nothing.
curl -s -X POST https://news.trademcp.dev/v1/alerts/rules/$RULE_ID/test -H "X-API-Key: $KEY"{"matched": 7, "sample": [{"id": "…", "title": "…", "published_at": "…"}]}Too many matches? Raise min_impact, add exclude words, or narrow symbols. Too few? Switch keywords_all to keywords_any,
use prefix*, or lower the thresholds.
Delivery
We POST JSON to your URL:
{"type": "article.matched", "rule_id": "0f3c…", "delivery_id": "…", "attempt": 1,
"article": {"title": "…", "source_name": "…", "symbols": ["EUR", "EURUSD"], "sentiment_label": "negative",
"sentiment_score": -0.41, "impact": 0.66, "url": "https://…"}}with these headers:
X-Datap-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>X-Datap-Delivery: <delivery id>: use it to ignore retries you have already processed
Return any 2xx within 10 seconds. Failed deliveries are retried after 30 s, 1 min, 2 min, 4 min and so on, capped at 1 hour, for
up to 8 attempts. In the delivery log, status is pending (will retry), delivered or failed (gave up). URLs resolving to private, loopback or link-local addresses are refused.
Each delivery in the log has last_status_code and an error_class describing the last failure (your endpoint's response body is
never echoed back):
error_class | Meaning |
|---|---|
http_<code> | Your endpoint answered with that non-2xx status, e.g. http_500 |
timeout | No response within 10 seconds |
connect_error | DNS failure, refused or reset connection |
tls_error | TLS handshake or certificate verification failed |
unsafe_target | The URL resolves to a non-public address; fix the rule's URL (such deliveries cannot be retried) |
Fixed your endpoint? POST /v1/alerts/deliveries/{id}/retry queues the delivery again immediately (409 if it is already queued
or the target is unsafe).
Verify the signature
Check the signature against the raw request body and reject timestamps older than 5 minutes:
import hashlib, hmac, time
def verify(secret: str, body: bytes, header: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
if abs(time.time() - int(parts["t"])) > 300:
return False
mac = hmac.new(secret.encode(), f"{parts['t']}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(mac, parts["v1"])