TypeScript SDK

@dcision/sdk for Node.js, Bun, Deno and edge runtimes — build from source, decide, destinations and functions, errors, idempotent retries, executions, me() and webhook verification.

@dcision/sdk is the official JavaScript and TypeScript SDK. It has no dependencies — it only uses fetch and Web Crypto — ships ESM, CommonJS and types, and runs on Node.js 20+, Bun, Deno and edge runtimes.

Install

Not on npm yet

@dcision/sdk isn't published on npm. Build it from a checkout of the Dcision repository; until this page says it is published, a package with this name on npm doesn't come from Dcision.

# 1. In a checkout of the Dcision repository (Node.js 22 and pnpm 10)
pnpm install
pnpm --filter @dcision/sdk build

# 2. In your project
npm install /path/to/dcision/packages/sdk

npm install with a folder links it into your project; pnpm, yarn and bun accept the same path. To copy the package instead — into a Docker image, another machine — make a tarball and install that:

cd /path/to/dcision/packages/sdk && npm pack   # writes dcision-sdk-0.1.0.tgz
npm install ./dcision-sdk-0.1.0.tgz           # in your project

Quickstart

Create an API key in API Keys and set it as DCISION_API_KEY in your server's environment.

import { Dcision } from "@dcision/sdk";

const dcision = new Dcision(); // reads DCISION_API_KEY

const decision = await dcision.decide("lead-qualification", {
  message: "We need pricing for 500 users and want to start next month.",
  company_size: 500,
});

decision.result.route; // "sales"
decision.confidence.route; // 0.85
decision.action; // "continue" | "block" | "escalate" | "fallback"
decision.destinations; // what happens next: replies, queued deliveries, functions…

CommonJS works the same way: const { Dcision } = require("@dcision/sdk").

Configuration

const dcision = new Dcision({
  apiKey: process.env.DCISION_API_KEY, // default: the DCISION_API_KEY environment variable
  timeoutMs: 30_000,
  maxRetries: 2,
  userAgent: "acme-crm/2.1",
});
OptionDefaultDescription
apiKeyDCISION_API_KEYdcs_live_… or dcs_test_…. A missing key, or one that doesn't start with dcs_, throws DcisionError INVALID_API_KEY at once — nothing is sent.
baseUrlhttps://api.dcision.ioMust use https — plain http only for an API on a loopback address. A path prefix is fine; credentials, a query string or a fragment are refused.
timeoutMs30000Timeout of each attempt, in milliseconds.
maxRetries2Retries after a network error, a timeout, 429, 502, 503 or 504.
fetchthe global fetchA custom implementation: (url, init) => Promise<Response>.
userAgent—Your app's identifier, prepended to the SDK's: acme-crm/2.1 dcision-sdk-js/0.1.0.

Create one client and share it: it keeps no per-request state.

Run a decision

const decision = await dcision.decide(slug, state, options);

state is what the decision evaluates — a string, an object or an array, as the decision declares. The slug is checked locally (lowercase letters, digits and single dashes) before anything is sent.

OptionDescription
idempotencyKeyThe Idempotency-Key: 1 to 128 characters from A–Z a–z 0–9 . _ : -. Default: a new UUID per call, reused by all its retries.
includeProbabilitiesAdds probabilities, the full distribution of every question (?include=probabilities).
functionsHandlers for function destinations.
onMissingFunction"throw" (default) or "ignore".
signalAn AbortSignal: aborting stops the request and its retries, and decide rejects with signal.reason.

decide resolves to a Decision: every field of the API response — decision_id, execution_id, schema, version, result, confidence, scores, composites, action, action_reason, probabilities, metrics, destinations — plus functionResults and dispatch(). destinations is always an array: [] when the decision has none or none fired.

A decision with onEngineError: "fallback" answers with its fallback action when the engine fails: then action_reason.type is "engine_error" and result and confidence are empty objects.

Destinations

decision.destinations holds the destination entries of the response. Destination is a union you narrow on type and status:

for (const destination of decision.destinations) {
  if (destination.type === "reply") chat.send(destination.text, destination.buttons);
  else if (destination.type === "llm" && destination.status === "completed") chat.send(destination.text);
  else if (destination.type === "agent" && destination.status === "completed") chat.send(destination.reply ?? "");
  else if (destination.status === "failed") log.warn(`${destination.key}: ${destination.error}`);
  else if (destination.status === "queued") log.info(`${destination.key} queued as ${destination.delivery_id}`);
  else if (destination.status === "skipped") log.info(`${destination.key} skipped: ${destination.reason}`);
}

Function destinations

A function destination names a function and its params. Pass the handlers in functions and the SDK calls them after the decision:

const decision = await dcision.decide("lead-qualification", { message, email }, {
  functions: {
    assignToSales: async (params, decision) => crm.assign(String(params.email), decision.result.route),
    notifySlack: (params) => slack.post(String(params.text)),
  },
});

decision.functionResults;
// [{ key: "assign_owner", function: "assignToSales", result: <what the handler returned> }]
  • Handlers are called as functions[name](params, decision), in the order of destinations, one at a time: an async handler is awaited before the next one starts.
  • Only function entries that fired are called — not skipped ones, and not the other types.
  • Missing handler: every handler is looked up before the first one runs. If one is missing, decide throws DcisionError FUNCTION_NOT_REGISTERED and no handler runs — unless you pass onMissingFunction: "ignore".
  • A handler throws: the next handlers aren't called and decide throws DcisionError FUNCTION_FAILED, with cause (what the handler threw), decision and functionResults (the handlers that completed). The decision already ran — and was billed.
  • Handlers run once per decide call, after the final answer: retries inside the call never dispatch twice. Calling decide again with the same idempotencyKey replays the stored answer and dispatches its functions again, so make handlers idempotent — for example keyed on decision.execution_id.

Dispatch later, or somewhere else, from a stored response:

import { Decision } from "@dcision/sdk";

const decision = await dcision.decide("lead-qualification", state); // no functions: nothing is called
await queue.push(JSON.stringify(decision));

// in a worker
const results = await new Decision(JSON.parse(job.payload)).dispatch(handlers, { onMissingFunction: "ignore" });

Errors

Everything the SDK throws for an API answer, a network failure, a handler or a signature is a DcisionError:

import { DcisionError } from "@dcision/sdk";

try {
  await dcision.decide("lead-qualification", state);
} catch (error) {
  if (error instanceof DcisionError && error.code === "INVALID_STATE") return reportBadInput(error.message);
  throw error;
}
FieldDescription
codeA stable code: switch on it, not on message.
messageA human-readable explanation.
statusThe HTTP status; undefined when there was no response (network error, timeout) or the check was local.
requestIdThe request's request_id (X-Request-ID): quote it when you contact support.
detailsExtra data for some codes — see Errors.
retryAfterSeconds from the Retry-After header, when there was one.
idempotencyKeyThe Idempotency-Key that decide sent: reuse it to retry safely later.
causeThe underlying error: a network failure, a handler's exception.
decision, functionResultsFUNCTION_NOT_REGISTERED and FUNCTION_FAILED only.

The API's codes are listed in Errors. The SDK adds:

CodeWhen
NETWORK_ERRORNo answer from the API: DNS failure, connection refused or reset, TLS error. Retried.
TIMEOUTAn attempt took longer than timeoutMs. Retried.
FUNCTION_NOT_REGISTEREDA function destination has no handler in functions.
FUNCTION_FAILEDA handler threw.
INVALID_SIGNATUREA webhook failed verification.

Local checks reuse the API's codes with status undefined: INVALID_API_KEY for a missing or malformed key, INVALID_REQUEST for a bad slug, idempotencyKey or a missing state. An answer that doesn't come from the API — an HTML page from a proxy, a redirect — keeps its HTTP status, with a fallback code: INVALID_API_KEY (401), FORBIDDEN (403), NOT_FOUND (404), CONFLICT (409), PAYLOAD_TOO_LARGE (413), RATE_LIMITED (429), INVALID_REQUEST (other 4xx) or INTERNAL_ERROR. Redirects are never followed.

Retries and idempotency

decide sends an Idempotency-Key, generated once per call and reused by every retry. If an attempt ran the decision but its answer was lost, the retry gets the stored answer: retries never run, or bill, a decision twice.

RetriedNot retried
Network errors and timeouts500 INTERNAL_ERROR
409 IDEMPOTENCY_CONFLICT with Retry-After: the first request with the key is still running409 without Retry-After — a different request reused the key — and the other 4xx
429 RATE_LIMITED502 ENGINE_AUTH_FAILED and ENGINE_INVALID_REQUEST: they fail the same way every time
Other 502 errors, 503 and 504Function and signature errors
  • Up to maxRetries retries: 2 by default, so 3 attempts.
  • The SDK waits for Retry-After when the API sends it, up to 30 s. A longer Retry-After isn't waited for: the error is thrown at once, with retryAfter set, so you can schedule the retry.
  • Without Retry-After, it waits 0.5 s, 1 s, 2 s… — doubling, at most 8 s — plus up to 25% of random jitter.
  • me() and executions are retried the same way.

