> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cyberdesk.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Detailed Webhook Guide

> End-to-end guidance for implementing Cyberdesk webhooks

## Introduction

Webhooks are HTTPS POST requests sent from Cyberdesk to your service when events happen (e.g., a workflow run completes). They eliminate polling and let you react in real-time.

The high-level steps:

1. Activate Webhooks in the Dashboard (creates a Svix Application for your org).
2. Add an endpoint URL and subscribe to `run_complete`.
3. Implement your endpoint to verify signatures and process events.
4. Test and monitor via the embedded Webhooks portal.

## Events and Event Types

Cyberdesk webhooks are organized by event types (e.g., `run_complete`). You can browse the full, always-up-to-date list and their schemas in the Event Catalog inside the Webhooks portal. As of today, `run_complete` is the most commonly used type and fires when a workflow run reaches a terminal state (`success`, `error`, `task_failed`, or `cancelled`). Its payload includes the completed Run object, excluding `run_message_history` to keep webhook deliveries small and reliable. If you need message history, retrieve the run later through the API or SDK.

## Adding an Endpoint

1. Go to Dashboard → Webhooks.
2. Click "Add Endpoint" and enter your publicly reachable HTTPS URL.
3. Select the `run_complete` event type (or accept all for initial testing).
4. Copy the generated endpoint signing secret (`whsec_...`).

<Note>
  Keep separate endpoints/secrets for each environment (dev, staging, prod).
</Note>

## Testing Endpoints

Use the "Testing" tools in the Webhooks portal to send example events to your endpoint. Inspect payloads, responses, and message attempts under Logs/Activity. You can replay messages individually or recover all failed messages since a timestamp.

<Note>
  We’re building a managed Webhooks SDK with built‑in verification and typing. If you want this prioritized, please let the team know.
</Note>

## Verifying Signatures

Always verify signatures to ensure messages originate from Cyberdesk. We use Svix headers and signing format.

Headers sent with every webhook:

* `svix-id` – unique message id (use for idempotency)
* `svix-timestamp` – Unix timestamp
* `svix-signature` – signature over the raw request body

### Install dependencies

<Tabs>
  <Tab title="TypeScript / Node">
    ```bash theme={null}
    npm install cyberdesk svix
    # or
    yarn add cyberdesk svix
    # or
    pnpm add cyberdesk svix
    ```
  </Tab>

  <Tab title="Python">
    ```bash theme={null}
    pip install cyberdesk svix
    # or
    poetry add cyberdesk svix
    # or
    pipenv install cyberdesk svix
    ```
  </Tab>
</Tabs>

### Endpoint handlers

<Tabs>
  <Tab title="TypeScript / Node">
    ```ts theme={null}
    import express from "express";
    import { Webhook, WebhookVerificationError } from "svix";
    import { createCyberdeskClient } from "cyberdesk";
    import type { RunCompletedEvent, RunResponse } from "cyberdesk";

    const app = express();
    app.use(express.raw({ type: "application/json" }));

    function assertRunCompletedEvent(x: unknown): asserts x is RunCompletedEvent {
      if (!x || (x as any).event_type !== "run_complete" || !(x as any).run) {
        throw new Error("Invalid run_complete payload");
      }
    }

    app.post("/webhooks/cyberdesk", (req, res) => {
      const secret = process.env.SVIX_WEBHOOK_SECRET!;
      const wh = new Webhook(secret);
      const headers = {
        "svix-id": req.header("svix-id")!,
        "svix-timestamp": req.header("svix-timestamp")!,
        "svix-signature": req.header("svix-signature")!,
      };
      try {
        const payload = wh.verify(req.body, headers);
        assertRunCompletedEvent(payload);
        const run: RunResponse = payload.run; // fully typed
        // process run
        res.status(200).end();
      } catch (err) {
        if (err instanceof WebhookVerificationError) return res.status(400).end();
        res.status(500).end();
      }
    });
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    from fastapi import FastAPI, Request, HTTPException
    from svix.webhooks import Webhook, WebhookVerificationError
    from openapi_client.cyberdesk_cloud_client.models.run_completed_event import RunCompletedEvent
    from openapi_client.cyberdesk_cloud_client.models.run_response import RunResponse
    from typing import cast
    import os

    app = FastAPI()

    @app.post("/webhooks/cyberdesk")
    async def cyberdesk_webhook(request: Request):
        secret = os.environ["SVIX_WEBHOOK_SECRET"]
        wh = Webhook(secret)
        payload = await request.body()
        headers = {
            "svix-id": request.headers.get("svix-id"),
            "svix-timestamp": request.headers.get("svix-timestamp"),
            "svix-signature": request.headers.get("svix-signature"),
        }
        try:
            data = wh.verify(payload, headers)
            evt = RunCompletedEvent.from_dict(data)
            run: RunResponse = cast(RunResponse, evt.run)
            # process run
            return {"ok": True}
        except WebhookVerificationError:
            raise HTTPException(status_code=400, detail="Invalid signature")
    ```
  </Tab>
