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.