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_ |
| 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_, event_ |
Most integrations care about a handful of these:
task_run.completedandtask_run.failed: a run has finished. Checkdata.result(successorfailure) and fetch the output from the API.task_: the agent is paused waiting for your user, usually for an MFA code. This one is time-sensitive (see below).run.interaction_ required credential.verifiedandcredential.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:
{
"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": "", andresultis""until the run finishes. - The
workflow.*andworkflow_run.*events are the exception. Theirdatahasworkflow_idorworkflow_run_idandorg_idinstead ofidandobject, andworkflow_run.*usesnullforresultandtrigger_iduntil they're set. task_adds anrun.interaction_ required interactionobject describing what the agent needs from the user.
Inspect Deck webhooks as they arrive. Point a destination at Hookdeck Inspect 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:
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 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
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_. 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:
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:
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 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:
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_. 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_, 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_atagainst the last applied event before updating state - Treat terminal states (
completed,failed,canceled) as final
How Hookdeck Can Help: Hookdeck's 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_ 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_timeoutfailures so you can spot when your pipeline is too slow
How Hookdeck Can Help: Hookdeck filters can route task_ 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_. 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_so you're alerted even when the main endpoint is downdestination.deactivated - Poll
GET /v2/event-destinations/{id}/event-deliveries?status=failureand alert on new failures before they reach 100 - Once the endpoint is fixed, set the destination back to
activewithPATCH /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 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_. 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 for local testing:
- Start a tunnel to your local server:
npx hookdeck-cli listen 3000 deck --path /webhooks/deck - Use the Hookdeck URL it prints as your Deck webhook destination URL
- Trigger a task run and watch the events arrive locally
- 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_ wait behind slower work.
Hookdeck 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 for free and handle Deck webhooks reliably in minutes.