</Tabs>

<Warning>
  Verify against the exact raw request body. Do not re-stringify JSON before verification.
</Warning>

## Retry Mechanism

Cyberdesk (via Svix) retries failed deliveries using exponential backoff:

* Immediately
* 5 seconds
* 5 minutes
* 30 minutes
* 2 hours
* 5 hours
* 10 hours
* 10 hours

A response with HTTP 2xx is considered success. Avoid long processing in the webhook handler—enqueue work and return 200 quickly to prevent timeouts.

Manual retries: from the portal you can resend single messages or recover all failed messages since a given time.

## Troubleshooting & Failure Recovery

Common pitfalls:

* Not using raw body for signature verification
* Using the wrong endpoint secret (each endpoint has its own secret)
* Returning non-2xx status for successful processing
* Handler timeouts (do heavy work asynchronously)

Failure recovery:

* Re-enable disabled endpoints in the portal
* Replay failed messages individually or recover all since a timestamp

## Using run\_complete Data

`run_complete` includes the full `RunResponse`. Useful patterns:

* Trigger the next workflow in your pipeline
* Update your job table with `run.status` and `output_data`
* Persist `output_attachment_ids` and fetch/download files as needed
* Correlate `input_values` to your original request (e.g., patient\_id)
* Understand sensitive inputs: plaintext secrets are never included in the payload. `run.sensitive_input_aliases` maps each sensitive input key (for example, `password`) to the secure secret identifier used during execution, so you can audit which sensitive variables were referenced without exposing their values.

### Example: TypeScript handler

```ts theme={null}
import { createCyberdeskClient } from "cyberdesk";
import type { RunCompletedEvent, RunResponse } from "cyberdesk";

if (payload.event_type === "run_complete") {
  const run: RunResponse = (payload as RunCompletedEvent).run;
  await db.runs.upsert({ id: run.id, status: run.status, output: run.output_data });
  // kick off a follow-up Cyberdesk run
  const client = createCyberdeskClient(process.env.CYBERDESK_API_KEY!);
  await client.runs.create({ workflow_id: process.env.NEXT_WORKFLOW_ID! });
  if (run.output_attachment_ids?.length) {
    // display download links using Cyberdesk attachment APIs
  }
}
```

### Example: Python handler

```python theme={null}
import os
from cyberdesk import CyberdeskClient, RunCreate
from openapi_client.cyberdesk_cloud_client.models.run_completed_event import RunCompletedEvent
from openapi_client.cyberdesk_cloud_client.models.run_response import RunResponse
from typing import cast

evt = RunCompletedEvent.from_dict(data)
run: RunResponse = cast(RunResponse, evt.run)
save_status(run.id, run.status, run.output_data)
client = CyberdeskClient(os.environ["CYBERDESK_API_KEY"])
client.runs.create_sync(RunCreate(workflow_id=os.environ["NEXT_WORKFLOW_ID"]))
```

### React to Post-run Check failures

`run_complete` is the best place to react to Post-run Checks, because the event fires only after those checks finish.

The result data lives on `run.post_run_checks`.

