Events feed
Read your account's events in order with a cursor or a named consumer, wait for new ones with a long-poll, and recover after a gap
GET /v1/events lists what happened on your account, oldest first: a discover run that completed, paused or found new rows on a schedule, or a listener that found a new post. Each event appears once, in the order it was written, and you page through the list with a cursor. Reading the feed is free. To have the same events pushed to you instead, or to watch something other than Discover, see Webhooks and events. To read what needs your attention as plain text with a next step, such as a paused webhook or a refused call, see Notifications.
curl -G 'https://www.socialcrawl.dev/v1/events' \
-H 'x-api-key: YOUR_API_KEY' \
--data-urlencode 'types=discover.*' \
--data-urlencode 'limit=2'{
"success": true,
"platform": "events",
"endpoint": "/v1/events",
"data": {
"items": [
{
"id": "evt_k3m7q2xwcbvd4nph6trz5ycgda",
"type": "discover.completed",
"api_version": "2026-10-01",
"created_at": "2026-10-19T10:15:04.212Z",
"cursor": "c_MS4xMi4xNzkyNDA0OTA0",
"subject": { "kind": "discover_run", "id": "run_7Qk2R9xLpV" },
"data": {
"run_id": "run_7Qk2R9xLpV",
"status": "completed",
"stopped_because": null,
"counts": {
"legs": { "planned": 24, "done": 22, "empty": 2, "failed": 0, "skipped": 0, "pending": 0 },
"rows_kept": 148
},
"credits": { "spent": 212 },
"version": 9,
"schedule_id": null
},
"links": {
"run": "https://www.socialcrawl.dev/v1/prism/discover/runs/run_7Qk2R9xLpV",
"results": "https://www.socialcrawl.dev/v1/prism/discover/runs/run_7Qk2R9xLpV/results.jsonl",
"changes": "https://www.socialcrawl.dev/v1/prism/discover/runs/run_7Qk2R9xLpV?changes_since=0"
}
}
],
"retained_from": "2026-09-20T08:02:11.540Z",
"next_step": "Process these events, save pagination.next_cursor, then call data.next.poll_url to wait for new ones.",
"next": {
"poll_url": "https://www.socialcrawl.dev/v1/events?cursor=c_MS4xMi4xNzkyNDA0OTA0&types=discover.*&limit=2&wait_s=25"
},
"dropped": 0
},
"pagination": { "next_cursor": "c_MS4xMi4xNzkyNDA0OTA0", "has_more": false },
"credits_used": 0,
"request_id": "req_9f1c2a7d",
"cached": false
}The first call without a cursor starts at the oldest event still kept (the feed keeps 30 days). pagination.next_cursor is your position after the last event on the page: save it, and send it as cursor on the next call. data.next.poll_url is that next call, ready to copy.
How do I wait for new events?
Add wait_s (up to 25 seconds). When nothing is new, the call holds open and answers as soon as an event arrives, or with an empty page at the deadline. Set your HTTP timeout to wait_s plus 10 seconds.
curl -G 'https://www.socialcrawl.dev/v1/events' \
-H 'x-api-key: YOUR_API_KEY' \
--data-urlencode 'cursor=c_MS4xMi4xNzkyNDA0OTA0' \
--data-urlencode 'wait_s=25'import requests
cursor = "oldest"
while True:
page = requests.get(
"https://www.socialcrawl.dev/v1/events",
headers={"x-api-key": "YOUR_API_KEY"},
params={"cursor": cursor, "types": "discover.*", "wait_s": 25},
timeout=35,
).json()
for event in page["data"]["items"]:
handle(event) # deduplicate on event["id"]
cursor = page["pagination"]["next_cursor"] # save it after processingA key holds at most 5 long-polls at once. A sixth answers at once with what it has and an X-Poll-Interval header: wait that many seconds before the next call. A page also carries an ETag; send it back as If-None-Match and an unchanged page answers 304 with no body.
Named consumers: let the server keep your position
If your code cannot store a cursor (an agent that starts fresh each session, a cron script), read as a named consumer instead. The server keeps the position for your API key, and only an acknowledgement moves it.
curl -G 'https://www.socialcrawl.dev/v1/events' \
-H 'x-api-key: YOUR_API_KEY' \
--data-urlencode 'consumer=crm-sync' \
--data-urlencode 'types=discover.*'The answer carries data.unread (events after the committed position), data.next.ack_url and data.subjects: for each listener the consumer covers (its bound subject_id, or every listener of your key), last_check_at, checks_since_last_read and lanes with each lane's health (ok, degraded, unavailable or skipped). An empty page with checks since your last read means the topic was quiet, not that the listener broke. Process the page, then acknowledge its pagination.next_cursor, an empty page's too:
curl -X POST 'https://www.socialcrawl.dev/v1/events/consumers/crm-sync/ack' \
-H 'x-api-key: YOUR_API_KEY' \
-H 'Content-Type: application/json' \
-d '{"cursor": "c_MS4xMi4xNzkyNDA0OTA0"}'- A read commits nothing. Reading again before you acknowledge returns the same page, so a crash or a lost context loses nothing.
- An acknowledgement never moves backwards, and acknowledging past the last page the consumer was served is a free
400. - Filters are bound at the first read. A later read with different
typesorsubject_idis a400withreason: "conflicting_params"; use a second consumer name for a second filter. A read that omits them uses the bound ones. - A new consumer starts at the oldest kept event. Send
start=nowon its first read to begin with events from now on. - Consumer names are 1 to 64 characters of
a-z,0-9,_and-, and belong to the API key that created them. Run one process per consumer name.
Parameters
| Parameter | What it does |
|---|---|
cursor | Where to read from: oldest (the default), now, a pagination.next_cursor, or an event id (evt_…), which reads the events after that one |
consumer | Read as a named consumer instead of a cursor. Send one of cursor and consumer, never both |
start | With consumer, on its first read only: oldest (the default) or now |
types | Comma-separated event types; discover.* selects every discover type and * every type |
subject_id | Comma-separated ids of the object an event is about, such as a run_id or a schedule id (at most 100). A schedule id also selects its runs' events |
limit | Events per page, 1 to 500 (default 100) |
wait_s | Seconds to hold an empty read open for new events, 0 to 25 (default 0) |
format | How a mention.created post is written: evidence (the full item, the default), compact (about 100 tokens) or text (one line) |
include | raw adds each post's original platform item as evidence.raw. There is no format=raw |
The feed is cursor only: it takes no since or until. A cursor is a position in your whole log, so it stays valid when you change types or subject_id between calls. A page that is not cut short (has_more: false, an empty page too) moves next_cursor to the end of your log, past events your key or filters do not select, so a reader that keeps polling never falls behind the 30-day retention.
Event types
| Type | When |
|---|---|
discover.progress | A background or scheduled run crosses each 10% of its planned searches |
discover.accounts_ready | A view=accounts run finished enriching and judging its accounts |
discover.completed, discover.partial, discover.paused | A background or scheduled run settles in that status |
discover.failed | A background or scheduled run settles failed (a cancel sends nothing) |
discover.new_rows, discover.new_accounts, discover.new_items | A scheduled run settles with new rows for its view |
discover.run_skipped | A schedule occurrence was skipped |
discover.run_failed | A scheduled run failed, or three skips in a row paused the schedule |
mention.created | A listener kept a new post after its first check. The post arrives in the format you ask for |
mention.deleted | A kept post was suppressed: delete every copy you stored (match evidence_id or mention_id) |
stream.baseline.established | A listener's first check finished on every lane |
stream.coverage_gap | A check stopped before it reached posts it had already seen |
stream.paused, stream.resumed, stream.deleted | A listener's status changed |
stream.budget.warning | A listener reached 80% of its monthly credit cap |
stream.health | A lane or a listener changed health (kind says how) |
A comment read under a post a listener kept (comments: "new_posts") is a mention.created too. Its stored payload carries record_type: "comment" and parent_id, the post's evidence.id.
The full payload carries the same two facts inside the evidence: evidence.record_type is comment and evidence.parent.id is the post's evidence.id.
Discover events carry counts and ids, never your keywords, questions or row text. Read the rows through the URLs in links; every URL there is absolute, so you can call it as it is, with your API key.
Listening events carry the post they found: each is stored with ids only and filled in when the feed is read or a webhook batch is frozen, so a post that was suppressed after the event is never served. Its data is { platform, methodology_version, mention: { mention_id, evidence, first_seen_at } }, where evidence is the post in the format you asked for. A post text longer than 32,768 bytes is cut there and the post marked truncated: true; read it whole from the listener's mentions (links.mentions). A post deleted after 30 days arrives as evidence: null with gone: "retention". The other stream.* events carry ids, counts and status words, like Discover's.
What an event looks like
Every event has the same fields in the feed and in a webhook batch: id, type, api_version, created_at, cursor, subject (kind, id and sometimes name), data and links. id is stable: the same fact never produces a second event, so deduplicate on it. cursor is the position right after that event, so a webhook receiver that missed deliveries can read the feed from the last event it processed by sending that event's cursor. Store pagination.next_cursor, not event ids: an event id cannot tell an event that aged out from a mistyped one, so an unknown id answers 400, while a cursor carries its position and time and answers an exact 410 with the missed window.
The feed is in the order events were written. Webhooks are delivered at least once and can arrive out of order; use the feed to fill a gap.
Which events does a key see?
An API key sees the events of the objects it created (its runs, its schedules and its listeners) and events about the whole account. Events about the whole account appear in every key's feed and go to every key's webhook endpoints that subscribe to them. Another key's runs do not appear, and consumers belong to the key that created them. A cursor is a position in the account's whole log, so its position also counts other keys' events; their content stays with them.
Retention and gaps
Events are kept for 30 days. data.retained_from is the time of the oldest event still kept. A cursor older than that answers 410 CURSOR_EXPIRED with oldest_cursor, missed_from and missed_until: restart from cursor=oldest and tell the user which window was missed. An event id sent as the cursor that is not a kept event of your key answers 400 with retained_from and oldest_cursor, since a mistyped id and one older than 30 days look the same. A consumer idle for more than 30 days answers the same 410 once; its next read restarts at the oldest kept event and carries restarted_from once.
Errors
| Status | error.type and details.reason | What to do |
|---|---|---|
| 400 | INVALID_REQUEST, invalid_param | Change the field named in details.param; details.did_you_mean suggests a value when it can |
| 400 | INVALID_REQUEST, conflicting_params | Send cursor or consumer, not both; or use another consumer name for another filter |
| 400 | INVALID_REQUEST, not_offered | stream_id is subject_id here; send the listener's lst_ id as subject_id |
| 404 | RESOURCE_NOT_FOUND, unknown_consumer | Read GET /v1/events?consumer=<name> first: a consumer is created by its first read |
| 410 | CURSOR_EXPIRED, retention_passed | Restart from cursor=oldest and report the missed window |
| 503 | SERVICE_UNAVAILABLE, store_unavailable | Retry after Retry-After seconds |
| 503 | SERVICE_UNAVAILABLE, store_timeout | Retry after Retry-After seconds |
| 503 | SERVICE_UNAVAILABLE, suppression_unavailable | The privacy check on posts could not run, so nothing was served. Retry after Retry-After seconds |
Every error is free and carries details.next_step, one sentence naming the call to make next. See Errors for the envelope.
