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:
{
"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
- Generate a long random secret, for example with
openssl rand -hex 32, and store it server-side asSHORTCUT_WEBHOOK_SECRET. - In your Shortcut workspace, open the webhook settings and add an outgoing webhook.
- Enter your HTTPS endpoint URL and the secret, then save.
- 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:
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.
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:
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 verifies the
Payload-Signatureheader, 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_typeandactionpairs as "log and acknowledge", never as an error. - Loop over every item in
actionsrather 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.
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.
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 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 for free and handle Shortcut webhooks reliably in minutes.