Important details:

* `run.post_run_checks` is an array, not an object keyed by name
* each item includes `name`, `status`, `error_message`, `messages`, and `matched_filenames`
* if you want to access a specific check by name, keep names stable and unique in the workflow editor

<Tabs>
  <Tab title="TypeScript / Node">
    ```ts theme={null}
    function getFailedPostRunChecks(run: RunResponse) {
      return (run.post_run_checks ?? []).filter((check) => check.status !== "success");
    }

    function getCheckByName(run: RunResponse, name: string) {
      return (run.post_run_checks ?? []).find((check) => check.name === name);
    }

    app.post("/webhooks/cyberdesk", (req, res) => {
      const secret = process.env.SVIX_WEBHOOK_SECRET!;
      const wh = new Webhook(secret);
      const headers = {
        "svix-id": req.header("svix-id")!,
        "svix-timestamp": req.header("svix-timestamp")!,
        "svix-signature": req.header("svix-signature")!,
      };

      try {
        const payload = wh.verify(req.body, headers);
        assertRunCompletedEvent(payload);
        const run: RunResponse = payload.run;

        const failedChecks = getFailedPostRunChecks(run);
        const invoiceCheck = getCheckByName(run, "Invoice PDF exists");

        if (failedChecks.length > 0) {
          console.log("failed checks:", failedChecks.map((check) => ({
            name: check.name,
            status: check.status,
            error: check.error_message,
            messages: check.messages,
            matched: check.matched_filenames,
          })));
        }

        if (invoiceCheck && invoiceCheck.status !== "success") {
          console.log("invoice check did not pass:", invoiceCheck);
        }

        res.status(200).json({ ok: true });
      } catch (err) {
        res.status(400).json({ error: "Invalid signature" });
      }
    });
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    def get_failed_post_run_checks(run: RunResponse):
        return [check for check in (run.post_run_checks or []) if getattr(check.status, "value", check.status) != "success"]

    def get_check_by_name(run: RunResponse, name: str):
        return next((check for check in (run.post_run_checks or []) if check.name == name), None)


    @app.post("/webhooks/cyberdesk")
    async def cyberdesk_webhook(request: Request):
        secret = os.environ["SVIX_WEBHOOK_SECRET"]
        wh = Webhook(secret)

        payload = await request.body()
        headers = {
            "svix-id": request.headers.get("svix-id"),
            "svix-timestamp": request.headers.get("svix-timestamp"),
            "svix-signature": request.headers.get("svix-signature"),
        }

        try:
            data = wh.verify(payload, headers)
            evt = RunCompletedEvent.from_dict(data)
            run: RunResponse = cast(RunResponse, evt.run)

            failed_checks = get_failed_post_run_checks(run)
            invoice_check = get_check_by_name(run, "Invoice PDF exists")

            if failed_checks:
                print([
                    {
                        "name": check.name,
                        "status": getattr(check.status, "value", check.status),
                        "error": check.error_message,
                        "messages": check.messages,
                        "matched": check.matched_filenames,
                    }
                    for check in failed_checks
                ])

            if invoice_check and getattr(invoice_check.status, "value", invoice_check.status) != "success":
                print("invoice check did not pass", invoice_check)

            return {"ok": True}
        except WebhookVerificationError:
            raise HTTPException(status_code=400, detail="Invalid signature")
    ```
  </Tab>
</Tabs>

## Advanced example: dynamic workflow chain webhook

Some production integrations build the entire Cyberdesk chain up front, but the
steps included in that chain depend on the initial request. For example, a
charge-entry integration might always run an encounter setup workflow, skip
remove/update/add workflows when their input arrays are empty, and always finish
with a finalization workflow.

In this pattern:

* Create the chain with `client.runs.chain(...)` from your normal POST endpoint.
* Store the returned `run_ids` in your database with their step order.
* In your `run_complete` webhook, branch on every terminal status.
* Treat non-success statuses before the final step as early chain stops; later
  runs may never emit webhooks.
* When the final run has `release_session_after: true`, clean up any local locks
  or resources tied to that Cyberdesk session.

