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.

dcision is the official Python SDK. It needs Python 3.9+ and nothing else — only the standard library (urllib, json, hmac) — and it is fully typed (py.typed, mypy --strict clean).

Install

Not on PyPI yet

dcision isn't published on PyPI. Install it from a checkout of the Dcision repository; until this page says it is published, a package with this name on PyPI doesn't come from Dcision.

# From a checkout of the Dcision repository, in your project's environment
pip install ./packages/sdk-python

uv pip install ./packages/sdk-python works the same way. To ship it elsewhere, build a wheel — python -m build packages/sdk-python writes it to packages/sdk-python/dist — and install that file, or point your requirements at the folder: dcision @ file:///path/to/dcision/packages/sdk-python.

Quickstart

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

from dcision import Dcision

client = Dcision()  # reads DCISION_API_KEY

decision = client.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…

Configuration

client = Dcision(
    api_key=os.environ["DCISION_API_KEY"],  # default: the DCISION_API_KEY environment variable
    base_url="https://api.dcision.io",
    timeout=30,
    max_retries=2,
    user_agent="acme-crm/2.1",
)
ArgumentDefaultDescription
api_keyDCISION_API_KEYdcs_live_… or dcs_test_…. A missing key, or one that doesn't start with dcs_, raises DcisionError INVALID_API_KEY at once — nothing is sent.
base_urlhttps://api.dcision.ioMust use https — plain http only for an API on a loopback address. A path prefix is fine.
timeout30Seconds to wait on each attempt — for the connection and for each read.
max_retries2Retries after a network error, a timeout, 429, 502, 503 or 504.
user_agent—Keyword-only. Your app's identifier, prepended to the SDK's: acme-crm/2.1 dcision-sdk-python/0.1.0.

Create one client and share it — across threads too: it keeps no per-request state. The HTTPS_PROXY environment variable is honored. Invalid arguments raise ValueError or TypeError.

Run a decision

decision = client.decide(slug, state, idempotency_key=None, include_probabilities=False,
                         functions=None, on_missing_function="raise")

state is a str, a dict or a list, as the decision declares. The slug is checked locally (lowercase letters, digits and single dashes) before anything is sent.

ArgumentDescription
idempotency_keyThe Idempotency-Key: 1 to 128 characters from A–Z a–z 0–9 . _ : -. Default: a new UUID per call, reused by all its retries.
include_probabilitiesAdds probabilities, the full distribution of every question.
functionsHandlers for function destinations, by function name.
on_missing_function"raise" (default) or "ignore".

decide() returns a Decision. Every field of the API response is an attribute — decision_id, execution_id, schema (the slug), version, result, confidence, scores, composites, action, action_reason, probabilities, metrics and destinations (always a list: [] when none fired). decision.raw is the response exactly as the API sent it, and decision.function_results lists the handlers that ran.

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

Destinations

Each entry of decision.destinations is a dict — see the entries:

for destination in decision.destinations:
    status = destination.get("status")
    if destination["type"] == "reply":
        chat.send(destination["text"], destination["buttons"])
    elif destination["type"] in ("llm", "agent") and status == "completed":
        chat.send(destination.get("text") or destination.get("reply") or "")
    elif status == "failed":
        log.warning("%s: %s", destination["key"], destination["error"])
    elif status == "queued":
        log.info("%s queued as %s", destination["key"], destination["delivery_id"])
    elif status == "skipped":
        log.info("%s skipped: %s", destination["key"], 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:

def assign_to_sales(params, decision):
    return crm.assign(params["email"], decision.result["route"])


decision = client.decide(
    "lead-qualification",
    {"message": message, "email": email},
    functions={"assignToSales": assign_to_sales, "notifySlack": lambda params, decision: slack.post(params["text"])},
)

decision.function_results
# [FunctionResult(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. Only function entries that fired are called.
  • Missing handler: every handler is looked up before the first one runs. If one is missing, decide() raises DcisionError FUNCTION_NOT_REGISTERED and no handler runs — unless you pass on_missing_function="ignore".
  • A handler raises: the next handlers aren't called and decide() raises DcisionError FUNCTION_FAILED, with cause (also __cause__), decision and function_results (the handlers that completed). The decision already ran — and was billed.
  • Handlers run once per decide() call, after the final answer. Calling decide() again with the same idempotency_key replays the stored answer and dispatches its functions again, so make handlers idempotent — for example keyed on decision.execution_id.
  • Function names are dictionary keys, so any name configured in Dcision works, $ included.
  • The client is synchronous: an async def handler raises FUNCTION_FAILED instead of being skipped silently.

Dispatch later, or in another process, with dispatch():

decision = client.decide("lead-qualification", state)  # no functions: nothing is called
queue.push(json.dumps(decision.raw))

# in a worker
from dcision import Decision

results = Decision(json.loads(job.payload)).dispatch(handlers, on_missing_function="ignore")

Errors

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

from dcision import DcisionError

try:
    client.decide("lead-qualification", state)
except DcisionError as error:
    if error.code == "INVALID_STATE":
        report_bad_input(error.message)
    else:
        raise
AttributeDescription
codeA stable code: compare it, not message.
messageA human-readable explanation (also str(error)).
statusThe HTTP status; None when there was no response (network error, timeout) or the check was local.
request_idThe request's request_id (X-Request-ID): quote it when you contact support.
detailsExtra data for some codes — see Errors.
retry_afterSeconds from the Retry-After header, when there was one.
idempotency_keyThe Idempotency-Key that decide() sent: reuse it to retry safely later.
causeThe underlying exception (also __cause__).
decision, function_resultsFUNCTION_NOT_REGISTERED and FUNCTION_FAILED only.

The API's codes are listed in Errors. The SDK adds NETWORK_ERROR and TIMEOUT (both retried), FUNCTION_NOT_REGISTERED, FUNCTION_FAILED and INVALID_SIGNATURE. Local checks reuse the API's codes with status=None: INVALID_API_KEY for a missing or malformed key, INVALID_REQUEST for a bad slug, idempotency_key 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 (NOT_FOUND, PAYLOAD_TOO_LARGE, INTERNAL_ERROR…). DcisionError can be pickled for multiprocessing and task queues.

Retries and idempotency

decide() sends an Idempotency-Key, generated once per call and reused by every retry, so 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
Other 502 errors, 503 and 504Function and signature errors
  • Up to max_retries 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 raises at once, with retry_after set.
  • Without it, the waits are 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.

Pass your own key to make retries safe across processes or restarts:

client.decide("support-routing", ticket, idempotency_key=f"ticket-{ticket['id']}")

Keep timeout 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. If it times out anyway, the retry finds the key reserved, waits for Retry-After: 1 and gets the stored answer.

Account and executions

me = client.me()
me["plan"]["rate_limit_per_minute"]
me["usage"]["used"] / me["usage"]["included"]

# one page, newest first
page = client.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; None on the last one

# every page
for run in client.executions.iterate(decision="lead-qualification", limit=100):
    print(run["created_at"], (run["result"] or {}).get("route"), run["action"])

iterate() is a generator: it fetches the next page only when you get to it. Reads are never billed and have their own rate-limit window.

Webhooks

from dcision import DcisionError, verify_webhook

event = verify_webhook(raw_body, signature_header, os.environ["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)

verify_webhook(payload, header, secret, tolerance=300, now=None):

  • payload — the raw body: bytes (recommended) or str;
  • header — the Dcision-Signature header;
  • secret — the signing secret (whsec_…, in Settings → Destinations), or a list of secrets while you rotate it;
  • tolerance — how far, in seconds, the signature's timestamp may be from now;
  • now — Unix time in seconds, for tests.

It raises DcisionError INVALID_SIGNATURE when the header is missing or malformed, no signature matches (compared with hmac.compare_digest), the timestamp is outside the tolerance or the body isn't a JSON event — answer 400. Misuse, such as no secret or a parsed dict as the payload, raises TypeError or ValueError — let it become a 500, so Dcision keeps retrying until the fix is deployed.

verify_signature(...) does the same checks without parsing the body: use it for API requests and agents. Flask and FastAPI receivers are in Webhooks.

Typing

Responses are plain dicts described by TypedDicts in dcision.types — DecisionResponse, Destination and its variants (ReplyDestination, LlmDestination, AgentDestination, FailedDestination, QueuedDestination, SkippedDestination, FunctionDestination…), WebhookEvent, Me, Execution and ExecutionPage:

from dcision.types import WebhookEvent


def handle(event: WebhookEvent) -> None:
    route = event["data"]["result"]["route"]

Async applications

The client is synchronous. In asyncio code, run it in a thread — verify_webhook() does no I/O and can be called directly:

decision = await asyncio.to_thread(client.decide, "lead-qualification", state)

Security notes

  • The API key stays private. It is never in repr() or in an error message, and a value that isn't a Dcision key is refused before any request.
  • Transport. https only, and redirects are never followed: urllib would forward the Authorization header to the new 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