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/sdknpm 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 projectQuickstart
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",
});| Option | Default | Description |
|---|---|---|
apiKey | DCISION_API_KEY | dcs_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. |
baseUrl | https://api.dcision.io | Must 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. |
timeoutMs | 30000 | Timeout of each attempt, in milliseconds. |
maxRetries | 2 | Retries after a network error, a timeout, 429, 502, 503 or 504. |
fetch | the global fetch | A 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.
| Option | Description |
|---|---|
idempotencyKey | The Idempotency-Key: 1 to 128 characters from A–Z a–z 0–9 . _ : -. Default: a new UUID per call, reused by all its retries. |
includeProbabilities | Adds probabilities, the full distribution of every question (?include=probabilities). |
functions | Handlers for function destinations. |
onMissingFunction | "throw" (default) or "ignore". |
signal | An 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 ofdestinations, 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,
decidethrowsDcisionErrorFUNCTION_NOT_REGISTEREDand no handler runs — unless you passonMissingFunction: "ignore". - A handler throws: the next handlers aren't called and
decidethrowsDcisionErrorFUNCTION_FAILED, withcause(what the handler threw),decisionandfunctionResults(the handlers that completed). The decision already ran — and was billed. - Handlers run once per
decidecall, after the final answer: retries inside the call never dispatch twice. Callingdecideagain with the sameidempotencyKeyreplays the stored answer and dispatches its functions again, so make handlers idempotent — for example keyed ondecision.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;
}| Field | Description |
|---|---|
code | A stable code: switch on it, not on message. |
message | A human-readable explanation. |
status | The HTTP status; undefined when there was no response (network error, timeout) or the check was local. |
requestId | The request's request_id (X-Request-ID): quote it when you contact support. |
details | Extra data for some codes — see Errors. |
retryAfter | Seconds from the Retry-After header, when there was one. |
idempotencyKey | The Idempotency-Key that decide sent: reuse it to retry safely later. |
cause | The underlying error: a network failure, a handler's exception. |
decision, functionResults | FUNCTION_NOT_REGISTERED and FUNCTION_FAILED only. |
The API's codes are listed in Errors. The SDK adds:
| Code | When |
|---|---|
NETWORK_ERROR | No answer from the API: DNS failure, connection refused or reset, TLS error. Retried. |
TIMEOUT | An attempt took longer than timeoutMs. Retried. |
FUNCTION_NOT_REGISTERED | A function destination has no handler in functions. |
FUNCTION_FAILED | A handler threw. |
INVALID_SIGNATURE | A 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.
| Retried | Not retried |
|---|---|
| Network errors and timeouts | 500 INTERNAL_ERROR |
409 IDEMPOTENCY_CONFLICT with Retry-After: the first request with the key is still running | 409 without Retry-After — a different request reused the key — and the other 4xx |
429 RATE_LIMITED | 502 ENGINE_AUTH_FAILED and ENGINE_INVALID_REQUEST: they fail the same way every time |
Other 502 errors, 503 and 504 | Function and signature errors |
- Up to
maxRetriesretries: 2 by default, so 3 attempts. - The SDK waits for
Retry-Afterwhen the API sends it, up to 30 s. A longerRetry-Afterisn't waited for: the error is thrown at once, withretryAfterset, 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()andexecutionsare 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 astring, aUint8ArrayorBuffer, or anArrayBuffer;header— theDcision-Signatureheader;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 fromnow;now— Unix seconds or aDate, 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
| Runtime | Notes |
|---|---|
| Node.js 20+ | ESM (import) and CommonJS (require). |
| Bun | Same as Node.js. |
| Deno | Import 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 Edge | Pass apiKey from your bindings or environment. |
| Browsers | Not 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 inJSON.stringify— and never appears in an error message. A value that isn't a Dcision key is refused before any request. - Transport.
httpsonly, 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.
SDKs overview
The official TypeScript and Python SDKs — what they add over plain HTTPS, how they compare, and how to build them from source until they are published on npm and PyPI.
Python SDK
The dcision package for Python 3.9+ — build from source, decide, destinations and functions, errors, idempotent retries, executions, me() and webhook verification with Flask or FastAPI.