Skip to main content

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 retryable verdict.
  • 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 await loops.
  • Local report validation against the event type, before any request.
  • Webhook signature verification in one call.
Reports cannot be retracted yet

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 fetch and 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,
});
OptionDefaultMeaning
apiKeyEVINOR_API_KEY environment varYour API key. It must start with evnr_live_, or the constructor throws.
baseUrlhttps://api.evinor.aiThe API origin.
maxRetries2Automatic retries for a retryable failure. 0 turns retries off.
maxRetryDelayMs60000Upper bound on any single wait between retries, including a server Retry-After.
timeoutMs60000Per-attempt timeout, covering the response body.
fetchthe global fetchA 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​

NamespaceMethods
sensorslist, get, create, update, delete, enable, disable, rotateSigningSecret
eventssearch (billed), continue, searchAll
eventTypeslist
reportingGrantslist
entitiessearch
reportssubmit, 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: the Idempotency-Key the request was sent with, if any.

The subclasses:

ClassThrown when
EvinorApiErrorThe API answered with an RFC 9457 problem body.
ReportAlreadySubmittedErrorA subclass of EvinorApiError: this idempotency key already filed a report. See Filing a report.
EvinorConnectionErrorNo problem body arrived: a network failure, a timeout, or an error response without a problem body. Always retryable.
ReportValidationErrorThe SDK refused a report locally, before sending it. See Local validation.

EvinorApiError exposes the problem body as typed fields:

FieldMeaning
problemKeyThe last segment of the problem type URI, for example insufficient-credits. Branch on this.
typeThe full problem type URI.
statusThe HTTP status.
titleThe problem title.
detailThe human-readable explanation, when present.
errorsPer-field errors on validation-failed, as { field, message }.
codeThe stable machine code, when present (for example ROLE_VALUE_TYPE_MISMATCH).
missingScopesThe scopes the key lacks, on missing-scope.
extensionsEvery 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:

  1. The operation is eligible (the table below).
  2. The error is retryable.
  3. 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.

OperationRetried automatically
Reads: sensors.list / get, eventTypes, reportingGrants, entities.search, reports.get / listYes
events.continue (and the continuation pages of searchAll)Yes. Continuation pages are not billed.
events.searchNever. It is billed; re-sending it is always your decision.
reports.submitYes, always under the same Idempotency-Key.
sensors.create, enable, disable, rotateSigningSecretOnly 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-error is retried at most once per call, whatever maxRetries is.
  • idempotency-in-flight is waited out and retried only on reports.submit, under the same key. Everywhere else it is thrown to you.
  • search-execution-expired is always thrown. The SDK never re-runs a search, because that would bill again.
  • report-already-submitted is 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.

After any failed submit, resubmit with err.idempotencyKey, never a new key

The 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:

MethodBilled
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_typeValue
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:

codeMeaning
NO_ROLESNo role was added.
DESCRIPTION_TOO_LONGThe description is over 5,000 characters after trimming.
UNKNOWN_ROLE_CODE_NAMEThe role is not defined on the event type.
ROLE_NOT_MULTI_VALUEDThe same role was added twice.
UNSUPPORTED_ROLE_TYPEThe role is CATEGORICAL, which reports don't support yet.
ROLE_VALUE_TYPE_MISMATCHThe value is the wrong kind for the role's expected_type.
INVALID_DATEA date is not a real YYYY-MM-DD calendar day.
EMPTY_TEXT_VALUEA text value is blank.
TEMPLATE_NOT_FOUNDreports.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. verifyWebhook parses it itself. It also accepts an already-parsed object, but passing the string keeps any framework middleware from changing what gets verified.
  • secret is the sensor's signing_secret, exactly as the API returned it.
  • toleranceSeconds rejects deliveries whose signed timestamp is 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.