Events API quickstart: from key to first sync

How to use your DataSignals Events API key: where the header goes, your first call, and the cursor loop that keeps an hourly job from ever seeing the same filing event twice. Copy-paste examples in curl, Python and Node.

You have a key that looks like ds_live_ followed by a long string. This page takes you from that key to a job that syncs every hour without duplicates. It takes about ten minutes, and the first call takes thirty seconds.

Where does the key go?

Nothing to install and nothing to log in to. The key is an HTTP header that you send with every request:

Authorization: Bearer ds_live_your_key_here

If you have never sent a header before: it is one line of text your program attaches to the request, the way a letter carries a return address. Every HTTP client can do it. Three that you probably already have:

# Terminal (macOS, Linux, or Windows PowerShell)
curl -H "Authorization: Bearer ds_live_your_key_here" \
  "https://datasignalslab.com/v1/events?limit=5"
# Python
import requests

r = requests.get("https://datasignalslab.com/v1/events",
                 headers={"Authorization": "Bearer ds_live_your_key_here"},
                 params={"limit": 5})
print(r.json()["events"][0])
// Node
const r = await fetch("https://datasignalslab.com/v1/events?limit=5", {
  headers: { Authorization: "Bearer ds_live_your_key_here" },
});
console.log((await r.json()).events[0]);

If a header is awkward in your tool, ?api_key=ds_live_... works too. Use it only where the URL stays private: query strings end up in server logs, browser history and proxy records, and a key in a log is a key in someone else's hands.

Keep the key server-side. Anyone holding it can spend your quota. Never put it in a web page, a mobile app, or a public repository. If it leaks, mail support@datasignalslab.com and we will swap it.

Your first call

Run one of the three above. You get back an object with four parts:

{
  "events": [ ... ],
  "cursor": 928,
  "has_more": false,
  "quota": { "limit": 25000, "used": 5, "remaining": 24995 }
}

events is the payload. cursor is your bookmark. has_more tells you whether more is waiting right now. quota is what you have left this period, so you never have to guess.

What one record looks like

Every stream uses the same shape, so code written for insider clusters also reads FDA actions:

{
  "event_id": "insider_cluster:CAMP:2026-08-03",
  "event_type": "insider_cluster",
  "occurred_at": "2026-08-03",
  "company": { "name": "Camp4 Therapeutics Corp", "ticker": "CAMP" },
  "score": 67.2,
  "scored_on": "2026-08-06",
  "score_inputs": { "formula": "insider_cluster/v1", "inputs_complete": true },
  "source": { "url": "https://www.sec.gov/...", "form": "4", "publisher": "SEC EDGAR" },
  "proof": { "status": "anchored", "day": "2026-08-06" }
}

Two fields are worth knowing on day one. inputs_complete is false when a term of the scoring formula is missing from the published source, instead of a number quietly invented to fill the gap. proof.status is anchored when the record's source file was found in the hash chain we publish daily, and pending when it was ingested after last night's chain was written. Both are looked up, not asserted.

GET /v1/event-types lists every field each stream carries. It needs no key, so you can read it before you write a parser.

Never see the same event twice

This is the part that makes it a feed instead of a download, and it is the only concept worth learning.

Every response carries a cursor. Store it. Pass it back as since and you get only what arrived after it. Nothing repeats, nothing is skipped, and it does not matter whether you call once an hour or once a week.

import json
import os
import requests

KEY = os.environ["DATASIGNALS_KEY"]      # never hard-code the key
STATE = "cursor.json"

def load_cursor():
    if os.path.exists(STATE):
        return json.load(open(STATE))["cursor"]
    return 0                              # 0 means "from the very beginning"

def sync():
    cursor = load_cursor()
    while True:
        r = requests.get(
            "https://datasignalslab.com/v1/events",
            headers={"Authorization": f"Bearer {KEY}"},
            params={"since": cursor, "limit": 100},
            timeout=30)
        r.raise_for_status()
        page = r.json()

        for event in page["events"]:
            handle(event)                 # your code: store it, alert on it

        # Save the cursor only after the page is handled. Crash halfway and you
        # replay one page; save it first and you lose that page for good.
        cursor = page["cursor"]
        json.dump({"cursor": cursor}, open(STATE, "w"))

        if not page["has_more"]:
            break

def handle(event):
    print(event["event_type"], event["event_id"], event.get("score"))

sync()

Run that hourly from cron and you are done. The first run walks the archive from since=0 within your monthly allowance; every run after that returns only what is new, which is usually a few hundred records a day.

One rule: save the cursor after you have handled the page, not before. That way a crash costs you a repeated page, which is harmless because event_id is stable, instead of a page you never see again.

Narrowing the feed

All filters combine, and all of them work with since:

Parameter What it does Example
event_type One or more streams, comma separated event_type=fda_action,biotech_catalyst
ticker One or more tickers ticker=AAPL,MSFT
cik One or more SEC company identifiers cik=0000320193
min_score Only records scoring at or above, 0 to 100 min_score=70
since Your cursor from the previous response since=928
limit Records per page, 1 to 1000, default 100 limit=500

