# Guide to Deck Webhooks: Features and Best Practices

Deck runs computer-use agents that log in to websites on your users' behalf and return structured data. Agent work is slow and asynchronous. A task run can take seconds or minutes, it might stop halfway to ask the user for an MFA code, and a stored credential can be rejected at any point. Webhooks are how Deck tells your application about each of these changes without you polling the API.

This guide covers how Deck webhooks work, the events you can subscribe to, how to verify signatures, and the problems you're likely to run into in production.

## What are Deck webhooks?

Deck has a single event system. Every state change in your account (a credential being verified, a task run completing, a file being captured) produces an event, and Deck delivers each event to the **destinations** you subscribe.

A destination can be a message queue or bucket in your cloud account, or an HTTP endpoint. Deck supports ten destination types:

| Type | Identifier |
| --- | --- |
| Webhook | `webhook` |
| AWS SQS | `aws_sqs` |
| AWS Kinesis | `aws_kinesis` |
| AWS S3 | `aws_s3` |
| AWS EventBridge | `aws_eventbridge` |
| GCP Pub/Sub | `gcp_pubsub` |
| Azure Service Bus | `azure_servicebus` |
| Cloudflare Queues | `cloudflare_queues` |
| RabbitMQ | `rabbitmq` |
| Hookdeck | `hookdeck` |

This guide is about the `webhook` type: an HTTP `POST` of a JSON event to a URL you choose, signed using the Standard Webhooks specification. The cloud destinations use the same event envelope but authenticate with their own credentials (IAM keys, connection strings), so there's no signature to check.

## Deck webhook features

| Feature | Details |
| --- | --- |
| Configuration | Deck Console, or `POST /v2/event-destinations` |
| Event selection | Per destination, by event type, or `["*"]` for everything |
| Payload filter | Optional per-destination `filter` on the payload, with operators such as `$eq` and `$in` |
| Signature scheme | Standard Webhooks: base64 HMAC-SHA256 in `webhook-signature` |
| Signing secret | Optional, supplied by you (`whsec_` prefix), write-only |
| Secret rotation | Previous secret stays valid for 24 hours |
| Timeout | 2xx required within 5 seconds |
| Retries | Up to 10, exponential backoff (base 2, starting at 30 seconds) |
| Delivery guarantee | At-least-once; events may arrive out of order |
| Auto-disable | Yes; a destination is set to `inactive` after 100 consecutive failed deliveries |
| Delivery log | `GET /v2/event-destinations/{id}/event-deliveries` |
| Test event | `test.ping`, from the Console or `POST /v2/event-destinations/{id}/test` |
| Static IPs | Yes, for webhook destinations |
| Event retention | 30 days |

## Event types

Deck names events `object.action`. There are 42 event types across ten resources:

| Resource | Events |
| --- | --- |
| Task runs | `task_run.queued`, `task_run.running`, `task_run.completed`, `task_run.failed`, `task_run.canceling`, `task_run.canceled`, `task_run.interaction_required` |
| Credentials | `credential.created`, `credential.unverified`, `credential.verified`, `credential.invalid`, `credential.deleted` |
| Workflow runs | `workflow_run.queued`, `workflow_run.running`, `workflow_run.paused`, `workflow_run.completed`, `workflow_run.failed`, `workflow_run.canceling`, `workflow_run.canceled` |
| Sessions | `session.queued`, `session.running`, `session.idle`, `session.completing`, `session.completed`, `session.failed` |
| Storage | `storage.created`, `storage.deleted` |
| Tasks | `task.created`, `task.updated`, `task.reset`, `task.deleted` |
| Workflows | `workflow.created`, `workflow.updated`, `workflow.deleted` |
| Agents | `agent.created`, `agent.updated`, `agent.deleted` |
| Sources | `source.created`, `source.updated`, `source.deleted` |
| Event destinations | `event_destination.activated`, `event_destination.deactivated` |

Most integrations care about a handful of these:

