Webhooks

Receive the signed decision.completed event at your URL — the envelope, its headers, verifying Dcision-Signature with and without the SDKs, raw bodies in Express and Next.js, rotation and deduplication.

A webhook destination POSTs the decision's outcome to your URL as a signed decision.completed event, in the background and with retries. Use it to notify your backend: update a record, queue a job, start your own routing.

Configure it

{
  "key": "notify_sales",
  "type": "webhook",
  "when": { "conditions": [{ "field": "route", "on": "output", "operator": "eq", "value": "sales" }] },
  "params": {
    "message": { "from": "state.message" },
    "team": { "value": "inbound" }
  },
  "webhook": { "url": "https://example.com/hooks/dcision" }
}

webhook.url is an https:// URL of up to 2,048 characters — variables are URL-encoded — or {{secrets.NAME}} holding the whole URL. The body is always the event below: to send a body of your own — a Slack message, a CRM payload — use an API request.

The event

POST body
{
  "id": "dlv_8kJx2mQp4LzN7vR1tY6w",
  "type": "decision.completed",
  "created": 1759658400,
  "livemode": true,
  "destination": "notify_sales",
  "data": {
    "decision": "lead-qualification",
    "decision_id": "dec_3fKq9ZtW1mXcV7bN2pLa",
    "version": 4,
    "execution_id": "exec_8HsT2kQw9ZyR4vMn1cXe",
    "result": { "purchase_intent": 0.9412, "priority": "high", "route": "sales" },
    "confidence": { "purchase_intent": 0.8824, "priority": 0.69, "route": 0.85 },
    "scores": { "priority": 2.81 },
    "composites": { "lead_score": 0.8414 },
    "action": "continue",
    "action_reason": { "type": "default" },
    "params": { "message": "We need pricing for 500 users and want to start next month.", "team": "inbound" }
  }
}
  • id is the delivery ID: the same on every attempt of this delivery. Deduplicate on it.
  • created is when the decision ran, in Unix seconds; livemode is false for runs with a test key (dcs_test_…) and from the Playground.
  • data carries the answers, the action and its reason, and params — never the state itself: map the fields the receiver needs as params.

Every field is described in Webhook events, the API reference of this event.

Headers

HeaderValue
User-AgentDcision-Destinations/1.0 (+https://docs.dcision.io/destinations)
Content-Typeapplication/json
Dcision-DeliveryThe delivery ID, dlv_… — the same on every attempt.
Dcision-AttemptThe attempt number, 1 to 6.
Dcision-Eventdecision.completed
Dcision-Signaturet=<unix seconds>,v1=<hex> — see below.

Verify the signature

Every delivery is signed with your workspace's signing secret — whsec_ followed by 32 letters and digits. Admins and the Owner reveal it in Settings → Destinations → Signing secret; store it on your server as, for example, DCISION_WEBHOOK_SECRET.

Dcision-Signature: t=1759658400,v1=662b905d30dbc7ccce5864c0e9dda66451117e8d3361c3e39bb9f91417199b52

signed_payload = "<t>." + <the raw request body>
v1             = hex( HMAC-SHA256( signing secret, signed_payload ) )

To accept a request:

  1. Read the raw body — the exact bytes received. A body parsed and serialized again has different bytes and never verifies.
  2. Split the header on , into t= and one or more v1= entries.
  3. Compute the HMAC-SHA256 of <t>.<raw body> with the signing secret, as hex, and compare it with each v1 in constant time. Accept when any of them matches.
  4. Reject timestamps more than 300 seconds away from your clock — the protection against replayed requests. t is the time of the attempt: every retry is signed again.

Answer 400 when verification fails. The SDKs do all of this in one call.

Express

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

const app = express();

// express.raw, not express.json: the signature covers the exact bytes Dcision sent.
app.post("/hooks/dcision", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    event = await verifyWebhook(req.body, req.header("dcision-signature"), process.env.DCISION_WEBHOOK_SECRET!);
  } catch (error) {
    // A bad signature is final (400). Anything else is on our side: 500, so Dcision retries.
    return res.sendStatus(error instanceof DcisionError ? 400 : 500);
  }
  if (await deliveries.seen(event.id)) return res.sendStatus(200);
  await jobs.enqueue("dcision-decision", event.data);
  res.sendStatus(200);
});

Next.js route handler

app/api/hooks/dcision/route.ts
import { DcisionError, verifyWebhook, type WebhookEvent } from "@dcision/sdk";

