Skip to main content

Searching events

POST /v1/events/search searches Evinor's historical event corpus. Where a sensor tells you about events as they happen, a search tells you about events that already happened, using the same filter vocabulary.

This is the only billed endpoint in the API: running a search charges your account's credit balance. Paging through results you've already paid for does not charge again.

  • Scope required: EVENTS_READ (see Authentication).
  • Method and path: POST https://api.evinor.ai/v1/events/search.
  • Request body: JSON. There are no query parameters.

Building for an agent rather than an application? This endpoint is also exposed over the Model Context Protocol as the search_events and search_events_continue tools — same key, same scope, same credits, same problem types. See the MCP server.

Execute or continue​

One endpoint serves two different calls, distinguished by whether the body carries a cursor:

CallBodyWhat happens
ExecuteFilter fields, no cursorA new search runs against the corpus and returns its first page. Charged.
Continuecursor (and optionally limit) onlyThe next page of the execution that cursor came from. Not re-charged.

The two are mutually exclusive. Sending any filter field alongside a cursor returns 400 validation-failed rather than silently ignoring it — a continuation page always re-uses the filter recorded on the original execution, so a filter sent with a cursor could never take effect.

What is charged​

A cursor-less call is charged a flat cost per execution, regardless of how many events match. A cursor-bearing call is not re-charged — it reads an execution your account has already paid for. (It is not free of work: the engine re-runs from the filter recorded on the execution, so it still costs you a request against your rate-limit budget and takes real time. It just isn't billed a second time.)

When the balance can't cover an execution, the request is refused with 402 insufficient-credits before the search runs — a refused search is never charged.

curl -X POST https://api.evinor.ai/v1/events/search \
-H "Authorization: Bearer evnr_live_your_key_here" \
-H "Idempotency-Key: 3f1c9a2e-6b4d-4e2a-9f77-0c53412fd0e1" \
-H "Content-Type: application/json" \
-d '{
"event_type_id": "0f3c7a41-5b21-4f8e-9c0a-2d6e18b4c9a3",
"involved_entity_ids": ["7c2e1b90-3d44-4a6f-b8e2-9a5c0d71f346"],
"min_reports": 2,
"lookback_days": 90,
"limit": 20
}'

Request fields​

FieldTypeNotes
lookback_daysintegerRequired when cursor is omitted. One of 7, 30, 90, 180, 365. Every search is windowed: it returns events first reported within that many days — see Ordering events in time.
event_type_iduuid | nullRestrict to one event type. Omit (or send null) to search every type.
involved_entity_idsuuid[]Canonical entity ids that must be involved. Up to 500.
involved_entity_list_idsuuid[]Entity lists whose members count as involved. Up to 50.
required_source_news_outlet_idsuuid[]Only count reports from these outlets. Up to 200.
report_source_kindstringANY (default), ARTICLES_ONLY, USER_REPORTS_ONLY, or REPRESENTED_ONLY — which reports count toward a match. See Report source.
represented_entity_idsuuid[]With REPRESENTED_ONLY: only reports submitted on behalf of one of these entities. Up to 500.
represented_entity_list_idsuuid[]With REPRESENTED_ONLY: only reports submitted on behalf of a member of one of these entity lists. Up to 50.
min_reportsintegerMinimum distinct outlets reporting the event, 1–100. Defaults to 1.
role_filtersobject[]Up to 10 predicates on the event's structured roles — see below.
limitintegerPage size, 1–50. Defaults to 20. A larger value is rejected, not clamped.
cursorstringA next_cursor from a previous response. Selects the continuation call.

Any unrecognized field returns 400 validation-failed.

Report source​

report_source_kind narrows which reports can match, with the same meaning as on a sensor filter:

  • ANY — every report. Omitting the field is the same as sending ANY.
  • ARTICLES_ONLY — news-article reports only.
  • USER_REPORTS_ONLY — reports submitted by users only.
  • REPRESENTED_ONLY — user reports submitted on behalf of an entity, optionally narrowed by represented_entity_ids / represented_entity_list_ids (both empty = on behalf of any entity). "On behalf of" is the reporter's own statement — it is not a verification.

The two represented_* fields are accepted only with REPRESENTED_ONLY. Like every filter field, all three are rejected alongside a cursor.

Role filters​

Each entry in role_filters is tagged with a kind and names the role it applies to with role_code_name:

kindAdditional fields
amount_gtemin_value (number), unit (string, optional)
amount_ltemax_value (number), unit (string, optional)
date_afterafter_iso (ISO 8601 timestamp)
date_beforebefore_iso (ISO 8601 timestamp)
entity_incanonical_ids (uuid[], optional), entity_list_ids (uuid[], optional)
text_containsterms (string[], 1–20)
category_invalues (string[], 1–100)
{
"kind": "amount_gte",
"role_code_name": "DEAL_VALUE",
"min_value": 50000000,
"unit": "USD"
}
text_contains and category_in are not enabled yet

Both kinds are accepted by the request schema but are rejected as invalid until their rollout completes. Filters that already hold them are read back as documented below.

text_contains​

Matches when the role's text value contains any of the terms as a whole word or a whole phrase. Matching ignores case, accents and punctuation: both the value and each term are lower-cased, accents are stripped, and every run of characters that is not a letter or digit becomes a single space. So "data" and "head of data" match "Head of Data", but "data" does not match "Update", and "ai" does not match "Chair".

{
"kind": "text_contains",
"role_code_name": "ROLE_TITLE",
"terms": ["chief technology officer", "CTO"]
}

Known limits:

  • Scripts written without spaces (Chinese, Japanese, Thai) have no word boundaries, so a term matches only the whole value.
  • Hyphens and other punctuation become spaces: "co-founder" is matched by "co founder" or "co-founder", but not by "cofounder".

category_in​

Matches when the role's value is exactly one of values. Use it on CATEGORICAL roles; their vocabulary is listed in each role's allowed_values on GET /v1/event-types.

{
"kind": "category_in",
"role_code_name": "STAGE",
"values": ["SEED", "SERIES_A"]
}

Limits​

LimitValue
Characters per text_contains term, after normalization64
Terms per text_contains filter20
Terms across all text_contains filters of one filter set40
Values per category_in filter100
Characters per category_in value128

The "filter set" is one search request, or one beat clause. A term that is only punctuation (for example "!!!") is rejected: it has no letters or digits left to match.

The response​

{
"data": [
{
"id": "9d41b7e2-0c58-4b1a-8f37-2ae6c05d9134",
"event_type": { "code_name": "ACQUISITION" },
"sentence": "Northwind Corp agreed to acquire Contoso for $1.2B.",
"happened_at": "2026-06-14",
"event_date": {
"value": "2026-06-14T00:00:00.000Z",
"source": "STATED",
"precision": "DAY"
},
"first_report_date": "2026-06-14T16:05:00.000Z",
"last_report_date": "2026-06-19T08:30:00.000Z",
"matched_report_count": 7,
"distinct_outlet_count": 4,
"involved_entities": [
{
"id": "7c2e1b90-3d44-4a6f-b8e2-9a5c0d71f346",
"name": "Northwind Corp",
"type": "ORGANIZATION"
},
{
"id": "3b8f6a14-5e2d-4c91-a7b0-1d4e9c62f805",
"name": "Contoso",
"type": "ORGANIZATION"
}
]
}
],
"has_more": true,
"next_cursor": "OWQ0MWI3ZTItMGM1OC00YjFhOjIw",
"execution_id": "c0a8012e-8b3e-4c77-9f21-6b1d0e2a4f55",
"total_count": 143
}

Envelope​

FieldMeaning
dataThe events on this page.
has_moretrue if another page is reachable. Branch on this, never on total_count.
next_cursorThe cursor to send back for the next page, or null when has_more is false.
execution_idThe id of the (already charged) execution these results belong to. Useful for correlating your charge; the cursor already carries it, so you don't need to send it back.
total_countThe exact corpus-wide match count for the filter. See the caveat below.

Event fields​