* **`task_run.completed`** and **`task_run.failed`**: a run has finished. Check `data.result` (`success` or `failure`) and fetch the output from the API.
* **`task_run.interaction_required`**: the agent is paused waiting for your user, usually for an MFA code. This one is time-sensitive (see below).
* **`credential.verified`** and **`credential.invalid`**: a user's stored login worked, or the website rejected it and you need to ask them to update it.
* **`storage.created`**: the agent captured a file, such as a PDF statement.

There's also `test.ping`, which sits outside the catalogue. You can't subscribe to it. Deck sends it only when you send a test event to a destination, and its `data` is `{"ok": true}`.

## Webhook payload structure

Every event uses the same envelope. Here's a `task_run.completed` event from Deck's events reference:

```json
{
  "id": "evt_a1b2c3d4",
  "object": "event",
  "type": "task_run.completed",
  "data": {
    "id": "trun_a1b2c3d4",
    "object": "task_run",
    "status": "completed",
    "result": "success",
    "task_id": "task_a1b2c3d4",
    "workflow_id": "",
    "credential_id": "cred_a1b2c3d4",
    "session_id": "sess_a1b2c3d4",
    "agent_id": "agt_a1b2c3d4",
    "source_id": "src_a1b2c3d4",
    "runtime_ms": 12453,
    "created_at": "2025-01-15T09:30:00Z",
    "updated_at": "2025-01-15T09:31:15Z"
  },
  "created_at": "2025-01-15T09:31:15Z"
}

```

## Key payload fields

| Field | Description |
| --- | --- |
| `id` | Unique event ID with the `evt_` prefix. Use it to deduplicate. |
| `object` | Always `event`. Don't route on it. |
| `type` | The event type, e.g. `task_run.completed`. Route on this. |
| `data` | A summary of the resource at the time of the event. Its shape depends on the resource. |
| `created_at` | When the event occurred (ISO 8601). Use it to order events. |
| `request_id` | The API request that caused the event (`req_` prefix). Only present when an API call triggered it. |

A few details in `data` will catch you out if you write one generic handler:

* Fields with no value yet are delivered as **empty strings**, not `null`. A task run that isn't part of a workflow has `"workflow_id": ""`, and `result` is `""` until the run finishes.
* The `workflow.*` and `workflow_run.*` events are the exception. Their `data` has `workflow_id` or `workflow_run_id` and `org_id` instead of `id` and `object`, and `workflow_run.*` uses `null` for `result` and `trigger_id` until they're set.
* `task_run.interaction_required` adds an `interaction` object describing what the agent needs from the user.