export async function POST(request: Request) {
  const body = await request.text(); // the raw body — not request.json()
  let event: WebhookEvent;
  try {
    event = await verifyWebhook(body, request.headers.get("dcision-signature"), process.env.DCISION_WEBHOOK_SECRET!);
  } catch (error) {
    return new Response(null, { status: error instanceof DcisionError ? 400 : 500 });
  }
  await handleDecision(event.data); // throws → 500 → Dcision retries
  return new Response(null, { status: 204 });
}

It runs on the Node.js and the Edge runtimes.

Python

import os

from flask import Flask, request

from dcision import DcisionError, verify_webhook

app = Flask(__name__)


@app.post("/hooks/dcision")
def dcision_webhook():
    try:
        # get_data(): the raw bytes. Don't read request.json first.
        event = verify_webhook(request.get_data(), request.headers.get("Dcision-Signature"), os.environ["DCISION_WEBHOOK_SECRET"])
    except DcisionError as error:
        return error.message, 400
    if deliveries.seen(event["id"]):
        return "", 200
    handle_decision(event["data"])
    return "", 200

The SDKs raise INVALID_SIGNATURE for a missing or malformed header, a signature that doesn't match, a timestamp outside the tolerance or a body that isn't an event, and a TypeError for misuse — no secret, or a body that was already parsed. See TypeScript SDK and Python SDK. The SDKs are built from source until they are published.

Verify without the SDK

import { createHmac, timingSafeEqual } from "node:crypto";

/** Verifies Dcision-Signature over the raw request body (a string or a Buffer). */
export function verifyDcisionSignature(rawBody, header, secret, toleranceSec = 300) {
  let timestamp;
  const signatures = [];
  for (const part of (header ?? "").split(",")) {
    const [name, value = ""] = part.trim().split("=");
    if (name === "t") timestamp = value;
    else if (name === "v1" && /^[0-9a-f]{64}$/i.test(value)) signatures.push(Buffer.from(value, "hex"));
  }
  if (!timestamp || !/^\d+$/.test(timestamp) || signatures.length === 0) return false;
  const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest();
  if (!signatures.some((signature) => timingSafeEqual(signature, expected))) return false;
  return Math.abs(Date.now() / 1000 - Number(timestamp)) <= toleranceSec;
}

Test vector

Check your implementation against this signature — the one the SDKs are tested with. The body is exactly one line, UTF-8, with no trailing newline:

Inputs
secret  whsec_9vX2kLmQ4pRt7wYz1aBc3dEf5gHj8KnS
t       1759658400
Raw body
{"id":"dlv_8kJx2mQp4LzN7vR1tY6w","type":"decision.completed","created":1759658400,"livemode":true,"destination":"notify_sales","data":{"decision":"lead-qualification","decision_id":"dec_4Qm9Tz2Lx7Wv1Rb8Nc3K","version":3,"execution_id":"exec_8HsT2kQw9ZyR4vMn1cXe","result":{"route":"sales"},"confidence":{"route":0.91},"scores":{},"composites":{},"action":"continue","action_reason":{"type":"default"},"params":{"message":"Olá! Preciso de preço para 500 licenças — urgente 🚀","team":"inbound"}}}
Expected
v1      662b905d30dbc7ccce5864c0e9dda66451117e8d3361c3e39bb9f91417199b52

Use a clock of 1759658400 — or a large tolerance — when you test the timestamp check.

Rotate the signing secret

Rotate in Settings → Destinations creates a new secret at once. For the next 24 hours Dcision signs every delivery with both secrets, so the header carries two v1= entries — t=…,v1=<new>,v1=<old> — and a receiver that knows either one keeps verifying:

  1. Rotate, and copy the new secret.
  2. Deploy your receivers with both secrets — the SDKs take a list: verifyWebhook(body, header, [newSecret, oldSecret]).
  3. Within 24 hours, remove the old secret.

Respond and deduplicate

  • Answer 2xx within 10 seconds, then do the work asynchronously. Slow answers count as timeouts and are retried.
  • Deliveries are at least once: a timeout or a lost answer means the event can arrive again. Deduplicate on id (or Dcision-Delivery).
  • Every non-2xx answer counts: 408, 425, 429 and 5xx are retried — six attempts over about seven hours; other 4xx answers and redirects are final. See Delivery and retries.
  • Check livemode to keep test and Playground events out of production systems.

On this page