# Guide to Shortcut Webhooks: Features and Best Practices

Shortcut webhooks tell your systems when work moves: a story is created, changes state, gets a new owner or estimate, or an epic is updated. If your team plans and tracks work in Shortcut, the project-management tool formerly called Clubhouse, outgoing webhooks are how those changes reach Slack bots, CI pipelines, reporting and other services without polling the API.

This guide covers how Shortcut's outgoing webhooks are configured and signed, the structure of an event and its `actions` array, what Shortcut leaves undocumented, and the practices that keep a Shortcut receiver reliable.

## What are Shortcut webhooks?

An outgoing webhook in Shortcut is a URL that Shortcut notifies whenever something changes in your workspace. Shortcut's documentation calls this v1 of its Outgoing Webhook API and says that breaking changes, if any, will be listed in a change log on the same page.

There is no subscription to individual event types. A webhook fires whenever stories or epics are created, updated or deleted, and that includes changes to comments and tasks and workflow state changes made by version control integrations such as GitHub. Changes made by people in the app and changes made through API tokens both count.

Each delivery is a JSON `POST` describing one change event. The event does not have a single type. Instead it carries an `actions` array with one entry for each object the change touched, and each entry names its own `entity_type` and `action`.

Signing is optional. If you enter a secret when you create the webhook, every request carries a `Payload-Signature` header: a hex HMAC-SHA256 of the raw body. If you don't, requests arrive unsigned.

## Shortcut webhook features

| Feature | Details |
| --- | --- |
| Configuration | The webhook settings in your Shortcut workspace |
| Scope | Every story and epic change in the workspace, including comments and tasks |
| Event filter | None documented. One webhook receives everything |
| Event identity | `entity_type` and `action` on each item in the `actions` array |
| Signature header | `Payload-Signature`, only when a secret was entered |
| Scheme | HMAC-SHA256, lowercase hex, no prefix |
| Signed content | The raw request body, nothing else |
| Secret | A string you choose when you create the webhook |
| Replay window | None. No timestamp is sent or signed |
| Retries, timeout | Not documented |
| Versioning | `version` field in the body, currently `v1` |

Shortcut publishes no source IP list for webhooks, so when a secret is set, the signature is the only credential.

## Common events