<Tabs>
  <Tab title="Python / FastAPI">
    ```python theme={null}
    import os
    from typing import Any, cast

    from cyberdesk import CyberdeskClient
    from openapi_client.cyberdesk_cloud_client.models.workflow_chain_create import (
        WorkflowChainCreate,
    )
    from fastapi import BackgroundTasks, FastAPI, HTTPException, Request
    from openapi_client.cyberdesk_cloud_client.models.run_completed_event import (
        RunCompletedEvent,
    )
    from openapi_client.cyberdesk_cloud_client.models.run_response import RunResponse
    from svix.webhooks import Webhook, WebhookVerificationError

    app = FastAPI()
    client = CyberdeskClient(os.environ["CYBERDESK_API_KEY"])

    # Replace this with durable database tables in production.
    chains_by_run_id: dict[str, dict[str, Any]] = {}
    processed_messages: set[str] = set()


    def build_steps(batch: dict[str, Any]) -> list[dict[str, Any]]:
        steps = [
            {
                "workflow_id": os.environ["WORKFLOW_1_ID"],
                "session_alias": "encounter",
                "inputs": {
                    "FIN": batch["FIN"],
                    "icd_codes": batch["icd_codes"],
                    "notes": batch["notes"],
                },
            }
        ]

        if batch.get("charges_to_remove"):
            steps.append({
                "workflow_id": os.environ["WORKFLOW_2_ID"],
                "session_alias": "remove_charges",
                "inputs": {"charges_to_remove": batch["charges_to_remove"]},
            })

        if batch.get("charges_to_update"):
            steps.append({
                "workflow_id": os.environ["WORKFLOW_3_ID"],
                "session_alias": "update_charges",
                "inputs": {"charges_to_update": batch["charges_to_update"]},
            })

        if batch.get("charges_to_add"):
            steps.append({
                "workflow_id": os.environ["WORKFLOW_4_ID"],
                "session_alias": "add_charges",
                "inputs": {"charges_to_add": batch["charges_to_add"]},
            })

        steps.append({
            "workflow_id": os.environ["WORKFLOW_5_ID"],
            "session_alias": "finalize",
            "inputs": {},
        })
        return steps


    @app.post("/charge-batches")
    def start_charge_batch(batch: dict[str, Any]):
        steps = build_steps(batch)
        chain = WorkflowChainCreate.from_dict({
            "steps": steps,
            "keep_session_after_completion": False,
        })

        response = client.runs.chain_sync(chain)
        if response.error or response.data is None:
            raise HTTPException(status_code=502, detail=str(response.error))

        run_ids = response.data.run_ids
        for index, run_id in enumerate(run_ids):
            chains_by_run_id[run_id] = {
                "session_id": response.data.session_id,
                "step_number": index + 1,
                "total_steps": len(run_ids),
                "workflow_id": steps[index]["workflow_id"],
            }

        return {
            "session_id": response.data.session_id,
            "run_ids": run_ids,
            "included_steps": [step["session_alias"] for step in steps],
        }


    async def handle_run_terminal(run: RunResponse) -> None:
        record = chains_by_run_id.get(str(run.id))
        if not record:
            return

        is_final_step = record["step_number"] == record["total_steps"]

        if not is_final_step and run.status != "success":
            # PLACEHOLDER: Notify the client, retry the chain, or create a human
            # review task. Later steps may never run after this early stop.
            await mark_chain_failed(record, run.status, run.error)
            return

        if is_final_step:
            if run.status == "success":
                # PLACEHOLDER: Mark the full batch complete.
                await mark_chain_complete(record)
            elif run.status == "cancelled":
                # PLACEHOLDER: Mark finalization cancelled after earlier steps.
                await mark_chain_cancelled(record)
            else:
                # PLACEHOLDER: Handle finalization failure after prior mutations.
                await mark_chain_failed(record, run.status, run.error)

            if run.release_session_after:
                # PLACEHOLDER: Clear local locks/resources for this session.
                await cleanup_session(record["session_id"])
            return

        # PLACEHOLDER: Persist intermediate step success and wait for next webhook.
        await mark_step_success(record, run.output_data)


    async def mark_chain_complete(record: dict[str, Any]) -> None: ...
    async def mark_chain_cancelled(record: dict[str, Any]) -> None: ...
    async def mark_chain_failed(
        record: dict[str, Any], status: str, error: Any
    ) -> None: ...
    async def mark_step_success(record: dict[str, Any], output_data: Any) -> None: ...
    async def cleanup_session(session_id: str) -> None: ...


    @app.post("/webhooks/cyberdesk")
    async def cyberdesk_webhook(request: Request, background_tasks: BackgroundTasks):
        raw_body = await request.body()
        message_id = request.headers.get("svix-id")
        headers = {
            "svix-id": message_id,
            "svix-timestamp": request.headers.get("svix-timestamp"),
            "svix-signature": request.headers.get("svix-signature"),
        }

        try:
            data = Webhook(os.environ["SVIX_WEBHOOK_SECRET"]).verify(raw_body, headers)
        except WebhookVerificationError:
            raise HTTPException(status_code=400, detail="Invalid signature")

        if message_id in processed_messages:
            return {"ok": True, "duplicate": True}
        if message_id:
            processed_messages.add(message_id)

        evt = RunCompletedEvent.from_dict(data)
        run: RunResponse = cast(RunResponse, evt.run)
        background_tasks.add_task(handle_run_terminal, run)
        return {"ok": True}
    ```
  </Tab>

  <Tab title="Node.js / Express">
    ```ts theme={null}
    import express from "express";
    import { Webhook, WebhookVerificationError } from "svix";
    import { createCyberdeskClient } from "cyberdesk";
    import type { RunCompletedEvent, RunResponse, WorkflowChainCreate } from "cyberdesk";

    const app = express();
    const rawJson = express.raw({ type: "application/json" });
    const jsonBody = express.json();

    const client = createCyberdeskClient(process.env.CYBERDESK_API_KEY!);

    // Replace these with durable database tables in production.
    const chainsByRunId = new Map<string, {
      sessionId: string;
      stepNumber: number;
      totalSteps: number;
      workflowId: string;
    }>();
    const processedMessages = new Set<string>();

    function assertRunCompletedEvent(x: unknown): asserts x is RunCompletedEvent {
      if (!x || (x as any).event_type !== "run_complete" || !(x as any).run) {
        throw new Error("Invalid run_complete payload");
      }
    }

    function buildSteps(batch: any): WorkflowChainCreate["steps"] {
      const steps: WorkflowChainCreate["steps"] = [
        {
          workflow_id: process.env.WORKFLOW_1_ID!,
          session_alias: "encounter",
          inputs: {
            FIN: batch.FIN,
            icd_codes: batch.icd_codes,
            notes: batch.notes,
          },
        },
      ];

      if (batch.charges_to_remove?.length) {
        steps.push({
          workflow_id: process.env.WORKFLOW_2_ID!,
          session_alias: "remove_charges",
          inputs: { charges_to_remove: batch.charges_to_remove },
        });
      }

      if (batch.charges_to_update?.length) {
        steps.push({
          workflow_id: process.env.WORKFLOW_3_ID!,
          session_alias: "update_charges",
          inputs: { charges_to_update: batch.charges_to_update },
        });
      }

      if (batch.charges_to_add?.length) {
        steps.push({
          workflow_id: process.env.WORKFLOW_4_ID!,
          session_alias: "add_charges",
          inputs: { charges_to_add: batch.charges_to_add },
        });
      }

      steps.push({
        workflow_id: process.env.WORKFLOW_5_ID!,
        session_alias: "finalize",
        inputs: {},
      });
      return steps;
    }

    app.post("/charge-batches", jsonBody, async (req, res, next) => {
      try {
        const steps = buildSteps(req.body);
        const { data, error } = await client.runs.chain({
          steps,
          keep_session_after_completion: false,
        });
        if (error || !data) throw new Error(String(error));

        data.run_ids.forEach((runId, index) => {
          chainsByRunId.set(runId, {
            sessionId: data.session_id,
            stepNumber: index + 1,
            totalSteps: data.run_ids.length,
            workflowId: steps[index].workflow_id,
          });
        });

        res.json({
          session_id: data.session_id,
          run_ids: data.run_ids,
          included_steps: steps.map((step) => step.session_alias),
        });
      } catch (err) {
        next(err);
      }
    });

    async function handleRunTerminal(run: RunResponse) {
      const record = chainsByRunId.get(run.id);
      if (!record) return;

      const isFinalStep = record.stepNumber === record.totalSteps;

      if (!isFinalStep && run.status !== "success") {
        // PLACEHOLDER: Notify the client, retry the chain, or create a human
        // review task. Later steps may never run after this early stop.
        await markChainFailed(record, run.status, run.error);
        return;
      }

      if (isFinalStep) {
        if (run.status === "success") {
          // PLACEHOLDER: Mark the full batch complete.
          await markChainComplete(record);
        } else if (run.status === "cancelled") {
          // PLACEHOLDER: Mark finalization cancelled after earlier steps.
          await markChainCancelled(record);
        } else {
          // PLACEHOLDER: Handle finalization failure after prior mutations.
          await markChainFailed(record, run.status, run.error);
        }

        if (run.release_session_after) {
          // PLACEHOLDER: Clear local locks/resources for this session.
          await cleanupSession(record.sessionId);
        }
        return;
      }

      // PLACEHOLDER: Persist intermediate step success and wait for next webhook.
      await markStepSuccess(record, run.output_data);
    }

    async function markChainComplete(record: unknown) {}
    async function markChainCancelled(record: unknown) {}
    async function markChainFailed(record: unknown, status: string, error: unknown) {}
    async function markStepSuccess(record: unknown, outputData: unknown) {}
    async function cleanupSession(sessionId: string) {}

    app.post("/webhooks/cyberdesk", rawJson, async (req, res) => {
      const messageId = req.header("svix-id");
      const headers = {
        "svix-id": messageId!,
        "svix-timestamp": req.header("svix-timestamp")!,
        "svix-signature": req.header("svix-signature")!,
      };

      try {
        const payload = new Webhook(process.env.SVIX_WEBHOOK_SECRET!).verify(req.body, headers);
        assertRunCompletedEvent(payload);

        if (messageId && processedMessages.has(messageId)) {
          return res.json({ ok: true, duplicate: true });
        }
        if (messageId) processedMessages.add(messageId);

        await handleRunTerminal(payload.run);
        res.json({ ok: true });
      } catch (err) {
        if (err instanceof WebhookVerificationError) return res.status(400).end();
        res.status(500).end();
      }
    });
    ```
  </Tab>
</Tabs>

## Idempotency

Deduplicate using `svix-id` (header) or `event_id` (payload). Store processed ids and ignore duplicates.

## Finding webhooks by run ID

In the Svix dashboard, you can search for webhooks by their message ID. For most runs, the message ID equals the run ID, making it easy to find the webhook for a specific run.

<Note>
  **Retried runs:** If you [retry a run](/sdk-guides/python#retrying-a-run-same-run_id) (same run\_id), the second webhook will have a timestamped message ID (e.g., `abc123..._1702310400000`) to ensure uniqueness. The first webhook for any run will always use just the run ID.
</Note>

## Security Checklist

* Verify signatures and timestamp
* Use HTTPS only
* Rotate secrets when needed
* Return 2xx promptly; process work asynchronously

## Payload Transformations

If your webhook payloads are too large or you need to reshape the data before it reaches your endpoint, you can use **transformations** to modify the payload server-side. This is particularly useful for:

* Extracting only the fields your system needs
* Reformatting data to match your expected schema

See [Webhook Transformations](/webhooks/transformations) for setup instructions and examples.

## See Also

* Webhooks Quickstart
* API Reference → Event types and Run schema
* [Webhook Transformations](/webhooks/transformations) — Modify payloads before delivery