FieldMeaning
idThe event's canonical id. Stable across merges — store this one.
event_type.code_nameThe event type's stable code name.
sentenceA one-line generated summary of the event, or null when none exists.
happened_atWhen the event itself occurred, as reported. null when unknown. A date-like string, not a normalized timestamp.
event_dateWhen the event itself occurred, with its provenance: { value, source, precision }, or null when no report gives a date. See The event date.
first_report_dateWhen the event was first reported — the publish time of its earliest report, as an ISO 8601 UTC timestamp. Never null. This is the date lookback_days windows on and the order results come back in — see Ordering events in time.
last_report_dateWhen the event was most recently reported — the publish time of its latest report, as an ISO 8601 UTC timestamp. Never null. A freshness signal: later than first_report_date when coverage continued. Not a sort key.
matched_report_countHow many reports matched this event, counting reports received up to the time of the search.
distinct_outlet_countHow many distinct outlets reported it — this is the number min_reports is compared against. Same cut-off as matched_report_count.
involved_entitiesEvery entity involved in the event — its role participants (such as the acquirer and the target) and the entities its reports mention — each { id, name, type }, listed by canonical id with no duplicates and participants first. type may be null. Empty array when there are none. See Involved entities.

Every field above is always present. Nullable fields are sent as an explicit null rather than omitted, so the shape is uniform across pages.

Involved entities​

involved_entities is the same set of entities the entity filters — involved_entity_ids, involved_entity_list_ids, and the entity_in role filter — match an event on. It covers all of the event's reports, not only the ones your filters matched. So you can check a result against your filter locally: an event returned for an entity lists that entity.

  • Ids are surviving canonical ids. When two entities have been merged into one, only the surviving entity is listed, once. If you filtered on the id of an entity that was later merged away, the event lists the surviving entity's id instead, so compare against the survivor.
  • A deleted entity is not listed, even if a filter on its id matched the event.
  • The list is current. Like the dates, it describes the event as Evinor knows it when the page is served, so it can gain entities between pages (for example when two duplicate events are merged into one).

Ordering events in time​

Results are ordered by first_report_date, newest first, with ties broken by id. Page order is a timeline of when events were first reported, and it continues across pages: page two picks up where page one left off.

  • The window uses the same date. lookback_days selects events first reported within that many days before the search ran. An event first reported earlier is not returned, even when an article inside the window covers it again, so a recap or explainer doesn't pull an old event into a short lookback.
  • How a report is dated. A report's date is its source's publish time. When the source carries no usable publish time, or claims one more than a day after Evinor received the report, the time Evinor received the report is used instead. first_report_date is the earliest such date among the event's reports and last_report_date the latest.
  • Both dates describe the event, not your search. They cover all of the event's reports, including ones your filters didn't match, so they don't change with filters. Use last_report_date to tell a one-off story from one that is still developing.
  • Dates are current; counts are as of the search. Continuation pages re-run the search, and the dates on them describe the event as Evinor knows it at that moment. last_report_date advances when new coverage arrives. first_report_date can move earlier (two duplicate events merged into one, or an earlier-published report arriving late) or later (the earliest report removed), which can occasionally move an event across the window's edge or to a different position between pages. matched_report_count and distinct_outlet_count only count reports received up to the time of the original search, so reports that arrive later never change them.

Prefer event_date when you need the date of the event itself rather than of its coverage. It is null more rarely than happened_at, but it can still be null, and it can be an estimate, which is why first_report_date is the ordering key.

The event date​

event_date answers "when did this event happen, and how sure is Evinor?":

FieldMeaning
valueAn ISO 8601 UTC timestamp. For MONTH or null precision it is the first instant of the month, so only its year and month are meaningful.
sourceSTATED when a report explicitly stated the date. ESTIMATED when it was inferred from a report's text (for example "last December") because no report stated one.
precisionDAY or MONTH. A stated date is always DAY. null means the estimate's precision is unspecified: treat it like MONTH and never show or compare it as a day.

It is derived from the event's featured report, then its first report, and any stated date wins over any estimate. An estimate is not a stated fact: an agent that acts on dates should filter on source.

How it relates to the other dates:

  • happened_at is stated-only. It is the featured report's stated date, verbatim, and is unchanged by event_date. When event_date.source is STATED and the featured report stated a date, event_date.value is that same date normalized to UTC. event_date can be set while happened_at is null: when only the first report stated a date, or when the date is an estimate.
  • first_report_date is about coverage, not the event. It is when the event was first reported, and it is never null. An event can be reported long after it happened, so event_date is usually earlier than first_report_date, and it is not what lookback_days windows on.
  • It is current. Like the other dates, event_date describes the event as Evinor knows it when the page is served, so a later report can change it.

What the response deliberately excludes​

This is an allowlist, not a subset that will grow on request. Article bodies and source URLs, report links, media, event-type internals (extraction and classification guidance), per-role parsed values, and confidence scores are excluded by design and won't be added. Build against the fields documented above.