The 409 case is what makes a timeout safe: if an attempt times out while the decision is still running, the retry finds the key reserved, waits one second (Retry-After: 1) and tries again until it gets the stored answer — or runs out of retries and throws IDEMPOTENCY_CONFLICT with the key in error.idempotencyKey, to try again later.

Pass your own key to make retries safe across processes or restarts, for example the ID of what you decide about:

await dcision.decide("support-routing", ticket, { idempotencyKey: `ticket-${ticket.id}` });

timeoutMs applies to each attempt; for a deadline on the whole call, pass a signal: { signal: AbortSignal.timeout(15_000) }. Keep timeoutMs above the decision's own timeoutMs (5 s by default, 30 s at most) plus the timeout of its slowest LLM or sync agent destination, so an attempt has really finished on Dcision's side before it is retried.

Account and executions

const me = await dcision.me();
me.plan.rate_limit_per_minute;
me.usage.used / me.usage.included;

// one page, newest first
const page = await dcision.executions.list({ decision: "lead-qualification", status: "error", limit: 100 });
page.data; // the runs: answers, action, metrics — never the inputs
page.next_cursor; // pass it as `cursor` for the next page; null on the last one

// every page
for await (const run of dcision.executions.iterate({ decision: "lead-qualification", limit: 100 })) {
  console.log(run.created_at, run.result?.route, run.action);
}

iterate() fetches the next page only when you get to it, so break stops it. Reads are never billed and have their own rate-limit window — see Get account and usage and List executions.

Webhooks

import { DcisionError, verifyWebhook } from "@dcision/sdk";

const event = await verifyWebhook(rawBody, signatureHeader, process.env.DCISION_WEBHOOK_SECRET!);
event.type; // "decision.completed"
event.id; // "dlv_…": the same on every delivery attempt
event.data.result.route; // "sales"
event.data.params; // the params mapped in the destination (the state itself is never sent)

verifyWebhook(body, header, secret, { toleranceSec = 300, now }):

  • body — the raw body, as a string, a Uint8Array or Buffer, or an ArrayBuffer;
  • header — the Dcision-Signature header;
  • secret — the signing secret (whsec_…, in Settings → Destinations), or an array of secrets while you rotate it;
  • toleranceSec — how far the signature's timestamp may be from now;
  • now — Unix seconds or a Date, for tests.

It throws DcisionError INVALID_SIGNATURE when the header is missing or malformed, no signature matches (compared in constant time), the timestamp is outside the tolerance or the body isn't a JSON event — answer 400. Misuse, such as no secret or an already parsed body, throws a TypeError instead — answer 500, so Dcision keeps retrying until the fix is deployed.

verifySignature(body, header, secret, options) does the same checks without parsing the body: use it for API requests and agents, whose bodies aren't events.

Full receivers for Express and Next.js are in Webhooks.

TypeScript

Type the answers of a decision with a generic — interfaces work too:

interface LeadResult {
  route: "sales" | "sdr" | "nurture" | "spam" | "other";
  purchase_intent: number;
}

const decision = await dcision.decide<LeadResult>("lead-qualification", state, {
  functions: {
    // handlers can declare the params they expect
    assignToSales: async (params: { email: string }, decision) => crm.assign(params.email, decision.result.route),
  },
});
decision.result.route; // "sales" | "sdr" | "nurture" | "spam" | "other"

const event = await verifyWebhook<LeadResult>(body, header, secret);
event.data.result.route;

The package exports Dcision, Decision, DcisionError, verifyWebhook, verifySignature and VERSION, and types such as DecisionResponse, Destination, QueuedDestination, SkippedDestination, FunctionDestination, WebhookEvent, Me, Execution, ExecutionPage and DcisionErrorCode.

Runtime support

RuntimeNotes
Node.js 20+ESM (import) and CommonJS (require).
BunSame as Node.js.
DenoImport the build: import { Dcision } from "/path/to/dcision/packages/sdk/dist/esm/index.js". Allow the network (--allow-net=api.dcision.io) and, to read DCISION_API_KEY, --allow-env=DCISION_API_KEY — or pass apiKey.
Cloudflare Workers, Vercel EdgePass apiKey from your bindings or environment.
BrowsersNot supported: an API key in a browser is public. Call Dcision from your server.

Security notes

  • The API key stays private. It lives in a private field — not printed by console.log, not in JSON.stringify — and never appears in an error message. A value that isn't a Dcision key is refused before any request.
  • Transport. https only, and redirects are never followed, so the key never reaches another host.
  • Paths. The slug is validated and encoded: nothing else can end up in the URL path.
  • Webhooks. HMAC-SHA256 compared in constant time, timestamps outside the tolerance rejected, verification over the raw bytes only.

On this page