TypeScript SDK
@evinor/sdk is the official TypeScript client for the Evinor API. It wraps
every REST endpoint in a typed method and handles the parts that are easy to
get wrong by hand:
- Typed errors for every problem type, with the server's
retryableverdict. - Retries that are bounded, honor
Retry-After, and never re-run a billed search. - Exactly-once report filing, with the idempotency key always available to you.
- Pagination as
for awaitloops. - Local report validation against the event type, before any request.
- Webhook signature verification in one call.
reports.submit() publishes a report under your account's name as soon as it
is accepted. There is no draft state and no retraction endpoint yet, and the
SDK cannot undo a filing. Only file facts you have confirmed yourself. See
Reporting events.
Install
npm install @evinor/sdk
- Runtimes: Node.js 20 or later, and edge runtimes with
fetchand Web Crypto (for example Cloudflare Workers, Vercel Edge, Deno). - Server-side only. The client authenticates with your secret API key. Never bundle it into a browser app or a mobile client.
- ES modules. The package is ESM-only and has one runtime dependency.
Configuration
import { Evinor } from '@evinor/sdk';
// Reads the key from the EVINOR_API_KEY environment variable.
const evinor = new Evinor();
// Or configure it explicitly.
const configured = new Evinor({
apiKey: mySecrets.evinorApiKey, // any string source, e.g. a secrets manager
maxRetries: 2,
maxRetryDelayMs: 60_000,
timeoutMs: 60_000,
});
| Option | Default | Meaning |
|---|---|---|
apiKey | EVINOR_API_KEY environment var | Your API key. It must start with evnr_live_, or the constructor throws. |
baseUrl | https://api.evinor.ai | The API origin. |
maxRetries | 2 | Automatic retries for a retryable failure. 0 turns retries off. |
maxRetryDelayMs | 60000 | Upper bound on any single wait between retries, including a server Retry-After. |
timeoutMs | 60000 | Per-attempt timeout, covering the response body. |
fetch | the global fetch | A fetch implementation to use instead: a proxy-aware fetch, instrumentation, or a mock. |
The constructor never echoes the key in an error message, and no error object
the SDK throws holds the key or the Authorization header.
Every method takes an optional signal (an AbortSignal) to cancel the call.
Responses
Methods that make one request resolve to an ApiResponse:
const { data, requestId, status, headers } = await evinor.sensors.get(sensorId);
data is the parsed JSON body, typed from the API's OpenAPI spec. Responses
are not validated at runtime, and fields the SDK doesn't know yet pass through
untouched, so a newer API never breaks an older SDK (see
Versioning). requestId is the x-request-id to quote to
support.
Methods
| Namespace | Methods |
|---|---|
sensors | list, get, create, update, delete, enable, disable, rotateSigningSecret |
events | search (billed), continue, searchAll |
eventTypes | list |
reportingGrants | list |
entities | search |
reports | submit, builder, get, list |
The API Reference documents every request and response field.
Errors
Every error the SDK throws extends EvinorError, which carries:
retryable: whether sending the same request again can succeed.requestId: the server's correlation id, when a response arrived.idempotencyKey: theIdempotency-Keythe request was sent with, if any.
The subclasses:
| Class | Thrown when |
|---|---|
EvinorApiError | The API answered with an RFC 9457 problem body. |
ReportAlreadySubmittedError | A subclass of EvinorApiError: this idempotency key already filed a report. See Filing a report. |
EvinorConnectionError | No problem body arrived: a network failure, a timeout, or an error response without a problem body. Always retryable. |
ReportValidationError | The SDK refused a report locally, before sending it. See Local validation. |
EvinorApiError exposes the problem body as typed fields:
| Field | Meaning |
|---|---|
problemKey | The last segment of the problem type URI, for example insufficient-credits. Branch on this. |
type | The full problem type URI. |
status | The HTTP status. |
title | The problem title. |
detail | The human-readable explanation, when present. |
errors | Per-field errors on validation-failed, as { field, message }. |
code | The stable machine code, when present (for example ROLE_VALUE_TYPE_MISMATCH). |
missingScopes | The scopes the key lacks, on missing-scope. |
extensions | Every other member of the body, as received. |
import { EvinorApiError, EvinorConnectionError } from '@evinor/sdk';
try {
await evinor.sensors.get(sensorId);
} catch (err) {
if (err instanceof EvinorApiError) {
switch (err.problemKey) {
case 'not-found':
return null;
case 'missing-scope':
throw new Error(`Key is missing: ${err.missingScopes?.join(', ')}`);
default:
logger.error({ requestId: err.requestId, problem: err.problemKey });
throw err;
}
}
if (err instanceof EvinorConnectionError) {
// err.kind is 'network', 'timeout', or 'http'.
}
throw err;
}
Where retryable comes from
Every problem body the API sends carries a boolean retryable (see
Errors), and the SDK uses the body's value when it is
present. When a body lacks it, for example a response from an older server
during a deployment, the SDK falls back to the Retryable column of the
problem-type catalog, which it ships with. An
unknown problem type falls back to not retryable.
retryable is a verdict, not a retry plan. Which failures the SDK actually
retries is decided per operation, below.
Retries
The SDK retries a failure only when all of these hold:
- The operation is eligible (the table below).
- The error is
retryable. - The retry budget (
maxRetries) isn't spent.
Waits use full-jitter exponential backoff starting at 500 ms. A Retry-After
header wins over the backoff, then RateLimit-Reset when the rate-limit window
is actually spent. Every wait is capped at maxRetryDelayMs. Every retry sends
the identical request, including the same Idempotency-Key.
| Operation | Retried automatically |
|---|---|
Reads: sensors.list / get, eventTypes, reportingGrants, entities.search, reports.get / list | Yes |
events.continue (and the continuation pages of searchAll) | Yes. Continuation pages are not billed. |
events.search | Never. It is billed; re-sending it is always your decision. |
reports.submit | Yes, always under the same Idempotency-Key. |
sensors.create, enable, disable, rotateSigningSecret | Only when you pass an idempotencyKey. |
sensors.update (PATCH), sensors.delete (DELETE) | Never. These endpoints take no Idempotency-Key. |
Some problem types have extra rules on top of the table:
upstream-erroris retried at most once per call, whatevermaxRetriesis.idempotency-in-flightis waited out and retried only onreports.submit, under the same key. Everywhere else it is thrown to you.search-execution-expiredis always thrown. The SDK never re-runs a search, because that would bill again.report-already-submittedis never retried: the report was filed.
To pass a key on the sensor operations:
await evinor.sensors.enable(sensorId, { idempotencyKey: crypto.randomUUID() });
Idempotency
The SDK sends your idempotencyKey as the Idempotency-Key
header. reports.submit always sends one: it generates a UUID when you don't
pass one, and returns the key it used.
Every error thrown by reports.submit carries that key as
err.idempotencyKey, including a timeout, a network failure, or a cancelled
request, when you can't tell whether the report was filed.
err.idempotencyKey, never a new keyThe report's id is derived from the idempotency key, so the same key can never
file the same report twice. A new key is a new report: resubmitting with
one after a failure you couldn't see the end of can publish the report twice.
If your process may die before it can retry, persist the key before you
submit, by passing your own idempotencyKey.
Pagination
List methods (sensors.list, entities.search, reports.list) return a page
walker. Iterate it with for await to walk every page:
for await (const sensor of evinor.sensors.list({ limit: 100 })) {
console.log(sensor.id);
}
Or fetch just the first page, with its has_more and next_cursor:
const { data: page } = await evinor.reports.list({ limit: 20 }).firstPage();
// page.data, page.has_more, page.next_cursor
Both accept limit and startingAfter, the SDK's names for the
limit and starting_after query parameters.
Searching events
Event search is the one billed endpoint, so the SDK keeps the billing boundary visible:
| Method | Billed |
|---|---|
events.search(filter) | Yes. Runs a new search. |
events.continue(cursor) | No. The next page of a search you ran. |
events.searchAll(filter) | Once. Runs the search, then walks its pages for free. |
// One billed execution, then free continuation pages.
for await (const event of evinor.events.searchAll({
event_type_id: '0f3c7a41-5b21-4f8e-9c0a-2d6e18b4c9a3',
lookback_days: 90,
min_reports: 2,
limit: 50,
})) {
console.log(event.first_report_date, event.sentence);
}
The filter uses the API's own field names. events.search takes no cursor:
passing one is a type error, and a runtime error in plain JavaScript. To page
yourself, call events.continue(page.next_cursor).
If a continuation page comes back search-execution-expired, searchAll
throws rather than re-running the search. Deciding whether to pay for a new
execution is up to you.
Because events.search is never retried automatically, pass an
idempotencyKey and handle failures yourself, following the
rules for a billed endpoint:
const { data: page } = await evinor.events.search(
{ lookback_days: 30 },
{ idempotencyKey: crypto.randomUUID() }
);
Reporting events
To file a structured report, build it against an event type
with reports.builder(). Given an event type id, it fetches the event type for
you. Given an event type object from eventTypes.list(), it makes no request.
const builder = await evinor.reports.builder('0b6f0d8e-3a7c-4d2e-9f1a-5c8e2b7d4a10');
const result = await builder
.role('RECIPIENT', { entityId: '7c1e9a52-0d4b-4f6e-8a3c-2b9d5e1f7a64' })
.role('AMOUNT', { amount: { value: 25_000_000, unit: 'USD' } })
.role('ROUND', { text: 'Series B' })
.happenedAt('2026-09-14T00:00:00Z')
.description('Led by Example Ventures.')
.submit();
console.log(result.data.id, result.idempotencyKey);
Each role takes exactly one value, chosen by the role's expected_type:
expected_type | Value |
|---|---|
NAMED_ENTITY | { entityId } |
DATE | { date } as YYYY-MM-DD |
CURRENCY, NUMBER, and the other measurable types | { amount: { value, unit } } |
anything else, including null | { text } |
Find an entityId with evinor.entities.search({ query: 'acme' }). To file
under a reporting grant, add
.grant(grantId). happenedAt() also accepts a Date. Call .build()
instead of .submit() to get the validated request body without sending it.
The SDK has no event types built in. Event types are data that can change, so the builder always works from the event type the API returns.
Local validation
The builder checks the report before sending anything, in the same order as the
server, and throws a ReportValidationError on the first problem. Its code
uses the server's validation codes, and role
names the role at fault:
code | Meaning |
|---|---|
NO_ROLES | No role was added. |
DESCRIPTION_TOO_LONG | The description is over 5,000 characters after trimming. |
UNKNOWN_ROLE_CODE_NAME | The role is not defined on the event type. |
ROLE_NOT_MULTI_VALUED | The same role was added twice. |
UNSUPPORTED_ROLE_TYPE | The role is CATEGORICAL, which reports don't support yet. |
ROLE_VALUE_TYPE_MISMATCH | The value is the wrong kind for the role's expected_type. |
INVALID_DATE | A date is not a real YYYY-MM-DD calendar day. |
EMPTY_TEXT_VALUE | A text value is blank. |
TEMPLATE_NOT_FOUND | reports.builder(id) found no active event type with that id. |
These codes are SDK-side. They match what the server reports for the same
check, with one difference: the server parses the request shape first, so an
empty roles array or an empty text sent straight to the API comes back as a
generic validation-failed with an errors array and no code. Passing local
validation doesn't guarantee acceptance: the server still checks things the SDK
can't, such as whether an entity exists or whether the roles are enough to
describe the event. Those arrive as an EvinorApiError with a code.
A validation failure throws before any request, so a ReportValidationError
carries no idempotency key: nothing was sent.
Filing a report
reports.submit() (which the builder's .submit() calls) files a report
exactly once:
- It sends an
Idempotency-Key, generating one if you don't pass{ idempotencyKey }. - It retries retryable failures under that same key, including waiting out
idempotency-in-flight. - The key is on the result (
result.idempotencyKey) and on every error it throws (err.idempotencyKey).
import { EvinorError, ReportAlreadySubmittedError } from '@evinor/sdk';
const idempotencyKey = crypto.randomUUID();
await saveToOutbox(idempotencyKey, body); // persist before sending
try {
await evinor.reports.submit(body, { idempotencyKey });
} catch (err) {
if (err instanceof ReportAlreadySubmittedError) {
// Filed on an earlier attempt. Don't resubmit; find it with reports.list().
} else if (err instanceof EvinorError && err.retryable) {
// Resubmit later with err.idempotencyKey. Never mint a new key.
} else {
throw err;
}
}
After a crash mid-request, the server holds the key for about a minute before a
resubmit can run, so the SDK's automatic retries can run out and throw
idempotency-in-flight. Resubmit later with the same key.
Webhooks
verifyWebhook() checks a delivery's X-Evinor-Signature against your sensor's
signing secret, following the signature recipe.
It resolves to true or false and never throws.
import { verifyWebhook } from '@evinor/sdk';
import express from 'express';
const app = express();
app.post(
'/webhooks/evinor',
express.text({ type: 'application/json' }), // keep the raw body string
async (req, res) => {
const valid = await verifyWebhook({
payload: req.body,
signature: req.get('X-Evinor-Signature') ?? '',
secret: process.env.EVINOR_SIGNING_SECRET ?? '',
toleranceSeconds: 300,
});
if (!valid) return res.status(403).send('invalid signature');
const envelope = JSON.parse(req.body);
// Dedupe on envelope.idempotencyKey, then queue the work.
res.status(200).send('ok');
}
);
- Pass the raw body string.
verifyWebhookparses it itself. It also accepts an already-parsed object, but passing the string keeps any framework middleware from changing what gets verified. secretis the sensor'ssigning_secret, exactly as the API returned it.toleranceSecondsrejects deliveries whose signedtimestampis further than that from now. It is off unless you set it; five minutes is a sensible value. See Replay protection.
Versioning
The SDK is versioned 0.x while the API is in preview:
- A minor release (
0.1→0.2) may contain breaking changes, for example to follow a preview correction to the API. Each is listed in the package changelog. - A patch release (
0.1.0→0.1.1) is backwards-compatible.
Pin a minor version (~0.1.0) and read the changelog before moving to the next
one. The SDK tolerates response fields it doesn't know, so an API that adds
fields never breaks an installed version.