Agent skill
Deck Webhooks Skill
Receive and verify Deck (deck.co) webhooks, the event stream for Deck's computer-use agents API (agents, sources, Deck Vault credentials, sessions, tasks, task runs, workflows, storage). Use when setting up a Deck webhook event destination, generating the caller-provided whsec_ signing secret, debugging Standard Webhooks signature verification (webhook-id, webhook-timestamp, webhook-signature), rotating secrets, or handling events like task_run.completed, task_run.failed, task_run.interaction_required (MFA prompts), credential.verified, credential.invalid, workflow_run.completed, storage.created and test.ping.
Install this skill
npx skills add hookdeck/webhook-skills --skill deck-webhooks
Deck is an API for computer-use agents that log in to websites on a user's behalf. Deck pushes events to event destinations. This skill covers the webhook destination type: HTTP POST deliveries signed with Standard Webhooks.
When to Use This Skill
- How do I receive Deck webhooks?
- How do I verify a Deck webhook signature?
- Where do I get my Deck webhook signing secret? (You generate it yourself.)
- How do I handle
task_and answer an MFA prompt?run.interaction_ required - Why is my Deck
webhook-signatureverification failing? - How do I rotate a Deck webhook secret without downtime?
- My Deck destination is stuck in
pending_verification. What's wrong? - Why did my Deck destination switch to
inactive?
The Secret Is Yours to Generate
Deck does not issue a signing secret. webhook.secret is optional and caller-provided: you generate it, send it when you create the destination, and Deck can't show it to you again. Without one, you can't verify deliveries.
# "whsec_" + base64 of random bytes (24–32 bytes is the Standard Webhooks
# convention; Deck's docs don't specify a length)
node -e "console.log('whsec_' + require('crypto').randomBytes(32).toString('base64'))"
curl -X POST https://api.deck.co/v2/event-destinations \
-H "Authorization: Bearer sk_live_..." -H "Content-Type: application/json" \
-d '{"name":"Production Webhook","type":"webhook",
"webhook":{"url":"https://your-app.com/webhooks/deck","secret":"whsec_..."},
"events":["credential.verified","task_run.completed","task_run.failed"]}'
Add an optional filter to deliver only matching events, for example "filter": {"data": {"agent_ (operators include $eq, $neq, $in, $nin, $gt, $startsWith, $exist, $and, $or, $not). It applies to retries and test.ping too.
New destinations start as pending_verification and become active (firing event_) once your endpoint returns a 2xx. Deck doesn't document a challenge or handshake. Your endpoint has to be reachable and return a 2xx. See references/setup.md.
Verification (core)
| Header | Value |
|---|---|
webhook-id | Unique event identifier |
webhook-timestamp | Unix seconds when the delivery was sent |
webhook-signature | v1,<base64 HMAC-SHA256>, possibly several space-separated entries |
Signed content is {webhook-id}.{webhook-timestamp}.{raw body}. The key is the base64-decoded part of the secret after whsec_. Deck's docs say to use the official SDK (standardwebhooks on npm and PyPI). It enforces a 5-minute timestamp tolerance and compares in constant time.
const { Webhook } = require('standardwebhooks');
// Current secret first; Deck keeps the previous one valid 24h after rotation.
const secrets = [process.env.DECK_WEBHOOK_SECRET, process.env.DECK_WEBHOOK_SECRET_PREVIOUS].filter(Boolean);
if (secrets.length === 0) throw new Error('DECK_WEBHOOK_SECRET not set'); // fail closed → 500
function verifyDeckWebhook(rawBody, headers) { // rawBody: exact bytes, NOT re-serialised JSON
let lastError;
for (const secret of secrets) {
try {
return new Webhook(secret).verify(rawBody, headers); // strips whsec_, base64-decodes key
} catch (err) { lastError = err; }
}
throw lastError; // → 400 Invalid signature
}
Python: from standardwebhooks.webhooks import Webhook, then Webhook(secret).verify(await request.body(), headers).
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
Event Envelope
{ "id": "evt_...", "object": "event", "type": "task_run.completed",
"data": { "id": "trun_...", "object": "task_run", "status": "completed", "result": "success", "...": "..." },
"created_at": "2025-01-15T09:31:15Z", "request_id": "req_..." }
type is the discriminator (object.action). request_id is only present when an API call caused the event. Fields with no value yet arrive as empty strings, for example "workflow_id": "". Exception: workflow_run.* uses null for result and trigger_id, and uses workflow_run_id instead of id.
Common Event Types
| Event | Fired when |
|---|---|
task_ | A run needs user input (mfa, account_selection, security_question). Time-sensitive (5-minute window) |
task_run.completed / task_run.failed | Run finished; result is success / failure |
credential.verified / credential.invalid | Deck Vault credential worked / was rejected by the source |
workflow_run.completed / workflow_run.failed / workflow_run.paused | Workflow run state changes |
storage.created | A file was captured during a task run |
event_ / event_ | Destination status changed. deactivated also fires when Deck auto-deactivates a destination after 100 consecutive failures |
test.ping | Console Send test ping or POST /v2/event-destinations/{id}/test. Not subscribable, never retried. Return a 2xx |
Also: agent.*, source.*, credential.created|unverified|deleted, session.*, task.*, task_, workflow.*, workflow_, and storage.deleted. references/overview.md lists all of them with payloads.
To answer an interaction, read data.interaction.fields and call POST /v2/task-runs/{data.id}/interaction with {"input": {"<field name>": "<value>"}}.
Delivery Semantics
- Return a
2xxwithin 5 seconds, or Deck counts the delivery as failed and retries it even if your original request is still running. Enqueue the event and acknowledge it; do the processing asynchronously. - Up to 10 retries with exponential backoff (base 2, starting at 30s). Inspect failures with
GET /v2/event-destinations/{id}/event-deliveries?status=failure. - Auto-deactivation: if deliveries to a destination fail 100 times in a row, Deck sets it to
inactiveand stops delivering (a success resets the count). Deck emitsevent_, so subscribe a different destination to it. Once the endpoint is fixed,destination.deactivated PATCH /v2/event-destinations/{id}with{"status": "active"}. - At-least-once delivery. Dedupe on the event
id(evt_...) and return200for duplicates. - Order isn't guaranteed. Compare the event's
created_atwith your last-seen state and drop stale updates. - Static delivery IPs (
34.139.110.242,35.237.129.153,34.24.255.163) are optional defence in depth and don't replace signature checks.
Environment Variables
DECK_WEBHOOK_SECRET=whsec_your_generated_base64_secret # required; handlers fail closed without it
DECK_WEBHOOK_SECRET_PREVIOUS= # optional, during the 24h rotation window
Local Development
# Express / Next.js (use 8000 for FastAPI). No install or account required.
npx hookdeck-cli listen 3000 deck --path /webhooks/deck
Use the printed URL as webhook.url. Deck's own docs recommend Hookdeck for local testing. To look at payloads before you have a receiver, choose Test endpoint in the Deck Console. Deck also has a native hookdeck destination type (hookdeck.token = Hookdeck source token), which routes events through the Event Gateway without Standard Webhooks signing on Deck's side.
Reference Materials
- references/overview.md - Full event catalog, payload shapes, interactions
- references/setup.md - Generating the secret, creating the destination, filters, activation, auto-deactivation, rotation, IPs
- references/verification.md - SDK and manual verification, gotchas, debugging