Register an https endpoint on your organization page to be pushed events instead
of polling.
Events
| Event | Sent when |
|---|---|
price_alert.triggered |
a price alert crosses its threshold |
liquidation_alert.triggered |
a liquidation alert matches a newly extracted liquidation |
chain_event_alert.triggered |
a timeline alert matches a newly imported event |
data_export.ready |
an export has finished and can be downloaded |
liquidation.recorded |
the Aave-style extractor writes a liquidation, whether or not anyone is watching it |
chain_event.created |
a row lands on the event timeline, from an import, a detector or a curator |
Every alert kind publishes under <kind>_alert.triggered; the suite checks
that each one is a name an endpoint can subscribe to, because two of them
were not, once, and every delivery was dropped without a trace.
Who receives what
Events have one of two audiences.
- Tenant events (
*_alert.triggered,data_export.ready) go to one
organization: the billing organization of the user who owns the alert or
requested the export. An alert carries its owner’s label and condition, so
it is never pushed to anyone else’s endpoint, and the publisher refuses to
send one without an organization to send it to. Endpoints belong to
organizations, so a member whose billing organization is their personal
one gets their alerts on the personal organization’s endpoints, not on the
university’s. - Platform events (
liquidation.recorded,chain_event.created)
describe public data and go to every subscribed endpoint on every
organization.
Verifying a delivery
Each POST carries X-DFRDS-Event (the event type) and X-DFRDS-Signature:
HMAC-SHA256 of the raw body, hex encoded, keyed with your endpoint secret.
import hmac, hashlib
def verify(raw_body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
Sign the raw bytes you received, before parsing: re-serializing changes the
bytes and the signature will not match.
The secret is shown once, when you add the endpoint. Rotate secret on the
organization page issues a new one, also shown once; the next delivery is
signed with it and the old secret stops verifying, so put the new one in your
receiver straight away: a delivery it rejects in between counts as a failure
and is retried. Adding, re-enabling,
removing and rotating are all in the organization’s audit log (the URL, never
the secret).
Delivery behaviour
- Reply 2xx quickly; do your work asynchronously. We time out after 10 seconds
and treat that as a failure. - Failures retry with backoff. Ten consecutive failures disable the
endpoint: a dead URL must not become an immortal retry loop. Re-enable it
on the organization page, which clears the streak. - Retries mean a delivery can arrive twice. Treat events as idempotent on their
identifiers. - The per-endpoint delivery log shows what was sent and what came back, so you
can debug your own integration without asking us.
Payloads
A null figure means we could not derive that value. It does not mean zero.
The data_export.ready payload carries a path, not the signed download URL:
that URL expires, and a webhook body can sit in a log for months.
Every body has the same envelope: event, sent_at, and the event’s own
fields under data.
liquidation_alert.triggered
Alerts fire once and stay fired until re-armed, so one alert produces at most
one of these per arming. watching is the condition as it was set;
matched is the liquidation that satisfied it.
{
"event": "liquidation_alert.triggered",
"sent_at": "2026-09-17T14:02:11Z",
"data": {
"alert_id": 42,
"kind": "liquidation",
"label": "Liquidations involving weth",
"watching": { "token": "WETH", "protocols": ["aave_v3"] },
"matched": {
"protocol": "aave_v3",
"tx_hash": "0x…",
"borrower": "0x…",
"collateral": "WETH",
"debt": "USDC",
"debt_value_usd": 12500.0
},
"triggered_at": "2026-09-17T14:02:10Z"
}
}
tx_hash and borrower are complete. debt_value_usd is null when the
debt asset could not be priced at that block.
chain_event_alert.triggered
Same envelope; watching holds whichever of category, chain and
protocol the alert named (all absent means every event), and matched is
the timeline entry.
{
"event": "chain_event_alert.triggered",
"sent_at": "2026-09-17T09:15:00Z",
"data": {
"alert_id": 43,
"kind": "chain_event",
"label": "hack",
"watching": { "category": "hack" },
"matched": {
"title": "Bridge drained",
"category": "hack",
"protocol": "somebridge",
"occurred_on": "2026-09-01",
"amount_usd": 40000000,
"source_url": "https://example.org/report"
},
"triggered_at": "2026-09-17T09:14:59Z"
}
}
amount_usd is what the source published, null when it published nothing.
label is the alert’s own label when it has one, otherwise the same words
the alert centre shows. See Alerts for what each kind
watches.
chain_event.created
Published after the row is committed, once per event, whatever put it there:
the weekly exploit import, the daily depeg detector, the governance importer
or a curator. The payload is the row itself; hashes and addresses are
complete, and a figure the source did not publish is null.
{
"event": "chain_event.created",
"sent_at": "2026-09-17T06:00:04Z",
"data": {
"id": 1201,
"title": "USDT depeg against USDC: trough 0.9612",
"category": "depeg",
"protocol": "USDT",
"occurred_on": "2026-09-15",
"ended_on": null,
"chains": ["Ethereum"],
"amount_usd": null,
"returned_usd": null,
"tx_hashes": [],
"addresses": ["0x3416cf6c708da44db2624d63ea0aaef7113527c6"],
"curated": false,
"source_name": "DFRDS depeg detector",
"source_url": null,
"url": "https://dfrds.com/timeline/1201"
}
}
curated is false for anything a machine filed; a later curation edits the
row in place and does not publish again. See the timeline
for what each source contributes.