Beats
A beat is a saved set of clauses that Evinor keeps matching for you. Every event that matches one of its clauses is recorded on the beat as an entry, and you read the entries back whenever you like: by time window, or by polling for what is new since your last read. There is no webhook to stand up and no endpoint to host. A beat is built for "my agent checks what's new every hour and briefs me".
Where a sensor pushes matching events to your webhook as they happen, a beat collects them and waits for you to ask.
- Scopes required:
BEATS_READfor reads,BEATS_WRITEfor changes (see Authentication). A key withBEATS_WRITEshould also holdBEATS_READ, so it can read back the beats it writes. - Base path:
https://api.evinor.ai/v1/beats. - Ownership: a key sees only the beats of the account that owns it. A beat
that belongs to another account answers exactly like one that does not
exist:
404not-found.
What a beat costs
Entries cost credits when they are recorded, not when they are read. Every new entry is charged once against the account's credit balance, so a beat spends on its own, every month, for as long as it is enabled.
That spend is bounded by the beat's monthly spend cap (spend_cap_credits,
in credits). When an entry would take the month's spend past the cap, the beat
pauses: its status becomes PAUSED_SPEND_CAP and it records nothing
until the next month starts or you resume it. Each pause is reported as a
gap — a from / until window (until is null while still paused) —
so a reader can tell that a window is incomplete. A cap pause lifts by itself
at the start of the next UTC month.
To keep a paused beat going this month, raise the cap first, then resume it. Raising the cap alone does not resume the beat, and resuming without raising the cap pauses it again on its next entry.
Reading entries is free. Neither an entries read nor a continuation page is charged, and listing or fetching beats is free too.
The beat object
{
"id": "5b0f2c7e-1d4a-4e3b-9a61-8c2f0d7e4b19",
"object": "beat",
"name": "Acquisitions in our sector",
"description": null,
"status": "ACTIVE",
"enabled": true,
"spend_cap_credits": 500,
"spent_this_month": 42,
"entries_this_month": 14,
"last_entry_at": "2026-10-08T14:02:11.000Z",
"retention_days": 90,
"backfill_days": 30,
"clauses": [
{
"id": "e3a1c5d0-7b2f-4c8e-9d14-6f0a2b9c7e31",
"event_type_id": "0f3c7a41-5b21-4f8e-9c0a-2d6e18b4c9a3",
"involved_entity_ids": [],
"involved_entity_list_ids": ["a8d2e4f1-3c6b-4a90-8e57-1b9d0c2f6a48"],
"required_source_news_outlet_ids": [],
"min_reports": 2,
"scope": "PARTICIPANT",
"role_filters": []
}
],
"gaps": [],
"created_at": "2026-09-30T09:12:44.000Z",
"updated_at": "2026-10-01T16:40:03.000Z"
}
| Field | Meaning |
|---|---|
status | ACTIVE, PAUSED_SPEND_CAP (paused by the monthly cap), or DISABLED. |
enabled | Whether the beat is switched on. A disabled beat records nothing and spends nothing. |
spend_cap_credits | The monthly spend cap, in credits. |
spent_this_month | Credits spent on entries this UTC month. For display: the cap itself is enforced server-side. |
entries_this_month | New entries recorded this UTC month. |
last_entry_at | When the most recent entry was recorded, or null before the first one. |
retention_days | How far back entries can be read, from the owner's plan. Older entries are not returned. |
backfill_days | How far back a new beat looks for existing matches when it is created, from the owner's plan. |
clauses | The clauses, each with its server-assigned id. See Clauses. |
gaps | Spend-cap pause windows: from, and until (null while still paused). No entries exist inside a gap. |
Clauses
A beat holds 1–20 clauses, OR-ed together: an event that matches any clause becomes an entry. Each clause uses the same filter vocabulary as event search and sensors:
| Field | Type | Notes |
|---|---|---|
id | uuid | Server-assigned. Omit it on create. On replace, echo it to keep the clause. See Replacing a beat. |
event_type_id | uuid | null | Match one event type. A clause without one is an entity clause: it must name at least one entity or entity list. |
involved_entity_ids | uuid[] | Canonical entity ids that must be involved. Up to 500. |
involved_entity_list_ids | uuid[] | Entity lists whose members count as involved. Up to 500. A list your account cannot use is refused with entity-list-inaccessible. |
required_source_news_outlet_ids | uuid[] | Only count reports from these outlets. Up to 500. |
report_source_kind | string | ANY, ARTICLES_ONLY, USER_REPORTS_ONLY or REPRESENTED_ONLY, exactly as on sensors. Omitted means ANY. |
represented_entity_ids | uuid[] | With REPRESENTED_ONLY: only user reports submitted on behalf of these entities. Up to 500. |
represented_entity_list_ids | uuid[] | With REPRESENTED_ONLY: only user reports submitted on behalf of these lists' members. Up to 500. |
min_reports | integer | Minimum distinct outlets reporting the event, 1–10. Defaults to 1. |
scope | string | MENTIONED (the entity appears anywhere in the event) or PARTICIPANT (the entity plays a role in it). Omitted reads back as MENTIONED. |
role_filters | object[] | Predicates on the event's structured roles, exactly as in event search. |
A clause has the same shape on the way in and on the way out, so you can GET
a beat and send its clauses straight back in a replace. Any unrecognized field
returns 400 validation-failed.
How many beats an account may hold, and how many clauses each may have, depend
on the plan. Going over either is refused with 403
quota-exceeded.
Endpoints
| Method and path | Scope | What it does |
|---|---|---|
GET /v1/beats | BEATS_READ | Every beat the account owns, in one page. |
GET /v1/beats/{id} | BEATS_READ | One beat, with its clause ids and gaps. |
POST /v1/beats | BEATS_WRITE | Create a beat. Returns 201 and the beat. |
PUT /v1/beats/{id} | BEATS_WRITE | Replace a beat's name, description, and complete clause list. |
DELETE /v1/beats/{id} | BEATS_WRITE | Delete a beat. It stops recording immediately. Returns 204. |
POST /v1/beats/{id}/enable | BEATS_WRITE | Switch a disabled beat back on. |
POST /v1/beats/{id}/disable | BEATS_WRITE | Switch a beat off: it records nothing, and spends nothing. |
POST /v1/beats/{id}/resume | BEATS_WRITE | Resume a beat paused by its spend cap. |
PATCH /v1/beats/{id}/spend-cap | BEATS_WRITE | Set the monthly spend cap: body { "spend_cap_credits": 1000 }. |
POST /v1/beats/{id}/entries | BEATS_READ | Read entries, by window or by since. See Reading entries. |
GET /v1/beats takes no paging parameters. It returns { "data": [...], "has_more": false } with every beat the account owns; has_more would be
true only past 100 beats, which no plan allows.
Requests that conflict with the beat's current state — enabling a beat that is
already enabled, disabling one that is already disabled, resuming one that is
not paused (or is disabled), or a replace that changes nothing — are refused
with 409 state-conflict. Read the beat again and
decide from its current state.
The API reference documents every request and response field.
Creating a beat
- curl
- TypeScript
curl -X POST https://api.evinor.ai/v1/beats \
-H "Authorization: Bearer evnr_live_your_key_here" \
-H "Idempotency-Key: 7d2b9e14-0a3c-4f6e-8b51-2c9d4e7f0a16" \
-H "Content-Type: application/json" \
-d '{
"name": "Acquisitions in our sector",
"spend_cap_credits": 500,
"clauses": [
{
"event_type_id": "0f3c7a41-5b21-4f8e-9c0a-2d6e18b4c9a3",
"involved_entity_list_ids": ["a8d2e4f1-3c6b-4a90-8e57-1b9d0c2f6a48"],
"min_reports": 2
}
]
}'
import { Evinor } from '@evinor/sdk';
const evinor = new Evinor(); // reads EVINOR_API_KEY
const { data: beat } = await evinor.beats.create(
{
name: 'Acquisitions in our sector',
spend_cap_credits: 500,
clauses: [
{
event_type_id: '0f3c7a41-5b21-4f8e-9c0a-2d6e18b4c9a3',
involved_entity_list_ids: ['a8d2e4f1-3c6b-4a90-8e57-1b9d0c2f6a48'],
min_reports: 2,
},
],
},
{ idempotencyKey: '7d2b9e14-0a3c-4f6e-8b51-2c9d4e7f0a16' }
);
name is 1–120 characters and description is optional. Omit
spend_cap_credits to take the plan's default cap. Clause ids are assigned by
the server, so a clause sent with an id on create is refused.
A new beat starts by looking back over the last backfill_days for events that
already match, and records them as entries marked backfilled: true.
Replacing a beat
PUT /v1/beats/{id} is a whole-shape replace: send the name, the
description, and the complete clause list you want the beat to have.
Anything you leave out is gone — an omitted description clears it. The same holds inside each clause: an omitted clause field resets to its default (for example report_source_kind back to ANY), so send back every field a GET returned. The spend
cap and the enabled state are not part of the replace; they have their own
endpoints.
Clauses are matched by id:
- Echo a clause's
idto keep that clause. Its entries keep pointing at it. - Omit
idto create a new clause. - A clause whose
idyou drop is removed. If you send the same filter back without itsid, it is re-created as a new clause with a new id. Entries recorded under the old clause keep the oldmatched_clause_id.
An id that is not one of this beat's current clause ids is refused with 400
validation-failed.
The safe pattern is read, modify, write: GET the beat, change its clauses
array in place, and PUT it back. A replace that changes nothing returns 409
state-conflict.
Idempotency
The POST endpoints — create, enable, disable, resume, and the entries read —
accept an optional Idempotency-Key header and behave as described on
Idempotency. Send one on create: without it, a retried create
after a timeout can make a second beat. PUT, PATCH, and DELETE ignore the
header.
Reading entries
POST /v1/beats/{id}/entries reads a beat's entries. Like
event search, one endpoint serves two
calls, told apart by whether the body carries a cursor:
| Call | Body | What happens |
|---|---|---|
| Start | A window or since, no cursor | A new read starts and returns its first page. |
| Continue | cursor (and optionally limit) only | The next page of the read that cursor came from. |
Neither is charged. A continuation page re-uses the window or since recorded
in its cursor, so sending any of from, until, last, axis, or since
alongside a cursor returns 400
validation-failed.
Window or since — exactly one
A read that starts without a cursor must pass exactly one mode. Both, or
neither, is a 400.
| Mode | Fields | Order |
|---|---|---|
| Window | from + until (ISO 8601 with offset), or last | Newest first. |
| Poll | since | Oldest first, by when entries were added. |
| Field | Type | Notes |
|---|---|---|
from | string | Window start, inclusive. Requires until, and must be before it. |
until | string | Window end, exclusive. Requires from. |
last | string | A window ending now: 24h, 7d, or 30d. Cannot be combined with from / until. |
axis | string | Window mode only. occurred_at (when the event happened, the default) or added_at (when the entry was recorded). |
since | string | Poll mode. The poll_since of a previous response, or "" to start at the oldest retained entry. |
limit | integer | Page size, 1–50. Defaults to 20. A larger value is rejected, not clamped. Also accepted alongside cursor. |
cursor | string | A next_cursor from a previous response. Selects the continuation call. |
A window that starts before the beat's retention period is clamped to it, not
refused: entries older than retention_days are simply not returned.
Paging with next_cursor
When has_more is true, send the response's next_cursor back as cursor
for the next page of the same read. The cursor is opaque and carries the
window (or since) for you; a last window keeps the absolute bounds it had
when the read started. Keep going until next_cursor is null. A cursor is
signed and only valid for the beat it came from: an altered cursor, or one sent
to another beat, returns 400
validation-failed.
Cursors are short-lived pagination handles: Evinor may invalidate outstanding
cursors (for example on a key rotation), which also returns 400. Persist
poll_since for incremental polling, not next_cursor.
Polling with poll_since
next_cursor pages one read. poll_since is what connects one read to the
next. To poll a beat for new entries:
- Start with
since: "". The first read returns entries from the oldest one still retained, oldest first. - Page on with
next_cursoruntil it isnull. - Save the last page's
poll_since. - Next time, start a new read with
sinceset to that saved value. You get only entries added after it, still oldest first.
poll_since is set only in poll mode. After a window read it is null: a
window cannot be turned into a polling position. If a poll finds nothing new,
poll_since comes back unchanged, so a poller never loses its place.
The response
{
"object": "beat_entries",
"beat": {
"id": "5b0f2c7e-1d4a-4e3b-9a61-8c2f0d7e4b19",
"status": "ACTIVE",
"gaps": []
},
"data": [
{
"id": "c71e9a02-4b5d-4f38-a6e1-0d2c8b9f3a57",
"object": "beat_entry",
"matched_clause_id": "e3a1c5d0-7b2f-4c8e-9d14-6f0a2b9c7e31",
"occurred_at": "2026-10-07T00:00:00.000Z",
"added_at": "2026-10-08T14:02:11.000Z",
"backfilled": false,
"carried_from_event_id": null,
"superseded_by_event_id": null,
"event": {
"id": "9d41b7e2-0c58-4b1a-8f37-2ae6c05d9134",
"event_type": { "code_name": "ACQUISITION" },
"sentence": "Northwind Corp agreed to acquire Contoso for $1.2B.",
"happened_at": "2026-10-07"
}
}
],
"has_more": false,
"next_cursor": null,
"poll_since": "eyJrIjoiUyIsImEiOiJhZGRlZEF0In0",
"total_count": null
}
| Field | Meaning |
|---|---|
beat | The beat's current status and gaps. Check the gaps: no entries were recorded while the beat was paused. |
data | The entries on this page. |
has_more | true if another page of this read is reachable. |
next_cursor | Send back as cursor for the next page, or null on the last page. |
poll_since | Pass as since on your next read to get only newer entries. null after a window read. |
total_count | The exact number of entries in the window. null in poll mode. |
Each entry carries:
| Field | Meaning |
|---|---|
matched_clause_id | The clause that matched. It may name a clause that a later replace removed. |
occurred_at | When the event happened. |
added_at | When the entry was recorded on the beat. |
backfilled | true for an entry found by the look-back when the beat was created. |
carried_from_event_id | Set on an entry that was carried over when an earlier entry's event was merged into this one: the id of that earlier event. Carried entries are not charged. |
superseded_by_event_id | Set when this entry's event was later merged into another one: the id of the surviving event. Entries are never deleted. |
event | A compact summary — id, event_type, sentence, happened_at. null when the event cannot be resolved; skip such entries. |
Polling from code
- curl
- TypeScript
# First poll: start at the oldest retained entry.
curl -X POST https://api.evinor.ai/v1/beats/5b0f2c7e-1d4a-4e3b-9a61-8c2f0d7e4b19/entries \
-H "Authorization: Bearer evnr_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{ "since": "", "limit": 50 }'
# Next page of the same read, while next_cursor is not null.
curl -X POST https://api.evinor.ai/v1/beats/5b0f2c7e-1d4a-4e3b-9a61-8c2f0d7e4b19/entries \
-H "Authorization: Bearer evnr_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{ "cursor": "NEXT_CURSOR_FROM_THE_PREVIOUS_RESPONSE", "limit": 50 }'
# Next poll, later: pass the saved poll_since.
curl -X POST https://api.evinor.ai/v1/beats/5b0f2c7e-1d4a-4e3b-9a61-8c2f0d7e4b19/entries \
-H "Authorization: Bearer evnr_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{ "since": "SAVED_POLL_SINCE" }'
let since = await loadCursor(); // '' on the first run
const poll = evinor.beats.poll(beat.id, since);
for await (const entry of poll) {
console.log(entry.id, entry.event?.sentence);
}
since = poll.pollSince;
await saveCursor(since);
beats.poll runs one read with since and follows next_cursor until it is
null, yielding each entry oldest first. The first call is never retried
automatically; the continuation pages are. pollSince advances only after
every entry of a page has been yielded, so persisting it after an interrupted
loop re-delivers entries rather than skipping them. A poll is single-use: start
the next one with its pollSince. See the TypeScript SDK.
A window read looks the same with a window instead of since:
const { data: page } = await evinor.beats.entries(beat.id, { last: '7d' });
if (page.next_cursor) {
const { data: next } = await evinor.beats.entriesContinue(beat.id, page.next_cursor);
}
Over MCP
Beats are exposed to agents through the MCP server as five tools, with the same key, scopes, and problem types as the REST endpoints:
| Tool | Scope | Wraps |
|---|---|---|
list_beats | BEATS_READ | GET /v1/beats |
get_beat | BEATS_READ | GET /v1/beats/{id} |
create_beat | BEATS_WRITE | POST /v1/beats |
get_beat_entries | BEATS_READ | POST /v1/beats/{id}/entries, no cursor |
get_beat_entries_continue | BEATS_READ | POST /v1/beats/{id}/entries, with cursor |
A tool takes one argument object, so the beat's id travels as a beat_id
argument rather than in a path. As with search, the entries read is split into
a start tool and a continue tool: get_beat_entries refuses a cursor, and
get_beat_entries_continue takes beat_id, cursor, and optionally limit.
create_beat cannot set a spend cap. It has no spend_cap_credits
argument, so a beat an agent creates always gets the plan's default cap, and a
call that sends one is refused. Its description warns the model that a beat
spends credits on its own after the call returns, and that repeating a call
creates a second beat.
Everything else is REST-only: replace, delete, enable, disable, resume, and set-spend-cap. Each of them changes or removes a beat that you, or another agent, already rely on — or, for resume and set-spend-cap, would let an agent lift the limit on its own spending. An agent can add a beat and read beats; only you, through the REST API or the Evinor app, can change one.
Errors
Beat endpoints use the shared problem types. The ones specific to beats:
| Status | Problem type | When |
|---|---|---|
| 400 | validation-failed | A malformed body, both or neither read mode, an invalid cursor, an unknown clause id. |
| 403 | not-entitled | The key's owner is not entitled to beats. |
| 403 | quota-exceeded | The plan's beat or clause quota is reached. |
| 404 | not-found | No beat with that id belongs to the account. |
| 409 | state-conflict | The request conflicts with the beat's current state. |
| 422 | entity-list-inaccessible | A clause references an entity list the account cannot use. |