Filtering does not cost you extra: only the records actually delivered count against your quota. There are also two shortcuts, GET /v1/events/{event_id} for a single record and GET /v1/companies/{ticker or cik}/events for everything on one company.

When you run out

The feed does not throw an error in the middle of a sync. It returns an empty page, your cursor unchanged, and a plain notice:

{
  "events": [],
  "cursor": 4471,
  "has_more": true,
  "quota": { "limit": 25000, "used": 25000, "remaining": 0 },
  "notice": "Monthly event limit reached. Nothing was skipped: resume from this cursor when your quota resets or you upgrade."
}

Your loop stops cleanly and picks up at exactly the same place when the period resets. GET /v1/usage shows what you have used and which date resets it. That date is your own renewal date, not the first of the month.

When the source changes its mind

The archive is append-only. A record you fetched stays exactly as it was published, even if the filer amends it or the agency restates a number later. That is deliberate: it is what makes the proof chain worth anything. A track record you can quietly edit afterwards is not a track record.

But it would leave you holding a number without knowing it has been revised. So there is a second, much smaller stream that tells you:

curl -H "Authorization: Bearer ds_live_your_key_here" \
  "https://datasignalslab.com/v1/revisions"
{
  "revisions": [
    {
      "event_id": "insider_sale_notice:0001950047-26-006396",
      "event_type": "insider_sale_notice",
      "seen_at": "2026-08-07T04:34:51Z",
      "changed": {
        "score": { "published": 61, "source_now": 55 }
      }
    }
  ],
  "cursor": 26,
  "has_more": false
}

published is what we sent you. source_now is what the source says today. What you do with that is your call: re-score, flag it for review, or ignore it.

It uses the same cursor as the feed, so an hourly job can sync both and never see the same revision twice. A correction is reported once, when we first see it, not every night that it persists.

This does not count against your monthly events. It concerns records you have already paid for, and charging twice for the same fact would be a strange way to run a subscription.

Webhooks instead of polling

If you would rather be pushed to than poll, register an address:

curl -X POST "https://datasignalslab.com/v1/webhooks" \
  -H "Authorization: Bearer ds_live_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://your-server.example/hooks/datasignals",
       "event_types": "fda_action,biotech_catalyst"}'

The response contains a signing secret, shown once. Every delivery carries an HMAC-SHA256 signature over <timestamp>.<raw body>; verify it against the raw bytes you received, before parsing. GET /v1/webhooks/signature documents the exact check with an example.

Each webhook keeps its own cursor, so a receiver that was down for an hour catches up rather than missing that hour. After twenty consecutive failures a webhook switches off and GET /v1/webhooks tells you why. Only https, and only publicly resolvable addresses, checked again immediately before each delivery.

When something goes wrong

Errors say what to do, not just what failed.

Status Meaning What to do
401 no_key No key found on the request Check the header spelling: Authorization: Bearer ds_live_...
401 unknown_key Key is not active Retype it, or mail support if it should be active
403 event_type_locked A free key is bound to one stream Ask for that stream, or move to a paid plan
400 bad_cursor since was not a cursor we issued Pass back the cursor value verbatim, or start at 0
400 unknown_event_type Stream name misspelled The response lists every valid name
503 Our upstream is briefly unavailable Retry in a minute; your cursor is untouched

If you get an error mentioning an Apify token, that is a fault on our side and never something you need to fix. Tell us and we will look at the log.

Using it from an AI assistant, without writing code

If you would rather ask questions than write a loop, there is an MCP server. It runs on your own machine, uses the key you already have, and needs no account anywhere else. Nothing is billed twice: a call through it spends the same monthly allowance as a call you make yourself.

pip install datasignals-events-mcp

Then add it to your assistant's MCP configuration. In Claude Desktop that is claude_desktop_config.json; Cursor, Zed and the rest use the same shape:

{
  "mcpServers": {
    "datasignals-events": {
      "command": "datasignals-events-mcp",
      "env": { "DATASIGNALS_KEY": "ds_live_your_key_here" }
    }
  }
}

Restart the assistant and ask it to run check_setup. After that you can ask in plain language: which FDA actions this week scored above 70, what has been filed on one company across all eleven streams, how fresh the feed is right now, how much of your allowance is left.

The tools return our records unchanged, each with the link to the filing it came from, so the assistant answers from the filing rather than from a summary of one. Two of them, list_event_types and feed_health, work with no key at all.

Checking us before you rely on us

Two endpoints need no key at all, on purpose:

curl https://datasignalslab.com/v1/health
curl https://datasignalslab.com/v1/event-types

/v1/health reports how many hours old each of the eleven sources is right now, and how many records are anchored in the published hash chain against how many are still pending. It is the same number we look at. If a source has gone quiet, you will see it there before you notice it in your data.

Still stuck?

Reply to the mail that carried your key, or write to support@datasignalslab.com. A question about the first call is a question about our documentation, and we would rather fix the page than answer it twice.