API / Guides
Webhooks
Events, signatures and delivery behaviour.
Webhooks
What a webhook is
A webhook is a URL on your own server that we call when something happens.
Normally your tool asks us for things: you send a request, we send data back. A webhook turns that around. You give us a web address you control, and when a designer changes something in Designer Den, we send a message to that address straight away.
The alternative is polling: asking us every minute whether anything changed. Polling is wasteful, because almost every answer is "no", and it is slow, because you only find out at the next check. With a webhook you find out in seconds and make no wasted requests.
You would use one to keep your own copy of a designer's data current: they edit a card here, and your tool knows about it without anyone pressing refresh.
What you have to build
One page on your server that accepts a POST request.
That is the whole requirement. It needs to:
- Be reachable from the public internet over
https. - Read the JSON we send it.
- Check the signature, so you know the message really came from us.
- Answer quickly, then do whatever work it needs to afterwards.
Because it must be reachable from the internet, an address like
http://localhost:3000 will not work while you are
developing: we reject private and internal addresses. Use a tunnelling tool that gives your
local machine a temporary public URL, or a free request-capture service to watch the
deliveries arrive, then register your real URL when you deploy.
The whole flow, once
- You tell us your URL and which events you care about. We reply with a secret, a random string, shown once.
- A designer changes something.
- We put a delivery on a queue, so their action is never slowed down by your server.
- We POST the event to your URL, along with a signature made using your secret.
- You recompute that signature yourself. If it matches, the message is genuinely from us.
- You reply
200immediately, then do your work.
Step 1: register your URL
You do this once per designer, using their token. Your token needs the
webhooks:write scope, which is the permission that lets
an app manage webhooks at all.
curl -X POST https://dustinsdesignerden.com/api/v1/webhooks \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourtool.example.com/hooks/ddd",
"events": ["components.changed", "project.updated"]
}'
The reply contains a secret. Save it somewhere your
server can read it later, the same way you would store a password. We show it exactly once
and store only an encrypted copy, so we cannot show it to you again. If you lose it, delete
the webhook and register a new one.
A webhook only ever receives events belonging to the designer whose token created it, and only your app can see or delete it.
Step 2: what arrives at your URL
Every delivery looks like this. The outer fields are always the same; the part that differs
per event is inside data.
POST /hooks/ddd HTTP/1.1
Content-Type: application/json
User-Agent: DustinsDesignerDen-Webhook/1
X-DDD-Event: components.changed
X-DDD-Delivery: 6f1c1e7e-2a54-4a0d-9a5d-4b6e0b6a1d2f
X-DDD-Timestamp: 1785550000
X-DDD-Signature: sha256=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
{
"id": "6f1c1e7e-2a54-4a0d-9a5d-4b6e0b6a1d2f",
"event": "components.changed",
"created_at": "2026-07-31T21:02:13+00:00",
"data": {
"project_id": 1,
"type": "card",
"created": 2,
"updated": 1,
"deleted": 0,
"components": [
{ "id": 14591, "name": "Rusty Dagger", "type": "card" },
{ "id": 14592, "name": "Iron Shield", "type": "card" }
]
}
}
id |
A unique id for this one delivery. See "arriving twice" below for why you want it. |
event |
Which thing happened. Branch on this to decide how to read data. |
created_at |
When we sent it. |
data |
The details of that event, described next. |
Step 3: the events you can subscribe to
components.changed
Sent when components in a project were created, updated or deleted, from any source: your own writes, another connected app, a CSV or JSON import, or a Game Crafter sync. If a write happened but nothing actually changed, nothing is sent.
One delivery covers one write, not one component, so a single message can describe fifty cards at once. It tells you what changed and how many, but not the full details of each component. When you need those, read the components endpoint for that project.
project_id | Which project changed. |
type | The kind of component that write covered, for example card or token. |
created | How many were newly added. |
updated | How many already existed and were edited. |
deleted | How many were removed. |
components | The ones added or edited, each with its id, name and type. Deleted ones are counted but not listed. |
project.updated
Sent when a project's own details are edited through the API, such as its name or description, and at least one field really changed. Component edits do not trigger this; they have their own event above.
{
"project_id": 1,
"changed": ["genre", "short_description"]
}
changed lists the names of the fields that were written,
not their new values. Read the project back if you need those.
Step 4: check the signature
Your URL is a public address. Anyone who learns it can send it a fake message. The signature is how you tell a real delivery from a fake one.
It works because only you and we know the secret. Before sending, we combine the timestamp
and the exact body of the message with your secret and run them through a one-way function
(HMAC-SHA256), producing a string of hex. You do the same calculation on what you received.
If your result matches the X-DDD-Signature header, the
message came from someone holding the secret, and the body was not altered on the way. If it
does not match, throw the request away.
Two things to be careful of. Sign the raw body exactly as received: many frameworks parse JSON for you, and re-encoding it changes the bytes slightly, which changes the signature and makes every delivery look fake. And check the timestamp: the timestamp is signed along with the body, so an attacker who captured a real delivery cannot replay it later, as long as you reject anything with an old timestamp. Five minutes is a reasonable window.
$timestamp = $request->header('X-DDD-Timestamp');
$signature = $request->header('X-DDD-Signature');
// getContent() is the RAW body, not the parsed array. That matters.
$expected = 'sha256=' . hash_hmac(
'sha256',
$timestamp . '.' . $request->getContent(),
$secret
);
abort_unless(hash_equals($expected, (string) $signature), 400);
abort_if(abs(time() - (int) $timestamp) > 300, 400); // too old, refuse it
// Genuine. Acknowledge now, do the slow work on a queue.
HandleDesignerDenEvent::dispatch(json_decode($request->getContent(), true));
return response()->noContent();
const crypto = require('crypto');
const timestamp = req.headers['x-ddd-timestamp'];
const signature = req.headers['x-ddd-signature'] || '';
// rawBody must be the untouched request body. In Express, capture it with
// express.json({ verify: (req, res, buf) => { req.rawBody = buf.toString(); } })
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(timestamp + '.' + rawBody)
.digest('hex');
// timingSafeEqual THROWS when the buffers differ in length, so compare
// lengths first. Without this, a request with a short or missing signature
// header crashes the handler instead of being rejected.
const a = Buffer.from(expected);
const b = Buffer.from(signature);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(400).end();
}
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.status(400).end(); // too old, refuse it
}
// Genuine. Acknowledge now, do the slow work off the request.
res.status(200).end();
queue.push(JSON.parse(rawBody));
import hashlib, hmac, time
timestamp = request.headers.get('X-DDD-Timestamp', '')
signature = request.headers.get('X-DDD-Signature', '')
# request.data is the RAW body. Do not use request.json here: re-encoding
# it changes the bytes and the signature will never match.
expected = 'sha256=' + hmac.new(
secret.encode(),
f'{timestamp}.'.encode() + request.data,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, signature):
return '', 400
if abs(time.time() - int(timestamp)) > 300:
return '', 400 # too old, refuse it
# Genuine. Acknowledge now, do the slow work on a worker.
enqueue(request.get_json())
return '', 200
Step 5: answer quickly
Send back any 2xx status as soon as you have checked the
signature. Do not download images, rebuild your database or call other services first: put
the message on your own queue and reply. You have
10 seconds before we give up and count it as a
failure.
When things go wrong
- You are down, or too slow. A 5xx, a timeout or a connection error is retried up to 4 attempts, waiting 10 seconds, then 1 minute, then 5 minutes. A short outage will not lose the event.
- You reject it. Any 4xx other than 429 is taken as a deliberate refusal and is never retried, on the assumption you meant it. A delivery you rejected for a bad signature will not come back.
-
You stay broken. After
15 failures in a row we switch the endpoint off
rather than keep hammering a dead URL. The listing endpoint reports
is_activeandconsecutive_failuresso you can spot this. Register again once it is healthy. -
It arrives twice. Retries mean the same event can
reach you more than once, and messages are not guaranteed to arrive in the order they
happened. Record the
X-DDD-Deliveryid and ignore ones you have already handled, and make sure handling the same message twice does no harm.
Managing them later
GET /api/v1/webhooks lists the ones your app registered,
including whether each is still active and when it last delivered.
DELETE /api/v1/webhooks/{id} removes one. There is no way
to edit a webhook: to change its URL or its events, delete it and register again, which also
issues a fresh secret. See the
Webhooks reference
for the exact request and response shapes.