> **Inspect Deck webhooks as they arrive.** Point a destination at [Hookdeck Inspect](https://inspect.hookdeck.com) to see the full headers and body of every delivery. No account or setup required.

## Security with Standard Webhooks signatures

Deck signs webhook deliveries using the Standard Webhooks specification. Each request carries three headers:

| Header | Description |
| --- | --- |
| `webhook-id` | Unique event identifier |
| `webhook-timestamp` | Unix timestamp of when the delivery was sent |
| `webhook-signature` | Base64-encoded HMAC-SHA256 signature in `v1,<signature>` format |

The signature covers `{webhook-id}.{webhook-timestamp}.{raw body}`, keyed with your signing secret. Under the spec, the header can hold several space-separated `v1,` signatures, and a delivery is valid if any one of them matches.

Unlike most Standard Webhooks providers, **Deck doesn't generate the secret.** You create it yourself and pass it as `webhook.secret` when you create the destination. It's optional, and Deck stores it write-only: you can't read it back after creation. If you create a webhook destination without a secret, you have nothing to verify signatures against, so generate one before you start:

```javascript
const crypto = require("crypto");
const secret = "whsec_" + crypto.randomBytes(32).toString("base64");

```

Save that value in your secrets manager straight away, because Deck won't show it to you again.

When you rotate the secret, Deck keeps the previous one valid for 24 hours. Deliveries during that window can be signed with either secret, so your handler should try both.

## Setting up Deck webhooks

### Via the Deck Console

Create a destination in the Deck Console, choose **Webhook**, enter your endpoint URL and signing secret, and pick the events to subscribe to. If you don't have an endpoint yet, choose **Test endpoint** instead. Deck provisions a disposable [Hookdeck Inspect](https://inspect.hookdeck.com) URL and adds an **Inspect deliveries** button to the destination, so you can see real payloads before writing any code. Anyone with the inspect link can read the deliveries, so only use it with test data.

### Via the API

```bash
curl -X POST https://api.deck.co/v2/event-destinations \
  -H "Authorization: Bearer $DECK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Production Webhook",
    "type": "webhook",
    "webhook": {
      "url": "https://your-app.com/webhooks/deck",
      "secret": "whsec_..."
    },
    "events": ["credential.verified", "credential.invalid", "task_run.completed", "task_run.failed", "task_run.interaction_required"]
  }'

```

Use `"events": ["*"]` to subscribe to every event type. You can also send extra headers on every delivery with `webhook.custom_headers`, a JSON-encoded string.

### Destination activation

A new destination starts in `pending_verification` while Deck waits for a successful response from your endpoint. Once it gets one, the destination becomes `active` and Deck fires `event_destination.activated`. Deck doesn't describe a challenge request, so make sure your endpoint is deployed and returns a 2xx before you create the destination. A destination that's still pending can't receive test events; the test endpoint returns `422`.

To pause deliveries during maintenance, set the destination's status to `inactive` with `PATCH /v2/event-destinations/{id}`, then back to `active` when you're ready.

## Best practices when working with Deck webhooks

### Verify signatures with the Standard Webhooks SDK

Deck recommends the official Standard Webhooks libraries rather than hand-rolled verification. They handle the signature format, a timestamp check, and constant-time comparison. Verify against the raw request body, and fall back to the previous secret during a rotation:

```javascript
const express = require("express");
const { Webhook } = require("standardwebhooks");

const secrets = [
  process.env.DECK_WEBHOOK_SECRET,
  process.env.DECK_WEBHOOK_SECRET_PREVIOUS, // set only during a rotation
].filter(Boolean);

function verifyDeckWebhook(rawBody, headers) {
  for (const secret of secrets) {
    try {
      return new Webhook(secret).verify(rawBody, headers);
    } catch {
      // try the next secret
    }
  }
  throw new Error("Invalid signature");
}

const app = express();

app.post("/webhooks/deck", express.raw({ type: "*/*" }), async (req, res) => {
  if (secrets.length === 0) return res.status(500).send("Secret not configured");

  let event;
  try {
    event = verifyDeckWebhook(req.body.toString("utf8"), {
      "webhook-id": req.get("webhook-id"),
      "webhook-timestamp": req.get("webhook-timestamp"),
      "webhook-signature": req.get("webhook-signature"),
    });
  } catch {
    return res.status(401).send("Invalid signature");
  }

  await queue.enqueue(event); // process asynchronously
  res.status(200).json({ received: true });
});

```

The Python library works the same way:

```python
from standardwebhooks.webhooks import Webhook

def verify_deck_webhook(raw_body: bytes, headers: dict, secrets: list[str]) -> dict:
    for secret in secrets:
        try:
            return Webhook(secret).verify(raw_body, headers)
        except Exception:
            continue
    raise ValueError("Invalid signature")

```

The handler returns a 500 when no secret is configured, so a missing environment variable can't turn into accepting unverified deliveries.

The SDKs also reject a `webhook-timestamp` more than five minutes from the current time. That window rejects any delivery that is held and sent later with Deck's original headers, such as a Hookdeck retry or replay, so it isn't protection when deliveries come through Hookdeck. Because the timestamp is part of the signed content it can't be altered, so deduplicating on the event `id` is enough to stop a captured request being processed twice. If you keep an age check, make it longer than the full span of retries and replays. Alternatively, let Hookdeck verify the Deck signature and check [Hookdeck's own signature](/docs/authentication#hookdeck-webhook-signature-verification) in your handler instead.

### Acknowledge within 5 seconds

Deck treats anything other than a 2xx within 5 seconds as a failure and schedules a retry. Your original request may still be running when the retry arrives, so a slow handler can process the same event twice. Persist the event, return 200, and do the work in a background job.

### Deduplicate on the event ID

Deck guarantees at-least-once delivery, so the same event can arrive more than once. Use the event `id` as an idempotency key, and return 200 for duplicates rather than an error:

```javascript
async function processEvent(event) {
  const isNew = await redis.set(`deck:${event.id}`, "1", "NX", "EX", 86400);
  if (!isNew) return; // already handled

  await handleDeckEvent(event);
}

```

### Order by created_at, not arrival

Events can arrive out of order. A `task_run.completed` might land before `task_run.running` if the earlier delivery was retried. Store the `created_at` of the last event you applied for each resource, and skip anything older.

### Answer interactions quickly

When a task run hits an MFA prompt, security question or account picker, Deck pauses the agent and sends `task_run.interaction_required`. Your user has **5 minutes** to respond. If the window closes, the run fails with an `interaction_timeout` error and isn't retried.

Build the prompt from `data.interaction.fields` (don't assume a single `code` field) and submit the answer with `POST /v2/task-runs/{run_id}/interaction`, wrapping the values in an `input` object. Route this event to a fast path. If it waits in the same queue as slow processing jobs, your user can run out of time.

## Deck webhook limitations and pain points

### The signing secret is optional and unrecoverable

**The Problem:** Deck doesn't create a signing secret for you. A destination created without one sends deliveries you can't verify, and a secret you did set can't be retrieved from Deck if you lose it.

**Why It Happens:** The secret is an optional field on the destination, described in Deck's docs as "Optional and caller-provided", and the API never returns it once it's set.

**Workarounds:**

* Generate a `whsec_` secret before creating any webhook destination
* Store it in your secrets manager at the moment you create it
* If a secret is lost, set a new one and run both through the 24-hour rotation window

**How Hookdeck Can Help:** Hookdeck's Deck source type checks the Standard Webhooks signature for you, using the secret you paste into the source's webhook signing secret field. The Deck secret lives in one place, your Hookdeck source configuration, rather than in every service that consumes Deck events. Hookdeck returns a 200 to every Deck delivery; a request that fails verification is recorded in Hookdeck but not delivered to your destinations.

### A 5-second timeout for agent workloads

**The Problem:** Deck allows 5 seconds for a 2xx. Handlers that fetch task run output, write files to storage or call another service will often take longer. Each timeout triggers a retry, and the retry can overlap the request that's still running.

**Why It Happens:** Deck's delivery contract is a 2xx within 5 seconds. Anything slower counts as a failed delivery.

**Workarounds:**

* Acknowledge immediately and process in a background worker
* Keep the handler to verification and an enqueue
* Make processing idempotent so overlapping retries are harmless

**How Hookdeck Can Help:** Hookdeck accepts the delivery from Deck straight away and queues it, then delivers to your endpoint with your own timeout and retry policy. Your handler no longer has to finish inside Deck's 5-second window.

### Duplicate and out-of-order events

**The Problem:** The same event can arrive more than once, and lifecycle events can arrive in the wrong order. A handler that sets a run's status from whichever event came last can end up showing a finished run as still running.

**Why It Happens:** Deck delivers at-least-once and retries failures, so a retried event can land after a later one.

**Workarounds:**

* Deduplicate on the event `id`
* Compare `created_at` against the last applied event before updating state
* Treat terminal states (`completed`, `failed`, `canceled`) as final

**How Hookdeck Can Help:** Hookdeck's [deduplication](/docs/deduplication) drops repeated deliveries before they reach your endpoint, and every event is stored and searchable, so you can trace exactly what arrived and when.

### Interaction events have a 5-minute deadline

**The Problem:** `task_run.interaction_required` is only useful if your user sees it and answers within 5 minutes. A delayed delivery, a backed-up queue or a crashed worker can cost the user the run.

**Why It Happens:** Each interaction has a fixed 5-minute window, and a run that times out isn't retried automatically.

**Workarounds:**

* Handle this event on its own fast path, separate from bulk processing
* Notify the user through a channel they'll see immediately
* Monitor for `interaction_timeout` failures so you can spot when your pipeline is too slow

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can route `task_run.interaction_required` to a dedicated destination, so these events never wait behind slower work.

### A failing endpoint gets its destination deactivated

**The Problem:** An outage or a bad deploy on your side can switch off your Deck events. Once deliveries to a destination fail 100 times in a row, Deck sets it to `inactive` and stops delivering to it until you reactivate it. Before that, each failed delivery retries up to 10 times and then sits as a `failure` record, which is easy to miss.

**Why It Happens:** Deck counts consecutive failed deliveries per destination. A successful delivery resets the count; the 100th failure in a row deactivates the destination and fires `event_destination.deactivated`. Individual failed deliveries are recorded in the deliveries log rather than pushed to you.

**Workarounds:**

* Subscribe a second destination, such as a queue or a separate endpoint, to `event_destination.deactivated` so you're alerted even when the main endpoint is down
* Poll `GET /v2/event-destinations/{id}/event-deliveries?status=failure` and alert on new failures before they reach 100
* Once the endpoint is fixed, set the destination back to `active` with `PATCH /v2/event-destinations/{id}`, and fetch the latest state of your task runs and credentials from the API to cover the gap

**How Hookdeck Can Help:** Hookdeck accepts each Deck delivery as soon as it arrives, so an outage on your side doesn't count towards Deck's 100 failures. Hookdeck then retries delivery to your endpoint on a schedule you control, raises [Issues](/docs/issues) and sends notifications when those deliveries fail, and lets you retry failed events individually or in bulk once the fix is out.

## Testing Deck webhooks

### Send a test ping

Once a destination is active, click **Send test ping** in the Deck Console or call `POST /v2/event-destinations/{destination_id}/test`. Deck sends a `test.ping` event and reports the attempt's `status` and `response_code`. Test pings are delivered once and never retried.

### Use a test endpoint

Deck's **Test endpoint** option creates a Hookdeck Inspect URL for the destination, so you can look at real event payloads before you've built a receiver.

### Local development with Hookdeck

Deck's own docs recommend the [Hookdeck CLI](/docs/cli) for local testing:

1. Start a tunnel to your local server: `npx hookdeck-cli listen 3000 deck --path /webhooks/deck`
2. Use the Hookdeck URL it prints as your Deck webhook destination URL
3. Trigger a task run and watch the events arrive locally
4. Replay any event from the Hookdeck dashboard while you iterate on your handler

The Hookdeck URL stays the same between CLI sessions, so you don't have to update the Deck destination every time you restart.

## Conclusion

Deck webhooks follow the Standard Webhooks specification, with one difference that matters: you create and keep the signing secret yourself, and a destination without one can't be verified.

In production, most problems come down to timing: acknowledge within 5 seconds, expect duplicates and out-of-order events, and don't let `task_run.interaction_required` wait behind slower work.

[Hookdeck Event Gateway](/event-gateway) takes care of the delivery side: signature verification, queueing, deduplication, routing and retries, with a full record of every event Deck sends.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Deck webhooks reliably in minutes.

## Additional resources

* [Deck events overview](https://docs.deck.co/events/events)
* [Deck events reference](https://docs.deck.co/events/events-reference)
* [Deck destinations and deliveries](https://docs.deck.co/events/destinations-and-deliveries)
* [Deck webhook best practices](https://docs.deck.co/events/webhook-best-practices)
* [Deck handling interactions guide](https://docs.deck.co/guides/interactions)