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
{
"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" }
}
}idis the delivery ID: the same on every attempt of this delivery. Deduplicate on it.createdis when the decision ran, in Unix seconds;livemodeisfalsefor runs with a test key (dcs_test_…) and from the Playground.datacarries the answers, the action and its reason, andparams— 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
| Header | Value |
|---|---|
User-Agent | Dcision-Destinations/1.0 (+https://docs.dcision.io/destinations) |
Content-Type | application/json |
Dcision-Delivery | The delivery ID, dlv_… — the same on every attempt. |
Dcision-Attempt | The attempt number, 1 to 6. |
Dcision-Event | decision.completed |
Dcision-Signature | t=<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:
- Read the raw body — the exact bytes received. A body parsed and serialized again has different bytes and never verifies.
- Split the header on
,intot=and one or morev1=entries. - Compute the HMAC-SHA256 of
<t>.<raw body>with the signing secret, as hex, and compare it with eachv1in constant time. Accept when any of them matches. - Reject timestamps more than 300 seconds away from your clock — the protection against replayed requests.
tis 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
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 "", 200The 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:
secret whsec_9vX2kLmQ4pRt7wYz1aBc3dEf5gHj8KnS
t 1759658400{"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"}}}v1 662b905d30dbc7ccce5864c0e9dda66451117e8d3361c3e39bb9f91417199b52Use 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:
- Rotate, and copy the new secret.
- Deploy your receivers with both secrets — the SDKs take a list:
verifyWebhook(body, header, [newSecret, oldSecret]). - Within 24 hours, remove the old secret.
Respond and deduplicate
- Answer
2xxwithin 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(orDcision-Delivery). - Every non-
2xxanswer counts:408,425,429and5xxare retried — six attempts over about seven hours; other4xxanswers and redirects are final. See Delivery and retries. - Check
livemodeto keep test and Playground events out of production systems.
Workflows
Trigger n8n, Make, Zapier, Pipedream or any workflow URL with the route's variables as flat JSON fields, plus the decision under dcision — signed and retried in the background.
HTTP requests
Call any API after a decision — method, URL, headers and a JSON body built from variables and workspace secrets — delivered with retries, an Idempotency-Key and a signature.