Shortcut does not publish an event catalogue. It describes the `actions` array like this: each action has an `id` (the entity's ID), an `entity_type` "with values like `story` or `epic`", and an `action` "like `update` or `create`". Those four values are the only ones its documentation quotes:

| `entity_type` | `action` | Fires when |
| --- | --- | --- |
| `story` | `create` | A story is created |
| `story` | `update` | A story changes, for example its workflow state, owners, estimate, epic or iteration |
| `epic` | `create` | An epic is created |
| `epic` | `update` | An epic changes |

Deletions and changes to comments and tasks also fire webhooks, but Shortcut does not show the `entity_type` or `action` strings they use. Capture a real delivery before you write code that depends on them, and log combinations you don't recognise rather than rejecting them.

Here is Shortcut's example event, a story moving from Ready for Dev to Ready for Deploy:

```json
{
  "id": "595285dc-9c43-4b9c-a1e6-0cd9aff5b084",
  "changed_at": "2017-06-27T16:20:44Z",
  "primary_id": 16927,
  "member_id": "56d8a839-1c52-437f-b981-c3a15a11d6d4",
  "version": "v1",
  "actions": [
    {
      "id": 16927,
      "entity_type": "story",
      "action": "update",
      "name": "test story",
      "changes": {
        "started": { "new": true, "old": false },
        "workflow_state_id": { "new": 1495, "old": 1493 },
        "owner_ids": { "adds": ["56d8a839-1c52-437f-b981-c3a15a11d6d4"] }
      }
    }
  ],
  "references": [
    { "id": 1495, "entity_type": "workflow-state", "name": "Ready for Deploy" },
    { "id": 1493, "entity_type": "workflow-state", "name": "Ready for Dev" }
  ]
}

```

The top-level `id` is a UUID for the webhook event. `member_id` is the member who made the change, `changed_at` is when it happened, and the property table also lists `owner_ids` for the owners of any stories in the event. `primary_id` identifies the entity that caused the change, but Shortcut omits it when no single entity is responsible, so read the `id` on each action instead.

The `changes` object holds what moved: `old` and `new` for single values, and `adds` and `removes` for lists such as `owner_ids`. Changes refer to other objects by ID, and the `references` array supplies their names, which is how you turn `workflow_state_id: 1495` into "Ready for Deploy". References also carry an `entity_type` (here `workflow-state`), but they are context, not changes.

## Setting up Shortcut webhooks

1. Generate a long random secret, for example with `openssl rand -hex 32`, and store it server-side as `SHORTCUT_WEBHOOK_SECRET`.
2. In your Shortcut workspace, open the webhook settings and add an outgoing webhook.
3. Enter your HTTPS endpoint URL and the secret, then save.
4. Change a story to trigger an event, and check that your endpoint receives a signed request.

Shortcut's documentation only describes providing the secret when the webhook is created, and without it there is no `Payload-Signature` header, so don't skip it.

If you use Hookdeck, create a Shortcut source, set the same value as its **Webhook Signing Secret**, and use the source URL as the webhook URL in Shortcut. Verification on the source is optional: if you created the Shortcut webhook without a secret, leave verification off, because unsigned requests can never pass it.

For local testing, Shortcut suggests a tunnel such as Tailscale Funnel or ngrok, or a hosted request inspector. The Hookdeck CLI works the same way:

```bash
npx hookdeck-cli listen 3000 shortcut --path /webhooks/shortcut

```

## Securing Shortcut webhooks

Compute an HMAC-SHA256 of the raw request body, using your secret string as the key, encode it as hex, and compare it with `Payload-Signature` in constant time. Shortcut's documentation describes the algorithm but gives no code sample or test vector.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyShortcutSignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader || !secret) return false;

  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');

  const received = Buffer.from(signatureHeader.trim().toLowerCase(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/shortcut', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.SHORTCUT_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyShortcutSignature(req.body, req.get('payload-signature'), secret)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Loop over event.actions and route on entity_type + action. Persist, then process async.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac

def verify_shortcut_signature(raw_body: bytes, signature_header, secret: str) -> bool:
    if not signature_header or not secret:
        return False
    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    received = signature_header.strip().lower().encode("utf-8")
    return hmac.compare_digest(received, expected.encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# verify_shortcut_signature(raw_body, request.headers.get("payload-signature"),
#                           os.environ["SHORTCUT_WEBHOOK_SECRET"])

```

To check your verifier, use the secret `shortcut-test-secret` and this exact body:

```
{"id":"595285dc-9c43-4b9c-a1e6-0cd9aff5b084","version":"v1","actions":[{"id":16927,"entity_type":"story","action":"update"}]}

```

The signature should be:

```
59e23a6d9082ecb1711641c193e16d9d4d661e07df00994b1b098664062f1e55

```

> **Make Shortcut webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `Payload-Signature` header, answers Shortcut straight away, and durably queues every story and epic event before your service sees it.

## Shortcut webhook limitations and pain points

### Signing depends on a choice made once

**The Problem:** Requests arrive with no `Payload-Signature` header, so there is nothing to verify.

**Why It Happens:** Shortcut only signs events if a secret was entered when the outgoing webhook was created. A webhook created without one sends unsigned requests, and anyone who learns the URL can post to it.

**Workarounds:**

* Always create the webhook with a secret.
* If an existing webhook has none, create a new one with a secret, switch your receiver over, then remove the old one. Shortcut documents no way to add a secret afterwards.
* Reject requests without a valid signature once the secret is in place.

**How Hookdeck Can Help:** Hookdeck's Shortcut source checks `Payload-Signature` for you once the secret is set on both sides, so the key lives in one place instead of in every service that consumes Shortcut events.

### No retry or timeout policy is documented

**The Problem:** You can't tell whether an event that hit a failing endpoint will ever arrive again.

**Why It Happens:** Shortcut's webhook documentation describes the payload and the signature, but not how long it waits for a response, whether it retries, or whether it disables a failing webhook.

**Workarounds:**

* Keep the endpoint simple and fast: verify, store, respond, and process later.
* After an outage, reconcile the stories and epics you care about through the Shortcut REST API.
* Alert on gaps, for example when a busy workspace sends nothing for an hour.

**How Hookdeck Can Help:** Hookdeck accepts each event as soon as Shortcut sends it and stores it, then retries delivery to your service on a schedule you control and lets you replay anything that failed, so your own downtime doesn't depend on a retry policy Shortcut doesn't publish.

### No event catalogue

**The Problem:** Handlers miss deletions, comment edits or task changes, or crash on them.

**Why It Happens:** Shortcut only quotes the `story` and `epic` entity types and the `create` and `update` actions. The other strings that deletes, comments and tasks produce are not documented, and one delivery can mix several entity types in its `actions` array.

**Workarounds:**

* Capture real deliveries for the changes you care about before relying on their strings.
* Treat unknown `entity_type` and `action` pairs as "log and acknowledge", never as an error.
* Loop over every item in `actions` rather than reading only the first.

**How Hookdeck Can Help:** Hookdeck keeps every request with its headers and body, so you can search past deliveries for the exact strings Shortcut sent and replay them through updated code.

### No timestamp means no replay protection

**The Problem:** A captured request still verifies if it is sent again later.

**Why It Happens:** The signature covers only the body. Shortcut sends no timestamp header and signs nothing else.

**Workarounds:**

* Deduplicate on the top-level event `id`.
* Keep the endpoint on HTTPS.
* For actions with side effects, such as deploys, re-read the story from the API before acting.

**How Hookdeck Can Help:** Hookdeck's deduplication can drop repeated deliveries based on the event `id` before they reach your service.

## Best practices

### Verify against the raw body

The HMAC covers the exact bytes Shortcut sent. In Express, use `express.raw({ type: '*/*' })` on the webhook route and parse only after verification. In Next.js, read `await request.text()`. In FastAPI, read `await request.body()`.

### Route on each action, not on the event

Build a key such as `story.update` from each action's `entity_type` and `action`, and handle actions independently. A single event can update a story and an epic at once.

### Resolve IDs with `references`

Use the `references` array to turn workflow state, epic and other IDs in `changes` into names before you post them to Slack or a dashboard. That saves an API call per event.

### Deduplicate on the event `id`

The top-level `id` is a UUID for the webhook event. Store it before processing and skip anything you have already handled. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast and process asynchronously

Shortcut's own example server answers with `200` immediately. Do the same: verify, persist the event, respond, and do slow work such as CI triggers afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Shortcut's outgoing webhooks send one JSON event per change, with an `actions` array that says which stories and epics changed and how. Create the webhook with a secret so every request carries a hex HMAC-SHA256 in `Payload-Signature`, verify it against the raw body, route on each action's `entity_type` and `action`, deduplicate on the event `id`, and plan for the parts Shortcut doesn't document: retries, timeouts and the full list of action strings.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks the Shortcut signature, and holds each event until your service has processed it, so a failed delivery can be retried or replayed instead of lost.

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