Paging​

Send the previous response's next_cursor back as cursor, with no filter fields, until has_more is false:

curl -X POST https://api.evinor.ai/v1/events/search \
-H "Authorization: Bearer evnr_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{ "cursor": "OWQ0MWI3ZTItMGM1OC00YjFhOjIw", "limit": 20 }'

In pseudocode:

page = POST /v1/events/search { filter…, lookback_days: 90 }   # charged
process(page.data)
while page.has_more:
page = POST /v1/events/search { cursor: page.next_cursor } # not re-charged
process(page.data)

Cursors are opaque. next_cursor is a token to echo back, not a structure to parse or build — it encodes the execution id internally, which is why a continuation call needs nothing else. (The execution id is also returned in plain text as execution_id if you want to log it.)

Unlike Evinor's list endpoints, the cursor travels in the request body as cursor, not as a starting_after query parameter.

total_count can exceed what you can retrieve​

total_count is the exact number of events matching your filter across the corpus. The number of events you can actually page through may be smaller, for two reasons:

  1. A reachability ceiling. Each execution exposes a bounded number of results; matches beyond that ceiling can't be reached by any cursor.
  2. Dropped edges. An event whose underlying record can no longer be resolved — deleted or merged away since the corpus was indexed — is omitted from data. total_count is computed before that resolution and doesn't know about it.

Both are expected behavior, not a bug. Use has_more to decide whether to keep paging, and treat total_count as a corpus-wide estimate of reach rather than a promise of retrievable rows.

Errors and retries​

StatusProblem typeWhat it means here
400validation-failedTwo kinds. Malformed request: an unknown field, a limit above 50, a missing lookback_days, a malformed cursor, or filter fields sent alongside a cursor — these are rejected before anything is charged or claimed. Filter your account can't run: an id in involved_entity_list_ids that isn't one of your lists, or a watchlist selection too large to expand — fix the referenced field(s) and resend. Both kinds mean the request as written will never succeed — don't retry it unmodified.
402insufficient-creditsThe balance can't cover the execution. Nothing was charged.
403missing-scopeThe key lacks EVENTS_READ.
404not-foundThe cursor's execution doesn't exist or isn't yours. The two are deliberately indistinguishable.
409idempotency-in-flightAnother request with the same Idempotency-Key is running — or a previous one crashed. See below.
410search-execution-expiredThe cursor's execution passed its retention window. Re-run the search (charged).
429rate-limitedPer-key budget exhausted. Continuation pages count against it too.
502upstream-errorThe search could not be completed, and whether it was charged before failing can't be determined from here. Retry once; if it persists, contact support with the request_id rather than looping on fresh keys.

A 502 on an execute call is deliberately ambiguous about billing: the request may have been charged before it failed. The endpoint therefore keeps your Idempotency-Key claimed, so a same-key retry returns 409 rather than executing again. Treat a repeating 502 as a support question, not as something to loop on with fresh keys — each fresh key is a new, separately-billable execution.

A 400 is the opposite case: the endpoint can prove nothing was charged (the request is rejected before the search runs, or an upstream check that ran proves the refusal happened before the charge point), so once you've fixed your request you can retry it with the same Idempotency-Key — see Idempotency on a billed endpoint below.

Idempotency on a billed endpoint​

An execute call is a POST with a real side effect — a charge — so send an Idempotency-Key and retry with it. Its guarantee here is at-most-once, and it is stricter than on Evinor's other POSTs:

  • A completed request replays its stored response (same execution_id, Idempotency-Replayed: true) and is not charged again.
  • A request that crashed mid-flight is not reclaimable. Retrying with the same key returns 409 idempotency-in-flight indefinitely instead of executing, because "crashed before it charged" and "charged, then crashed" are indistinguishable from the outside. Retry with a fresh Idempotency-Key. A retry loop that keeps re-sending the same key on a 409 will never make progress.
  • A 402 or 400 is the exception: both are provable pre-charge refusals, so the key is released — a request rejected for malformed shape never claims a key at all, and one rejected for a filter your account can't run (an inaccessible list, a watchlist selection too large to expand) releases the key it claimed. Either way, the same key works again once you've fixed the request (and, for 402, topped up).
  • Continuation calls are not billed and behave like every other endpoint — a crashed page can be retried with the same key.

See Idempotency for the general rules and how they differ.